diff --git a/installer/YMhutBox-EULA.zh-CN.txt b/installer/YMhutBox-EULA.zh-CN.txt
new file mode 100644
index 0000000..7daa498
--- /dev/null
+++ b/installer/YMhutBox-EULA.zh-CN.txt
@@ -0,0 +1,48 @@
+YMhut Box 软件许可与服务协议
+
+更新日期:2026 年 7 月 25 日
+
+请在安装和使用 YMhut Box 前完整阅读本协议。点击“我已阅读并同意”或继续安装,即表示您已理解并同意本协议的全部内容;如不同意,请退出安装程序。
+
+一、许可范围
+
+1. YMhut Box 授予您一项有限的、非独占的、不可转让的使用许可,用于在您拥有或合法管理的 Windows 设备上安装和使用本软件。
+2. 本许可不代表软件、品牌、界面设计或相关服务的所有权发生转移。
+3. 未经明确授权,不得利用本软件从事违法活动、破坏系统或网络安全、侵害他人权益,或规避第三方服务的付费、地区及版权限制。
+
+二、安装、更新与卸载
+
+1. 安装程序会检查 Windows 版本、系统架构、目标目录、磁盘空间以及必要运行组件。
+2. 当 WebView2 Runtime 或 Microsoft Visual C++ x64 Runtime 缺失时,安装程序可能从微软官方地址下载并安装相应组件,并可能请求管理员权限。
+3. 升级或修复安装将尽量保留用户设置和本地数据;卸载前请自行备份重要内容。
+
+三、数据与网络访问
+
+1. 软件设置、缓存、登录凭据和运行日志原则上保存在本机;敏感登录信息按产品实现使用 Windows 系统能力进行保护。
+2. 天气、更新、反馈、网络音乐和其他联网功能会访问相应服务提供方。具体可用性、内容和处理规则同时受该服务提供方条款约束。
+3. 为诊断故障,软件可能在本机记录必要的错误信息。日志不应被用于存储用户主动输入的密码等敏感内容。
+
+四、第三方组件与服务
+
+1. 软件可能包含或调用微软运行库、硬件监控组件及其他第三方组件。相关组件的权利由其权利人享有,并适用各自的许可或服务条款。
+2. 对于跳转至第三方网站、下载地址或服务产生的内容、费用、账户及可用性,应以第三方实际规则为准。
+
+五、功能与风险提示
+
+1. 硬件监控结果受设备、驱动、权限和传感器能力影响,仅供参考。
+2. 系统优化、注册表、服务、电源、网络和驱动相关操作可能改变系统行为。请在执行前阅读变更预览并保留可用备份或还原点。
+3. 因设备差异、系统策略、第三方服务中断或不可抗力,部分功能可能暂时不可用。
+
+六、责任限制
+
+在法律允许的范围内,YMhut Box 不对因错误配置、未经授权的系统操作、第三方服务变化、网络故障或用户未备份数据造成的间接损失承担责任。本条不排除依法不得限制或排除的责任。
+
+七、协议更新与终止
+
+1. 软件功能、服务方式或合规要求发生变化时,本协议可能更新。重要变更将在软件或发布渠道中提示。
+2. 如您违反本协议或利用软件从事违法行为,相关使用许可可以被终止。
+3. 协议终止后,您应停止使用并可通过系统卸载功能移除软件。
+
+八、联系与反馈
+
+您可以通过 YMhut Box 内置反馈页面提交功能建议、问题报告或协议相关咨询。
diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss
index 4d84b32..e2e3903 100644
--- a/installer/ymhut_box_winui.iss
+++ b/installer/ymhut_box_winui.iss
@@ -482,6 +482,11 @@ begin
QueueInstallOutput(Text, True, True);
end;
+procedure LogBootstrapEvent(const EventName, Value: string);
+begin
+ Log('YMHUT_EVENT:' + EventName + '|' + Value);
+end;
+
procedure LogCurrentExtractFile();
var
FileName: string;
@@ -718,28 +723,67 @@ begin
Result := FmtMessage(CustomMessage(Key), [Value1, Value2]);
end;
-function DownloadAndInstallPrerequisite(const Url, FileName, Arguments, FriendlyName: string; var NeedsRestart: Boolean): Boolean;
+function AcquireAndInstallPrerequisite(
+ const Url, DownloadFileName, BundledFileName, Arguments, FriendlyName: string;
+ const Bundled: Boolean; var NeedsRestart: Boolean): Boolean;
var
+ Attempt: Integer;
+ Downloaded: Boolean;
+ LastDownloadError: string;
ResultCode: Integer;
ExecutablePath: string;
begin
Result := False;
- ExecutablePath := ExpandConstant('{tmp}\' + FileName);
- DownloadPage.Clear;
- DownloadPage.Add(Url, FileName, '');
- if not WizardSilent then
- DownloadPage.Show;
- try
- DownloadPage.Download;
- except
- if DownloadPage.AbortedByUser then
- RaiseException(FormatCustomMessage('DependencyDownloadCancelled', FriendlyName))
- else
- RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, GetExceptionMessage));
- end;
- if not WizardSilent then
- DownloadPage.Hide;
+ if Bundled then
+ begin
+ LogBootstrapEvent('DEPENDENCY', 'bundled|' + FriendlyName);
+ ExtractTemporaryFile(BundledFileName);
+ ExecutablePath := ExpandConstant('{tmp}\' + BundledFileName);
+ end
+ else
+ begin
+ ExecutablePath := ExpandConstant('{tmp}\' + DownloadFileName);
+ Downloaded := False;
+ LastDownloadError := '';
+ for Attempt := 1 to 3 do
+ begin
+ LogBootstrapEvent('DEPENDENCY', 'download|' + FriendlyName);
+ DownloadPage.Clear;
+ DownloadPage.Add(Url, DownloadFileName, '');
+ if not WizardSilent then
+ DownloadPage.Show;
+ try
+ try
+ DownloadPage.Download;
+ Downloaded := True;
+ except
+ if DownloadPage.AbortedByUser then
+ RaiseException(FormatCustomMessage('DependencyDownloadCancelled', FriendlyName))
+ else
+ LastDownloadError := GetExceptionMessage;
+ end;
+ finally
+ if not WizardSilent then
+ DownloadPage.Hide;
+ end;
+ if Downloaded then
+ Break;
+ if Attempt < 3 then
+ begin
+ LogBootstrapEvent('DEPENDENCY', 'retry|' + FriendlyName);
+ Sleep(Attempt * 1500);
+ end;
+ end;
+
+ if not Downloaded then
+ RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, LastDownloadError));
+ end;
+
+ if not FileExists(ExecutablePath) then
+ RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, 'downloaded file is missing'));
+
+ LogBootstrapEvent('DEPENDENCY', 'install|' + FriendlyName);
if not Exec(ExecutablePath, Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
RaiseException(FormatCustomMessage('DependencyStartFailed', FriendlyName));
@@ -749,9 +793,16 @@ begin
if (ResultCode = 3010) or (ResultCode = 1641) then
NeedsRestart := True;
+ LogBootstrapEvent('DEPENDENCY', 'complete|' + FriendlyName);
Result := True;
end;
+procedure SetSetupFailure(var ResultText: string; const MessageText: string);
+begin
+ ResultText := MessageText;
+ LogBootstrapEvent('FAILURE', MessageText);
+end;
+
procedure CopyIfExists(const Source, TargetDir: string);
begin
if FileExists(Source) then
@@ -900,46 +951,57 @@ end;
function PrepareToInstall(var NeedsRestart: Boolean): string;
begin
AppendInstallOutput('Prepare system prerequisites.');
+ LogBootstrapEvent('STAGE', 'prerequisites');
Result := '';
#if Int(QAIsolated) == 1
Exit;
#endif
if (ExistingInstallDir <> '') and IsProtectedInstallPath(ExistingInstallDir) and (not IsAdminInstallMode) then
begin
- Result := CustomMessage('ProtectedInstallNeedsAdmin');
+ SetSetupFailure(Result, CustomMessage('ProtectedInstallNeedsAdmin'));
Exit;
end;
if IsProtectedInstallPath(WizardDirValue) and (not IsAdminInstallMode) then
begin
- Result := CustomMessage('ProtectedInstallNeedsAdmin');
+ SetSetupFailure(Result, CustomMessage('ProtectedInstallNeedsAdmin'));
Exit;
end;
try
- if not IsWebView2Installed() then
+ if IsWebView2Installed() then
+ LogBootstrapEvent('DEPENDENCY', 'ready|' + GetCustomMessageValue('WebView2RuntimeName'))
+ else
begin
- DownloadAndInstallPrerequisite('{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe', '/silent /install', GetCustomMessageValue('WebView2RuntimeName'), NeedsRestart);
+ AcquireAndInstallPrerequisite(
+ '{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe',
+ 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/silent /install',
+ GetCustomMessageValue('WebView2RuntimeName'), BundledWebView2Included = 1, NeedsRestart);
if not WaitForWebView2Installed(60) then
begin
- Result := GetCustomMessageValue('WebView2StillMissing');
+ SetSetupFailure(Result, GetCustomMessageValue('WebView2StillMissing'));
Exit;
end;
end;
- if (not IsVCRedistInstalled()) then
+ if IsVCRedistInstalled() then
+ LogBootstrapEvent('DEPENDENCY', 'ready|' + GetCustomMessageValue('VCRuntimeName'))
+ else
begin
- DownloadAndInstallPrerequisite('{#VCRedistUrl}', 'vc_redist.x64.exe', '/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'), NeedsRestart);
+ AcquireAndInstallPrerequisite(
+ '{#VCRedistUrl}', 'vc_redist.x64.exe', 'vc_redist.x64.exe',
+ '/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'),
+ BundledVCRedistIncluded = 1, NeedsRestart);
if not WaitForVCRedistInstalled(10) then
begin
- Result := GetCustomMessageValue('VCRuntimeStillMissing');
+ SetSetupFailure(Result, GetCustomMessageValue('VCRuntimeStillMissing'));
Exit;
end;
end;
except
- Result := GetExceptionMessage;
+ SetSetupFailure(Result, GetExceptionMessage);
end;
end;
@@ -954,6 +1016,7 @@ begin
if CurStep = ssInstall then
begin
LastInstallProgressPercent := -1;
+ LogBootstrapEvent('STAGE', 'files');
AppendInstallOutput(CustomMessage('InstallOutputClean'));
CleanLegacyInstallLayout();
AppendInstallOutput(CustomMessage('InstallOutputExtract'));
@@ -961,6 +1024,7 @@ begin
else if CurStep = ssPostInstall then
begin
FlushInstallOutput();
+ LogBootstrapEvent('STAGE', 'finalize');
AppendInstallOutput(CustomMessage('InstallOutputPostInstall'));
#if Int(QAIsolated) == 0
MigrateLegacyData();
@@ -980,6 +1044,7 @@ begin
if LastInstallProgressPercent <> 100 then
begin
LastInstallProgressPercent := 100;
+ LogBootstrapEvent('PROGRESS', '100|' + ExpandConstant(CurrentFileName));
FlushInstallOutput();
end;
Exit;
@@ -991,6 +1056,7 @@ begin
if ProgressPercent <> LastInstallProgressPercent then
begin
LastInstallProgressPercent := ProgressPercent;
+ LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + ExpandConstant(CurrentFileName));
FlushInstallOutput();
end;
end;
diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1
index 46d77b2..a7f5fee 100644
--- a/scripts/build-winui.ps1
+++ b/scripts/build-winui.ps1
@@ -1206,12 +1206,16 @@ function Build-InnoInstaller([object] $VersionInfo, [string] $SignTool, [switch]
$iss = Join-Path $Root 'installer\ymhut_box_winui.iss'
$chineseMessagesFile = Resolve-InnoLanguageFile $iscc
+ $bundleVCRedist = if (Test-Path -LiteralPath (Join-Path $PayloadDirectory 'prereqs\vc_redist.x64.exe')) { 1 } else { 0 }
+ $bundleWebView2 = if (Test-Path -LiteralPath (Join-Path $PayloadDirectory 'prereqs\MicrosoftEdgeWebView2RuntimeInstallerX64.exe')) { 1 } else { 0 }
Invoke-Tool $iscc @(
"/DMyAppVersion=$($VersionInfo.PackageVersion)",
"/DMyAppBuild=$($VersionInfo.Build)",
"/DMyAppChannel=$($VersionInfo.Channel)",
"/DChineseMessagesFile=$chineseMessagesFile",
"/DPayloadDir=$PayloadDirectory",
+ "/DBundleVCRedist=$bundleVCRedist",
+ "/DBundleWebView2=$bundleWebView2",
$iss
) 'Inno Setup build failed'
@@ -1225,7 +1229,8 @@ function Build-InnoInstaller([object] $VersionInfo, [string] $SignTool, [switch]
function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) {
$bootstrapRoot = Join-Path $Root 'build\winui\installer-bootstrap'
- $setupPath = Join-Path $OutputRoot "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion).exe"
+ $bootstrapName = "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion)"
+ $setupPath = Join-Path $OutputRoot "$bootstrapName.exe"
Reset-DirectoryInsideRepo $bootstrapRoot
Invoke-DotNet @(
'publish', $InstallerBootstrapProject,
@@ -1236,11 +1241,12 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) {
"-p:Version=$($VersionInfo.PackageVersion)",
"-p:FileVersion=$($VersionInfo.PackageVersion)",
"-p:InformationalVersion=$($VersionInfo.PackageVersion)",
+ "-p:AssemblyName=$bootstrapName",
"-p:InstallerEnginePath=$setupPath",
'-o', $bootstrapRoot
)
- $bootstrap = Join-Path $bootstrapRoot 'YMhutBoxSetup.exe'
+ $bootstrap = Join-Path $bootstrapRoot "$bootstrapName.exe"
if (-not (Test-Path -LiteralPath $bootstrap)) {
throw "WinUI installer bootstrap was not produced: $bootstrap"
}
@@ -1251,6 +1257,7 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) {
}
Invoke-ToolQuiet $setupPath @('/SELFTEST') 'WinUI installer bootstrap self-test failed' " Installer self-test: $setupPath"
+ Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath"
}
function Sign-Artifact([string] $SignTool, [string] $Path) {
Ensure-LocalDeveloperCertificate
diff --git a/server/unified-management/internal/auth/auth.go b/server/unified-management/internal/auth/auth.go
index c4cf1a2..81c3d32 100644
--- a/server/unified-management/internal/auth/auth.go
+++ b/server/unified-management/internal/auth/auth.go
@@ -112,10 +112,13 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt
return "", "", false, nil
}
user, ok, err := s.store.VerifyAdminPassword(ctx, username, password)
- if err != nil || !ok {
- s.recordLoginFailure(attemptKey)
+ if err != nil {
return "", "", false, err
}
+ if !ok {
+ s.recordLoginFailure(attemptKey)
+ return "", "", false, nil
+ }
sessionID := randomToken(32)
csrf := randomToken(32)
s.mu.Lock()
diff --git a/server/unified-management/internal/auth/auth_test.go b/server/unified-management/internal/auth/auth_test.go
index af4e6b7..8386d68 100644
--- a/server/unified-management/internal/auth/auth_test.go
+++ b/server/unified-management/internal/auth/auth_test.go
@@ -2,6 +2,7 @@ package auth
import (
"context"
+ "errors"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -124,6 +125,46 @@ func TestLoginLocksAfterRepeatedFailures(t *testing.T) {
}
}
+func TestLoginDatabaseCancellationDoesNotCountAsCredentialFailure(t *testing.T) {
+ root := t.TempDir()
+ store, err := db.Open(&config.Config{
+ StorageDir: root,
+ Database: config.DatabaseConfig{
+ Provider: "sqlite",
+ SQLitePath: filepath.Join(root, "cancel-login.sqlite"),
+ FailoverEnabled: true,
+ HealthIntervalSec: 3600,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ service := NewService(store)
+ captcha, err := service.NewCaptcha()
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.mu.Lock()
+ answer := service.captchas[captcha.ID].answer
+ service.mu.Unlock()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _, ok, err := service.Login(ctx, "admin", "admin", captcha.ID, answer, "127.0.0.1")
+ if !errors.Is(err, context.Canceled) || ok {
+ t.Fatalf("canceled login returned ok=%v err=%v", ok, err)
+ }
+ service.mu.Lock()
+ _, exists := service.loginAttempts[loginAttemptKey("admin", "127.0.0.1")]
+ service.mu.Unlock()
+ if exists {
+ t.Fatal("database cancellation was counted as a credential failure")
+ }
+}
+
func TestSessionCookieUsesSecureForForwardedHTTPS(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", nil)
req.Header.Set("X-Forwarded-Proto", "https")
diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go
index fc6acba..5e06b93 100644
--- a/server/unified-management/internal/config/config.go
+++ b/server/unified-management/internal/config/config.go
@@ -36,6 +36,7 @@ type Config struct {
TimestampWindowSeconds int64 `json:"timestamp_window_seconds"`
MaxRequestBytes int64 `json:"max_request_bytes"`
MaxPackageBytes int64 `json:"max_package_bytes"`
+ ReleaseUploadMaxBytes int64 `json:"release_upload_max_bytes"`
Database DatabaseConfig `json:"database"`
Mail MailConfig `json:"mail"`
Branding BrandingConfig `json:"branding"`
@@ -149,6 +150,7 @@ func defaults(root string) *Config {
TimestampWindowSeconds: 600,
MaxRequestBytes: 12 * 1024 * 1024,
MaxPackageBytes: 10 * 1024 * 1024,
+ ReleaseUploadMaxBytes: 1024 * 1024 * 1024,
SourceCheckSeconds: 60,
Database: DatabaseConfig{
Provider: "sqlite",
@@ -340,6 +342,11 @@ func applyEnv(cfg *Config) {
cfg.MaxPackageBytes = parsed
}
}
+ if value := os.Getenv("YMHUT_RELEASE_UPLOAD_MAX_BYTES"); value != "" {
+ if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
+ cfg.ReleaseUploadMaxBytes = parsed
+ }
+ }
if value := os.Getenv("YMHUT_SOURCE_CHECK_SECONDS"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
cfg.SourceCheckSeconds = parsed
@@ -453,6 +460,9 @@ func normalize(root string, cfg *Config) {
if cfg.MaxPackageBytes <= 0 {
cfg.MaxPackageBytes = 10 * 1024 * 1024
}
+ if cfg.ReleaseUploadMaxBytes <= 0 {
+ cfg.ReleaseUploadMaxBytes = 1024 * 1024 * 1024
+ }
if cfg.UploadGuard.MaxZipFiles <= 0 {
cfg.UploadGuard.MaxZipFiles = 80
}
diff --git a/server/unified-management/internal/config/config_test.go b/server/unified-management/internal/config/config_test.go
index 3541b28..00258ad 100644
--- a/server/unified-management/internal/config/config_test.go
+++ b/server/unified-management/internal/config/config_test.go
@@ -100,3 +100,39 @@ func TestLoadRewritesAbsoluteConfigPaths(t *testing.T) {
t.Fatalf("config still contains absolute base path: %s", string(rewritten))
}
}
+
+func TestReleaseUploadLimitCanBeConfiguredFromEnvironment(t *testing.T) {
+ t.Setenv("YMHUT_RELEASE_UPLOAD_MAX_BYTES", "67108864")
+ cfg := defaults(t.TempDir())
+ applyEnv(cfg)
+ normalize(cfg.BaseDir, cfg)
+ if cfg.ReleaseUploadMaxBytes != 64*1024*1024 {
+ t.Fatalf("ReleaseUploadMaxBytes = %d, want %d", cfg.ReleaseUploadMaxBytes, 64*1024*1024)
+ }
+}
+
+func TestPreflightReportsMissingAdminAssetName(t *testing.T) {
+ root := t.TempDir()
+ cfg := defaults(root)
+ if err := os.MkdirAll(filepath.Join(cfg.AdminWebDir, "assets"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(cfg.AdminWebDir, "index.html"),
+ []byte(``),
+ 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ checks := Preflight(cfg)
+ admin := checks[0]
+ for _, check := range checks {
+ if check.Name == "admin web dist" {
+ admin = check
+ break
+ }
+ }
+ if !strings.Contains(admin.Message, "assets/missing.js") {
+ t.Fatalf("admin preflight message = %q, want missing asset name", admin.Message)
+ }
+}
diff --git a/server/unified-management/internal/config/preflight.go b/server/unified-management/internal/config/preflight.go
index ae6124a..048c4c8 100644
--- a/server/unified-management/internal/config/preflight.go
+++ b/server/unified-management/internal/config/preflight.go
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"path/filepath"
+ "regexp"
+ "strings"
webassets "ymhut-box/server/unified-management/web"
)
@@ -44,13 +46,47 @@ func Preflight(cfg *Config) []Check {
checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
- checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
+ checkAdminWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
}
return checks
}
+var adminPreflightAssetPattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
+
+func checkAdminWebBuild(name, path, embedRoot string) Check {
+ check := checkWebBuild(name, path, embedRoot)
+ if check.Status != "ok" || strings.Contains(check.Message, "embedded frontend assets") {
+ return check
+ }
+ index := filepath.Join(path, "index.html")
+ data, err := os.ReadFile(index)
+ if err != nil {
+ return check
+ }
+ matches := adminPreflightAssetPattern.FindAllSubmatch(data, -1)
+ if len(matches) == 0 {
+ return Check{Name: name, Status: "error", Path: index, Message: "index.html does not reference any /admin/assets files"}
+ }
+ for _, match := range matches {
+ assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
+ if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
+ return Check{Name: name, Status: "error", Path: index, Message: fmt.Sprintf("invalid admin asset reference %s", assetPath)}
+ }
+ info, statErr := os.Stat(filepath.Join(path, filepath.FromSlash(assetPath)))
+ if statErr == nil && !info.IsDir() {
+ continue
+ }
+ message := fmt.Sprintf("disk asset %s is missing", assetPath)
+ if embeddedWebBuildOK(embedRoot) {
+ return Check{Name: name, Status: "ok", Path: path, Message: message + "; using embedded frontend assets"}
+ }
+ return Check{Name: name, Status: "error", Path: filepath.Join(path, filepath.FromSlash(assetPath)), Message: message}
+ }
+ return check
+}
+
func checkDir(name, path string, create bool) Check {
if create {
if err := os.MkdirAll(path, 0o750); err != nil {
diff --git a/server/unified-management/internal/db/admin_store.go b/server/unified-management/internal/db/admin_store.go
index a3cbf58..2b10893 100644
--- a/server/unified-management/internal/db/admin_store.go
+++ b/server/unified-management/internal/db/admin_store.go
@@ -46,7 +46,7 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
if username == "" {
username = "admin"
}
- user, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, password)
+ user, ok, err := s.verifyAdminPasswordOnContext(ctx, s.localDB, s.localDialect, username, password)
if err == nil && (ok || user.Username != "") {
return user, ok, nil
}
@@ -57,7 +57,7 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
remote, remoteDialect := s.remoteDB, s.remoteDialect
s.mu.RUnlock()
if remote != nil && remote != s.localDB {
- user, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
+ user, ok, err := s.verifyAdminPasswordOnContext(ctx, remote, remoteDialect, username, password)
if err != nil {
s.markFailover(err)
}
@@ -67,12 +67,16 @@ func (s *Store) VerifyAdminPassword(ctx context.Context, username, password stri
}
func (s *Store) verifyAdminPasswordOn(conn *sql.DB, d dialect, username, password string) (AdminUser, bool, error) {
+ return s.verifyAdminPasswordOnContext(context.Background(), conn, d, username, password)
+}
+
+func (s *Store) verifyAdminPasswordOnContext(ctx context.Context, conn *sql.DB, d dialect, username, password string) (AdminUser, bool, error) {
if conn == nil {
return AdminUser{}, false, errors.New("database is not available")
}
var row adminRow
var changed int
- err := conn.QueryRow(d.rebind(`SELECT id, username, password_hash, password_changed, created_at, updated_at FROM admin_users WHERE username = ?`), username).
+ err := conn.QueryRowContext(ctx, d.rebind(`SELECT id, username, password_hash, password_changed, created_at, updated_at FROM admin_users WHERE username = ?`), username).
Scan(&row.ID, &row.Username, &row.PasswordHash, &changed, &row.CreatedAt, &row.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return AdminUser{}, false, nil
@@ -104,12 +108,12 @@ func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, cu
return "", err
}
username = firstNonEmpty(strings.TrimSpace(username), "admin")
- _, ok, err := s.verifyAdminPasswordOn(s.localDB, s.localDialect, username, current)
+ _, ok, err := s.verifyAdminPasswordOnContext(ctx, s.localDB, s.localDialect, username, current)
if err != nil {
return "", err
}
if !ok {
- remoteOK, remoteErr := s.verifyRemoteAdminPassword(username, current)
+ remoteOK, remoteErr := s.verifyRemoteAdminPassword(ctx, username, current)
if remoteErr != nil {
s.markFailover(remoteErr)
}
@@ -150,14 +154,14 @@ func validateAdminPasswordChange(current, next string) error {
return nil
}
-func (s *Store) verifyRemoteAdminPassword(username, password string) (bool, error) {
+func (s *Store) verifyRemoteAdminPassword(ctx context.Context, username, password string) (bool, error) {
s.mu.RLock()
remote, remoteDialect := s.remoteDB, s.remoteDialect
s.mu.RUnlock()
if remote == nil || remote == s.localDB {
return false, nil
}
- _, ok, err := s.verifyAdminPasswordOn(remote, remoteDialect, username, password)
+ _, ok, err := s.verifyAdminPasswordOnContext(ctx, remote, remoteDialect, username, password)
return ok, err
}
diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go
index 7475a73..2de4bca 100644
--- a/server/unified-management/internal/db/audit_store.go
+++ b/server/unified-management/internal/db/audit_store.go
@@ -1,6 +1,7 @@
package db
import (
+ "context"
"fmt"
"strings"
"time"
@@ -170,10 +171,18 @@ func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
}
func (s *Store) InsertAudit(log AuditLog) error {
+ return s.InsertAuditContext(context.Background(), log)
+}
+
+func (s *Store) InsertAuditContext(ctx context.Context, log AuditLog) error {
if log.CreatedAt == "" {
log.CreatedAt = Now()
}
- _, err := s.exec(`INSERT INTO audit_logs (actor, type, target, message, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ conn, d := s.active()
+ if conn == nil {
+ return fmt.Errorf("database is not available")
+ }
+ _, err := conn.ExecContext(ctx, d.rebind(`INSERT INTO audit_logs (actor, type, target, message, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`),
sanitize(log.Actor), sanitize(log.Type), sanitize(log.Target), sanitize(log.Message), sanitize(log.IP), sanitize(log.UserAgent), log.CreatedAt)
return err
}
diff --git a/server/unified-management/internal/db/store_test.go b/server/unified-management/internal/db/store_test.go
index 44152ca..8c5ca3a 100644
--- a/server/unified-management/internal/db/store_test.go
+++ b/server/unified-management/internal/db/store_test.go
@@ -4,10 +4,12 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"os"
"path/filepath"
"strings"
"testing"
+ "time"
"ymhut-box/server/unified-management/internal/config"
)
@@ -109,6 +111,45 @@ func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T)
}
}
+func TestVerifyAdminPasswordHonorsContextDeadlineWhenSQLiteIsBusy(t *testing.T) {
+ root := t.TempDir()
+ store, err := Open(&config.Config{
+ StorageDir: root,
+ Database: config.DatabaseConfig{
+ Provider: "sqlite",
+ SQLitePath: filepath.Join(root, "busy-login.sqlite"),
+ FailoverEnabled: true,
+ HealthIntervalSec: 3600,
+ MaxOpenConns: 1,
+ MaxIdleConns: 1,
+ ConnMaxLifetimeSeconds: 60,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+
+ conn, err := store.localDB.Conn(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ started := time.Now()
+ _, ok, err := store.VerifyAdminPassword(ctx, "admin", "admin")
+ if !errors.Is(err, context.DeadlineExceeded) || ok {
+ t.Fatalf("busy login returned ok=%v err=%v, want deadline exceeded", ok, err)
+ }
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("busy login ignored context deadline for %s", elapsed)
+ }
+}
+
func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "unified.sqlite")
diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go
index ac418fa..92a6a30 100644
--- a/server/unified-management/internal/releases/releases.go
+++ b/server/unified-management/internal/releases/releases.go
@@ -61,6 +61,19 @@ type UploadOptions struct {
UpdateManifest bool
}
+type UploadedPackageFile struct {
+ TempPath string
+ Size int64
+ SHA256 string
+}
+
+var (
+ ErrUploadedPackageMissing = errors.New("uploaded file is missing")
+ ErrUploadedPackageEmpty = errors.New("uploaded file is empty")
+ ErrUploadedPackageStorageFailed = errors.New("upload storage failed")
+ ErrUploadedPackageManifestFailed = errors.New("manifest update failed")
+)
+
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}}
if len(noticeService) > 0 {
@@ -226,7 +239,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
return Package{}, err
}
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
- return Package{}, err
+ return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
target := filepath.Join(s.cfg.DownloadsDir, name)
resolved, err := filepath.Abs(target)
@@ -288,6 +301,108 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
return pkg, nil
}
+func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageFile, opts UploadOptions, actor string) (Package, error) {
+ name, err := safePackageName(opts.FileName)
+ if err != nil {
+ return Package{}, err
+ }
+ if uploaded.TempPath == "" {
+ return Package{}, ErrUploadedPackageMissing
+ }
+ if uploaded.Size <= 0 {
+ return Package{}, ErrUploadedPackageEmpty
+ }
+ if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
+ return Package{}, err
+ }
+ target := filepath.Join(s.cfg.DownloadsDir, name)
+ resolved, err := filepath.Abs(target)
+ if err != nil {
+ return Package{}, err
+ }
+ base, _ := filepath.Abs(s.cfg.DownloadsDir)
+ if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
+ return Package{}, errors.New("path escape rejected")
+ }
+ if err := os.Chmod(uploaded.TempPath, 0o640); err != nil {
+ return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
+ }
+ backup, err := backupExistingPackage(target)
+ if err != nil {
+ return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
+ }
+ if err := os.Rename(uploaded.TempPath, target); err != nil {
+ _ = restorePackageBackup(target, backup)
+ return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
+ }
+ version := firstNonEmpty(opts.Version, detectVersion(name))
+ platform, arch := detectPlatform(name)
+ platform = firstNonEmpty(opts.Platform, platform)
+ arch = firstNonEmpty(opts.Arch, arch)
+ product := detectProduct(name)
+ pkg := Package{
+ ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
+ Name: product,
+ Version: version,
+ Platform: platform,
+ Arch: arch,
+ URL: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + "/downloads/" + name,
+ SHA256: strings.ToLower(uploaded.SHA256),
+ Size: uploaded.Size,
+ Required: strings.Contains(strings.ToLower(product), "ymhut"),
+ Enabled: true,
+ FileName: name,
+ UpdatedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+ if opts.UpdateManifest {
+ if err := s.updateLegacyManifest(pkg, opts); err != nil {
+ if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil {
+ return Package{}, fmt.Errorf("%w: %v; rollback failed: %v", ErrUploadedPackageManifestFailed, err, rollbackErr)
+ }
+ return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)
+ }
+ }
+ if backup != "" {
+ _ = os.Remove(backup)
+ }
+ _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(uploaded.Size))})
+ return pkg, nil
+}
+
+func backupExistingPackage(target string) (string, error) {
+ if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ } else if err != nil {
+ return "", err
+ }
+ file, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".backup-*")
+ if err != nil {
+ return "", err
+ }
+ backup := file.Name()
+ if err := file.Close(); err != nil {
+ _ = os.Remove(backup)
+ return "", err
+ }
+ if err := os.Remove(backup); err != nil {
+ return "", err
+ }
+ if err := os.Rename(target, backup); err != nil {
+ return "", err
+ }
+ return backup, nil
+}
+
+func restorePackageBackup(target, backup string) error {
+ if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if backup == "" {
+ return nil
+ }
+ return os.Rename(backup, target)
+}
+
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
payload := s.legacyUpdateBase()
diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go
index cf030c0..2754eaa 100644
--- a/server/unified-management/internal/releases/releases_test.go
+++ b/server/unified-management/internal/releases/releases_test.go
@@ -1,6 +1,7 @@
package releases
import (
+ "errors"
"net/http/httptest"
"os"
"path/filepath"
@@ -132,3 +133,104 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
t.Fatal("expected unsafe filename to be rejected")
}
}
+
+func TestSavePreparedPackageReplacesExistingFile(t *testing.T) {
+ service, cfg, cleanup := newPreparedPackageTestService(t)
+ defer cleanup()
+ name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
+ target := filepath.Join(cfg.DownloadsDir, name)
+ if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
+ if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := service.SavePreparedPackage(
+ httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
+ UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
+ UploadOptions{FileName: name},
+ "admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "new package" {
+ t.Fatalf("target contains %q, want new package", data)
+ }
+ backups, err := filepath.Glob(filepath.Join(cfg.DownloadsDir, "."+name+".backup-*"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(backups) != 0 {
+ t.Fatalf("successful replacement left backups: %v", backups)
+ }
+}
+
+func TestSavePreparedPackageRestoresExistingFileWhenManifestFails(t *testing.T) {
+ service, cfg, cleanup := newPreparedPackageTestService(t)
+ defer cleanup()
+ name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
+ if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(cfg.DownloadsDir, name)
+ if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
+ if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ blocked := filepath.Join(cfg.BaseDir, "manifest-blocked")
+ if err := os.WriteFile(blocked, []byte("not a directory"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ cfg.UpdatePublicDir = blocked
+
+ _, err := service.SavePreparedPackage(
+ httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
+ UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
+ UploadOptions{FileName: name, UpdateManifest: true},
+ "admin")
+ if !errors.Is(err, ErrUploadedPackageManifestFailed) {
+ t.Fatalf("got %v, want manifest failure", err)
+ }
+ data, readErr := os.ReadFile(target)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if string(data) != "old package" {
+ t.Fatalf("rollback restored %q, want old package", data)
+ }
+}
+
+func newPreparedPackageTestService(t *testing.T) (*Service, *config.Config, func()) {
+ t.Helper()
+ dir := t.TempDir()
+ cfg := &config.Config{
+ BaseDir: dir,
+ StorageDir: filepath.Join(dir, "storage"),
+ DataDir: filepath.Join(dir, "data"),
+ UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
+ DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
+ BaseURL: "https://update.ymhut.cn",
+ Database: config.DatabaseConfig{
+ Provider: "sqlite",
+ SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
+ HealthIntervalSec: 30,
+ },
+ }
+ store, err := db.Open(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return NewService(cfg, store), cfg, func() { _ = store.Close() }
+}
diff --git a/server/unified-management/internal/web/admin_release_routes.go b/server/unified-management/internal/web/admin_release_routes.go
index 00a8914..10d1147 100644
--- a/server/unified-management/internal/web/admin_release_routes.go
+++ b/server/unified-management/internal/web/admin_release_routes.go
@@ -1,9 +1,15 @@
package web
import (
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
+ "fmt"
+ "io"
+ "mime/multipart"
"net/http"
+ "os"
"strings"
"ymhut-box/server/unified-management/internal/notices"
@@ -22,27 +28,15 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
- if err := req.ParseMultipartForm(256 << 20); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
+ uploaded, opts, cleanup, err := r.readReleasePackageUpload(w, req)
+ if err != nil {
+ writeReleaseUploadError(w, err)
return
}
- file, header, err := req.FormFile("file")
+ defer cleanup()
+ pkg, err := r.releases.SavePreparedPackage(req, uploaded, opts, "admin")
if err != nil {
- writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
- return
- }
- defer file.Close()
- pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
- FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
- Version: req.FormValue("version"),
- Platform: req.FormValue("platform"),
- Arch: req.FormValue("arch"),
- Channel: req.FormValue("channel"),
- Notes: req.FormValue("notes"),
- UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
- }, "admin")
- if err != nil {
- writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
+ writeReleaseUploadError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
@@ -55,6 +49,176 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
}
}
+const releaseUploadFieldMaxBytes = 1 << 20
+
+type releaseUploadError struct {
+ status int
+ code string
+ err error
+}
+
+func (e releaseUploadError) Error() string { return e.err.Error() }
+
+func newReleaseUploadError(status int, code string, err error) error {
+ return releaseUploadError{status: status, code: code, err: err}
+}
+
+func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
+ limit := r.releaseUploadLimit()
+ req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
+ reader, err := req.MultipartReader()
+ if err != nil {
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
+ }
+ if err := os.MkdirAll(r.cfg.DownloadsDir, 0o750); err != nil {
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
+ }
+
+ fields := map[string]string{}
+ uploadedName := ""
+ uploaded := releases.UploadedPackageFile{}
+ cleanup := func() {}
+ for {
+ part, err := reader.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ cleanup()
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, classifyMultipartReadError(err)
+ }
+ name := part.FormName()
+ if name == "" {
+ _ = part.Close()
+ continue
+ }
+ if name != "file" {
+ value, err := readReleaseUploadField(part)
+ _ = part.Close()
+ if err != nil {
+ cleanup()
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
+ }
+ fields[name] = value
+ continue
+ }
+ if uploaded.TempPath != "" {
+ _ = part.Close()
+ cleanup()
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("multiple file fields are not supported"))
+ }
+ uploadedName = part.FileName()
+ prepared, err := r.streamReleaseUploadPart(part, limit)
+ _ = part.Close()
+ if err != nil {
+ cleanup()
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
+ }
+ uploaded = prepared
+ cleanup = func() { _ = os.Remove(prepared.TempPath) }
+ }
+ if uploaded.TempPath == "" {
+ return releases.UploadedPackageFile{}, releases.UploadOptions{}, cleanup, newReleaseUploadError(http.StatusBadRequest, "FILE_REQUIRED", errors.New("file is required"))
+ }
+ opts := releases.UploadOptions{
+ FileName: firstNonEmpty(fields["fileName"], uploadedName),
+ Version: fields["version"],
+ Platform: fields["platform"],
+ Arch: fields["arch"],
+ Channel: fields["channel"],
+ Notes: fields["notes"],
+ UpdateManifest: fields["updateManifest"] == "true" || fields["updateManifest"] == "1",
+ }
+ return uploaded, opts, cleanup, nil
+}
+
+func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (releases.UploadedPackageFile, error) {
+ tmp, err := os.CreateTemp(r.cfg.DownloadsDir, ".release-package-*.upload")
+ if err != nil {
+ return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
+ }
+ tmpName := tmp.Name()
+ removeOnError := true
+ defer func() {
+ if removeOnError {
+ _ = os.Remove(tmpName)
+ }
+ }()
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: part, N: limit + 1}
+ written, err := io.Copy(tmp, io.TeeReader(limited, hash))
+ if closeErr := tmp.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
+ }
+ if written > limit {
+ return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", fmt.Errorf("发布包超过上传上限 %s", formatReleaseUploadBytes(limit)))
+ }
+ if written <= 0 {
+ return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusBadRequest, "PACKAGE_EMPTY", releases.ErrUploadedPackageEmpty)
+ }
+ removeOnError = false
+ return releases.UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
+}
+
+func readReleaseUploadField(part *multipart.Part) (string, error) {
+ data, err := io.ReadAll(io.LimitReader(part, releaseUploadFieldMaxBytes+1))
+ if err != nil {
+ return "", classifyMultipartReadError(err)
+ }
+ if len(data) > releaseUploadFieldMaxBytes {
+ return "", newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("form field is too large"))
+ }
+ return strings.TrimSpace(string(data)), nil
+}
+
+func classifyMultipartReadError(err error) error {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
+ }
+ return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
+}
+
+func (r *router) releaseUploadLimit() int64 {
+ if r.cfg != nil && r.cfg.ReleaseUploadMaxBytes > 0 {
+ return r.cfg.ReleaseUploadMaxBytes
+ }
+ return 1024 * 1024 * 1024
+}
+
+func formatReleaseUploadBytes(value int64) string {
+ const megabyte = 1024 * 1024
+ if value < megabyte {
+ return fmt.Sprintf("%d KB", (value+1023)/1024)
+ }
+ return fmt.Sprintf("%.1f MB", float64(value)/megabyte)
+}
+
+func writeReleaseUploadError(w http.ResponseWriter, err error) {
+ var uploadErr releaseUploadError
+ if errors.As(err, &uploadErr) {
+ writeError(w, uploadErr.status, uploadErr.code, uploadErr.err)
+ return
+ }
+ status := http.StatusBadRequest
+ code := "PACKAGE_UPLOAD_FAILED"
+ if errors.Is(err, releases.ErrUploadedPackageManifestFailed) {
+ status = http.StatusInternalServerError
+ code = "MANIFEST_UPDATE_FAILED"
+ } else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) {
+ status = http.StatusInternalServerError
+ code = "UPLOAD_STORAGE_FAILED"
+ } else if errors.Is(err, releases.ErrUploadedPackageEmpty) {
+ code = "PACKAGE_EMPTY"
+ } else if errors.Is(err, releases.ErrUploadedPackageMissing) {
+ code = "FILE_REQUIRED"
+ }
+ writeError(w, status, code, err)
+}
+
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
if r.notices == nil {
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go
index 1f5cb3f..a3f8668 100644
--- a/server/unified-management/internal/web/response.go
+++ b/server/unified-management/internal/web/response.go
@@ -79,6 +79,7 @@ func localizedErrorMessage(code, message string) string {
byCode := map[string]string{
"UNAUTHORIZED": "需要登录后继续操作",
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
+ "LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
"PASSWORD_CHANGE_FAILED": "密码修改失败",
"INVALID_PAYLOAD": "提交内容格式不正确",
"DATABASE_TEST_FAILED": "数据库连接测试失败",
diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go
index c74b6b1..45fd8d6 100644
--- a/server/unified-management/internal/web/router.go
+++ b/server/unified-management/internal/web/router.go
@@ -1,6 +1,7 @@
package web
import (
+ "context"
"encoding/json"
"errors"
"net/http"
@@ -31,6 +32,8 @@ type router struct {
publicSnapshots *publicSnapshotService
}
+const loginRequestTimeout = 5 * time.Second
+
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
r := &router{
cfg: cfg,
@@ -55,7 +58,10 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
- if strings.HasPrefix(path, "/api/admin/") && req.Method != http.MethodGet && req.Method != http.MethodHead {
+ if strings.HasPrefix(path, "/api/admin/") &&
+ path != "/api/admin/auth/login" &&
+ path != "/api/admin/auth/logout" &&
+ req.Method != http.MethodGet && req.Method != http.MethodHead {
captured := &mutationResponseWriter{ResponseWriter: w, status: http.StatusOK}
w = captured
defer func() {
@@ -116,7 +122,7 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case strings.HasPrefix(path, "/downloads/"):
r.handleDownload(w, req)
case strings.HasPrefix(path, "/admin/assets/"):
- serveStaticAsset(w, req, r.cfg.AdminWebDir, "admin/dist", strings.TrimPrefix(path, "/admin/"))
+ r.serveAdminAsset(w, req, strings.TrimPrefix(path, "/admin/"))
case strings.HasPrefix(path, "/assets/"):
serveStaticAsset(w, req, r.cfg.PortalWebDir, "portal/dist", strings.TrimPrefix(path, "/"))
case strings.HasPrefix(path, "/api/admin/feedbacks"):
@@ -196,6 +202,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
+ w.Header().Set("Cache-Control", "no-store")
+ req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
var body struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -209,8 +217,14 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
if body.Username == "" {
body.Username = "admin"
}
- sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
+ ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
+ defer cancel()
+ sessionID, csrf, ok, err := r.auth.Login(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
+ return
+ }
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
return
}
@@ -219,8 +233,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
return
}
auth.SetSessionCookieForRequest(w, req, sessionID)
- _ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
+ go r.recordLoginAudit(body.Username, req.RemoteAddr, req.UserAgent())
+}
+
+func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = r.store.InsertAuditContext(ctx, db.AuditLog{
+ Actor: username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: remoteAddr, UserAgent: userAgent,
+ })
}
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go
index dcfcdb8..7c93853 100644
--- a/server/unified-management/internal/web/router_test.go
+++ b/server/unified-management/internal/web/router_test.go
@@ -598,6 +598,148 @@ func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
}
+ if got := res.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
+ t.Fatalf("%s cache control = %q, want no-store", path, got)
+ }
+ }
+}
+
+func TestMissingAdminAssetIsNotCached(t *testing.T) {
+ handler, cleanup := testRouter(t)
+ defer cleanup()
+ req := httptest.NewRequest(http.MethodGet, "/admin/assets/missing.js", nil)
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != http.StatusNotFound {
+ t.Fatalf("missing asset returned %d: %s", res.Code, res.Body.String())
+ }
+ if got := res.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("missing asset cache control = %q, want no-store", got)
+ }
+}
+
+func TestAdminReleasePackageUploadStreamsMultipart(t *testing.T) {
+ handler, cleanup := testRouter(t)
+ defer cleanup()
+ session, csrf, err := loginForTest(handler)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := newReleaseUploadRequest(t, "YMhut_Box_WinUI_Setup_2.0.8_x64.exe", []byte("package bytes"), true)
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
+ req.Header.Set("X-CSRF-Token", csrf)
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != http.StatusOK {
+ t.Fatalf("upload returned %d: %s", res.Code, res.Body.String())
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["ok"] != true {
+ t.Fatalf("unexpected upload payload: %#v", payload)
+ }
+}
+
+func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data []byte
+ include bool
+ wantStatus int
+ wantCode string
+ }{
+ {name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"},
+ {name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"},
+ {name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"},
+ {name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ handler, cleanup := testRouter(t)
+ defer cleanup()
+ session, csrf, err := loginForTest(handler)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fileName := "package.exe"
+ if tc.name == "unsafe-name" {
+ fileName = "../package.exe"
+ }
+ req := newReleaseUploadRequest(t, fileName, tc.data, tc.include)
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
+ req.Header.Set("X-CSRF-Token", csrf)
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != tc.wantStatus || !strings.Contains(res.Body.String(), tc.wantCode) {
+ t.Fatalf("upload returned %d %s, want %d containing %s", res.Code, res.Body.String(), tc.wantStatus, tc.wantCode)
+ }
+ })
+ }
+}
+
+func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ if includeFile {
+ part, err := writer.CreateFormFile("file", name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := part.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.WriteField("version", "2.0.8"); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(name, "..") {
+ if err := writer.WriteField("fileName", name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ return req
+}
+
+func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(``), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") {
+ t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err)
+ }
+}
+
+func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(``), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ r := &router{cfg: &config.Config{AdminWebDir: dir}}
+ req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil)
+ res := httptest.NewRecorder()
+ r.serveAdminAsset(res, req, "assets/stale.js")
+ if res.Code != http.StatusNotFound {
+ t.Fatalf("incomplete disk asset returned %d: %s", res.Code, res.Body.String())
+ }
+ if strings.Contains(res.Body.String(), "stale disk asset") {
+ t.Fatal("incomplete disk build served a stale disk asset")
+ }
+ if got := res.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("incomplete asset cache control = %q, want no-store", got)
}
}
@@ -796,6 +938,26 @@ func loginForTest(handler http.Handler) (string, string, error) {
return "", "", errors.New("session cookie not set")
}
+func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
+ handler, cleanup := testRouter(t)
+ defer cleanup()
+ body := bytes.NewBufferString(`{"username":"admin","password":"wrong","captchaId":"missing","captcha":"00000"}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", body)
+ req.Header.Set("Content-Type", "application/json")
+ res := httptest.NewRecorder()
+ started := time.Now()
+ handler.ServeHTTP(res, req)
+ if time.Since(started) > time.Second {
+ t.Fatal("invalid login request did not return promptly")
+ }
+ if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "LOGIN_FAILED") {
+ t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
+ }
+ if got := res.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("login cache control = %q, want no-store", got)
+ }
+}
+
func readTestCaptcha(dataURL string) (string, error) {
const prefix = "data:image/png;base64,"
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
@@ -936,6 +1098,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
TimestampWindowSeconds: 600,
MaxRequestBytes: 12 << 20,
MaxPackageBytes: 10 << 20,
+ ReleaseUploadMaxBytes: 1 << 20,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
diff --git a/server/unified-management/internal/web/static_routes.go b/server/unified-management/internal/web/static_routes.go
index e7553a7..31910bb 100644
--- a/server/unified-management/internal/web/static_routes.go
+++ b/server/unified-management/internal/web/static_routes.go
@@ -3,10 +3,13 @@ package web
import (
"bytes"
"errors"
+ "fmt"
+ "log"
"mime"
"net/http"
"os"
"path/filepath"
+ "regexp"
"strings"
"time"
@@ -46,6 +49,7 @@ func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot,
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
return
}
+ w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, req)
}
@@ -53,6 +57,29 @@ func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, asse
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
}
+func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
+ if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
+ writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
+ return
+ }
+ setStaticCacheHeaders(w, assetPath)
+ if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
+ if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) {
+ return
+ }
+ log.Printf("admin web disk build is missing requested asset: %s", assetPath)
+ } else {
+ if !errors.Is(err, os.ErrNotExist) {
+ log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err)
+ }
+ if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) {
+ return
+ }
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.NotFound(w, req)
+}
+
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
}
@@ -108,11 +135,13 @@ func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
}
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
- w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Cache-Control", "no-store, must-revalidate")
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
- if _, err := os.Stat(index); err == nil {
+ if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
http.ServeFile(w, req, index)
return
+ } else if !errors.Is(err, os.ErrNotExist) {
+ log.Printf("admin web disk build is incomplete: %v", err)
}
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
return
@@ -121,6 +150,35 @@ func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte(`
YMhut AdminYMhut Admin
Build web/admin to enable the Vue console.
`))
}
+var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
+
+func validateAdminDiskBuild(root string) error {
+ index := filepath.Join(root, "index.html")
+ data, err := os.ReadFile(index)
+ if err != nil {
+ return err
+ }
+ matches := adminAssetReferencePattern.FindAllSubmatch(data, -1)
+ if len(matches) == 0 {
+ return errors.New("index.html does not reference any admin assets")
+ }
+ for _, match := range matches {
+ assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
+ if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
+ return fmt.Errorf("invalid admin asset reference %s", assetPath)
+ }
+ path := filepath.Join(root, filepath.FromSlash(assetPath))
+ info, statErr := os.Stat(path)
+ if statErr != nil {
+ return fmt.Errorf("missing %s: %w", assetPath, statErr)
+ }
+ if info.IsDir() {
+ return fmt.Errorf("%s is not a file", assetPath)
+ }
+ }
+ return nil
+}
+
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
extension := strings.ToLower(filepath.Ext(assetPath))
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" {
diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue
index fd5584b..637dbaa 100644
--- a/server/unified-management/web/admin/src/App.vue
+++ b/server/unified-management/web/admin/src/App.vue
@@ -66,6 +66,7 @@ const route = useRoute();
const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false);
+const loginPending = ref(false);
const toast = ref(null);
const autoRefreshPaused = ref(false);
const databaseFormEditing = ref(false);
@@ -490,7 +491,7 @@ function isAuthError(raw: string, message: string) {
}
async function loadCaptcha() {
- captcha.value = await api("/api/admin/auth/captcha");
+ captcha.value = await adminFetch("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
}
async function loadAuthBootstrap() {
@@ -498,17 +499,35 @@ async function loadAuthBootstrap() {
}
async function login() {
- await guarded(async () => {
- const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
+ if (loginPending.value) return;
+ if (!loginForm.password || !loginForm.captcha || !captcha.value?.captchaId) {
+ setToast("请填写密码和验证码", "warn");
+ return;
+ }
+
+ loginPending.value = true;
+ loading.value = true;
+ try {
+ const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
- });
+ }, { timeoutMs: 8000 });
csrf.value = data.csrfToken;
sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard");
- });
+ } catch (error) {
+ const message = toChineseError(error instanceof Error ? error.message : String(error));
+ setToast(message, "error");
+ loginForm.captcha = "";
+ void loadCaptcha().catch(() => {
+ captcha.value = null;
+ });
+ } finally {
+ loading.value = false;
+ loginPending.value = false;
+ }
}
async function logout() {
@@ -747,6 +766,7 @@ async function uploadPackage() {
setToast("请选择要上传的发布包", "warn");
return;
}
+ let completed = false;
await guarded(async () => {
const form = new FormData();
form.append("file", uploadDraft.file as File);
@@ -771,6 +791,7 @@ async function uploadPackage() {
uploadDraft.status = "上传完成";
uploadDraft.file = null;
uploadDraft.notes = "";
+ completed = true;
setToast("发布包已上传并放入下载目录");
await loadReleases();
window.setTimeout(() => {
@@ -783,6 +804,12 @@ async function uploadPackage() {
}, 1200);
}).finally(() => {
uploadDraft.uploading = false;
+ if (!completed) {
+ uploadDraft.progress = 0;
+ uploadDraft.loadedBytes = 0;
+ uploadDraft.totalBytes = uploadDraft.file?.size || 0;
+ uploadDraft.status = "上传失败,可直接重试";
+ }
});
}
@@ -1709,19 +1736,19 @@ function connectAdminEvents() {
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
diff --git a/server/unified-management/web/admin/src/api/admin.ts b/server/unified-management/web/admin/src/api/admin.ts
index 6edf0c6..d7e551f 100644
--- a/server/unified-management/web/admin/src/api/admin.ts
+++ b/server/unified-management/web/admin/src/api/admin.ts
@@ -5,6 +5,7 @@ export type UploadProgress = {
export type AdminApiOptions = {
csrf?: string;
+ timeoutMs?: number;
};
const exactMessages: Record = {
@@ -31,6 +32,7 @@ const exactMessages: Record = {
const codeMessages: Record = {
UNAUTHORIZED: "需要登录后继续操作",
LOGIN_FAILED: "登录失败,请检查密码和验证码",
+ LOGIN_TIMEOUT: "登录校验超时,请稍后重试",
PASSWORD_CHANGE_FAILED: "密码修改失败",
INVALID_PAYLOAD: "提交内容格式不正确",
DATABASE_TEST_FAILED: "数据库连接测试失败",
@@ -42,6 +44,11 @@ const codeMessages: Record = {
NOTICE_SAVE_FAILED: "版本日志保存失败",
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
+ FILE_REQUIRED: "请选择要上传的发布包",
+ PACKAGE_EMPTY: "发布包不能为空",
+ PACKAGE_TOO_LARGE: "发布包超过服务端上传上限",
+ UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
+ MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
SOURCE_SAVE_FAILED: "接口源保存失败",
CHECK_FAILED: "接口健康检测失败",
@@ -56,12 +63,27 @@ export async function adminFetch(target: string, init: RequestInit = {}, opti
headers.set("Content-Type", "application/json");
}
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
- const res = await fetch(target, { ...init, headers, credentials: "include" });
- const data = await res.json().catch(() => ({}));
- if (!res.ok || data.ok === false) {
- throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
+ const controller = new AbortController();
+ const timeoutMs = Math.max(1000, options.timeoutMs ?? 20000);
+ const timeout = window.setTimeout(() => controller.abort("timeout"), timeoutMs);
+ const forwardAbort = () => controller.abort(init.signal?.reason);
+ init.signal?.addEventListener("abort", forwardAbort, { once: true });
+ try {
+ const res = await fetch(target, { ...init, headers, credentials: "include", signal: controller.signal });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data.ok === false) {
+ throw new Error(adminErrorMessage(data, res.status));
+ }
+ return data as T;
+ } catch (error) {
+ if (controller.signal.aborted && !init.signal?.aborted) {
+ throw new Error("请求超时,服务端未及时响应");
+ }
+ throw error;
+ } finally {
+ window.clearTimeout(timeout);
+ init.signal?.removeEventListener("abort", forwardAbort);
}
- return data as T;
}
export function uploadAdminFile(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise {
@@ -76,7 +98,7 @@ export function uploadAdminFile(target: string, form: FormData, options: Admi
xhr.onload = () => {
const data = parseJSONSafe(xhr.responseText, {});
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
- reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
+ reject(new Error(adminErrorMessage(data, xhr.status)));
return;
}
resolve(data as T);
@@ -87,6 +109,15 @@ export function uploadAdminFile(target: string, form: FormData, options: Admi
});
}
+function adminErrorMessage(data: any, status: number) {
+ const code = String(data?.error || "").trim();
+ const detail = String(data?.message || "").trim();
+ if (code && codeMessages[code]) {
+ return codeMessages[code];
+ }
+ return toChineseError(detail || code || `HTTP ${status}`);
+}
+
export function toChineseError(value: string) {
const raw = String(value || "").trim();
const lower = raw.toLowerCase();
diff --git a/server/unified-management/web/admin/src/main.ts b/server/unified-management/web/admin/src/main.ts
index 824f57f..6b65dcb 100644
--- a/server/unified-management/web/admin/src/main.ts
+++ b/server/unified-management/web/admin/src/main.ts
@@ -8,6 +8,64 @@ import "primeicons/primeicons.css";
import App from "./App.vue";
import "./styles.css";
+const resourceReloadKey = "ymhut.admin.resource-reload";
+
+function isAdminResourceFailure(value: unknown) {
+ const message = value instanceof Error ? value.message : String(value || "");
+ return /failed to fetch dynamically imported module|loading chunk|module script|importing a module/i.test(message);
+}
+
+function showResourceFailure() {
+ if (document.getElementById("admin-resource-failure")) return;
+ const notice = document.createElement("div");
+ notice.id = "admin-resource-failure";
+ notice.setAttribute("role", "alert");
+ notice.textContent = "后台资源加载失败,请刷新页面后重试。";
+ Object.assign(notice.style, {
+ position: "fixed",
+ inset: "16px 16px auto 16px",
+ zIndex: "2147483647",
+ padding: "12px 16px",
+ border: "1px solid #dc2626",
+ borderRadius: "6px",
+ color: "#7f1d1d",
+ background: "#fef2f2",
+ fontFamily: "Segoe UI, sans-serif",
+ fontSize: "14px",
+ });
+ document.body.appendChild(notice);
+}
+
+function recoverAdminResources() {
+ const canonical = new URL(location.href);
+ canonical.searchParams.delete("_admin_reload");
+ const marker = `${canonical.pathname}${canonical.search}`;
+ if (sessionStorage.getItem(resourceReloadKey) === marker) {
+ showResourceFailure();
+ return;
+ }
+ sessionStorage.setItem(resourceReloadKey, marker);
+ const next = new URL(location.href);
+ next.searchParams.set("_admin_reload", Date.now().toString());
+ location.replace(next);
+}
+
+window.addEventListener("error", (event) => {
+ const target = event.target as HTMLScriptElement | HTMLLinkElement | null;
+ const resource = target instanceof HTMLScriptElement
+ ? target.src
+ : target instanceof HTMLLinkElement
+ ? target.href
+ : "";
+ if (resource.includes("/admin/assets/") || isAdminResourceFailure(event.error || event.message)) {
+ recoverAdminResources();
+ }
+}, true);
+
+window.addEventListener("unhandledrejection", (event) => {
+ if (isAdminResourceFailure(event.reason)) recoverAdminResources();
+});
+
const RoutePlaceholder = { template: "" };
const routes = [
@@ -57,3 +115,5 @@ createApp(App)
.use(ToastService)
.use(ConfirmationService)
.mount("#app");
+
+window.setTimeout(() => sessionStorage.removeItem(resourceReloadKey), 30000);
diff --git a/src/YMhut.Box.Core/System/HardwareInfoService.cs b/src/YMhut.Box.Core/System/HardwareInfoService.cs
index f2289dc..e6d6653 100644
--- a/src/YMhut.Box.Core/System/HardwareInfoService.cs
+++ b/src/YMhut.Box.Core/System/HardwareInfoService.cs
@@ -71,10 +71,12 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
{
try
{
+ var videoControllers = QueryVideoControllers();
+ var primaryGpu = SelectPrimaryGpu(videoControllers);
var summary = new HardwareInfoSummary(
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
FirstWmiValue("Win32_Processor", "Name") ?? $"{Environment.ProcessorCount} logical processors",
- FirstWmiValue("Win32_VideoController", "Name") ?? "Unknown GPU",
+ primaryGpu?.Name ?? "Unknown GPU",
FormatMemory(FirstWmiUlong("Win32_ComputerSystem", "TotalPhysicalMemory")),
BuildDiskSummary(),
BuildBoardSummary(),
@@ -107,8 +109,7 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
var devices = new List();
devices.AddRange(TryQueryDevices("Processor", "Win32_Processor", null,
"Name", "Manufacturer", "ProcessorId", "DeviceID", "NumberOfCores", "NumberOfLogicalProcessors", "MaxClockSpeed", "Status"));
- devices.AddRange(TryQueryDevices("Graphics", "Win32_VideoController", null,
- "Name", "AdapterCompatibility", "PNPDeviceID", "DriverVersion", "AdapterRAM", "VideoModeDescription", "Status"));
+ devices.AddRange(QueryVideoControllers());
devices.AddRange(TryQueryDevices("Memory", "Win32_PhysicalMemory", null,
"Manufacturer", "PartNumber", "SerialNumber", "Capacity", "Speed", "ConfiguredClockSpeed", "DeviceLocator", "BankLabel", "Status"));
devices.AddRange(TryQueryDevices("Storage", "Win32_DiskDrive", null,
@@ -124,6 +125,8 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
bool.TryParse(physical, out var isPhysical) && isPhysical)
.GroupBy(device => device.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
+ .OrderBy(device => InventoryCategorySortIndex(device.Category))
+ .ThenByDescending(device => device.Category == "Graphics" ? GpuScore(device) : 0)
.ToList();
await WriteLogAsync(
@@ -136,6 +139,104 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
}, cancellationToken).ConfigureAwait(false);
}
+ public static HardwareInventoryDevice? SelectPrimaryGpu(IEnumerable videoControllers)
+ {
+ return videoControllers
+ .Where(device => string.Equals(device.Category, "Graphics", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(GpuScore)
+ .ThenBy(device => device.Name, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static IReadOnlyList QueryVideoControllers()
+ {
+ return TryQueryDevices("Graphics", "Win32_VideoController", null,
+ "Name", "AdapterCompatibility", "PNPDeviceID", "DriverVersion", "AdapterRAM", "VideoModeDescription", "Status");
+ }
+
+ private static int GpuScore(HardwareInventoryDevice device)
+ {
+ var text = string.Join(' ', new[]
+ {
+ device.Name,
+ device.Manufacturer,
+ device.Model,
+ Property(device, "AdapterCompatibility"),
+ Property(device, "PNPDeviceID")
+ }).ToLowerInvariant();
+ var score = 0;
+
+ if (device.Status.Equals("OK", StringComparison.OrdinalIgnoreCase) ||
+ device.Status.Equals("Detected", StringComparison.OrdinalIgnoreCase))
+ {
+ score += 40;
+ }
+
+ if (text.Contains("microsoft basic") || text.Contains("remote display") ||
+ text.Contains("indirect display") || text.Contains("virtual display"))
+ {
+ score -= 1200;
+ }
+
+ if (text.Contains("nvidia") || text.Contains("geforce") || text.Contains("quadro"))
+ {
+ score += 600;
+ }
+ else if (text.Contains("intel") && text.Contains("arc"))
+ {
+ score += 560;
+ }
+ else if (text.Contains("radeon rx") || text.Contains("radeon pro") || text.Contains("firepro"))
+ {
+ score += 540;
+ }
+ else if (text.Contains("amd") || text.Contains("radeon"))
+ {
+ score += 260;
+ }
+
+ if (text.Contains("intel uhd") || text.Contains("intel iris") || text.Contains("intel(r) hd") ||
+ text.Contains("intel hd graphics") || text.Contains("radeon graphics") || text.Contains("radeon vega"))
+ {
+ score -= 300;
+ }
+
+ var pnpId = Property(device, "PNPDeviceID");
+ if (pnpId.StartsWith("PCI\\", StringComparison.OrdinalIgnoreCase))
+ {
+ score += 80;
+ }
+ else if (pnpId.StartsWith("ROOT\\", StringComparison.OrdinalIgnoreCase) ||
+ pnpId.StartsWith("SWD\\", StringComparison.OrdinalIgnoreCase))
+ {
+ score -= 180;
+ }
+
+ if (ulong.TryParse(Property(device, "AdapterRAM"), out var adapterRam) && adapterRam > 0)
+ {
+ score += Math.Min(160, (int)(adapterRam / (1024UL * 1024 * 1024)) * 20);
+ }
+
+ return score;
+ }
+
+ private static string Property(HardwareInventoryDevice device, string name)
+ {
+ return device.Properties.TryGetValue(name, out var value) ? value : string.Empty;
+ }
+
+ private static int InventoryCategorySortIndex(string category) => category switch
+ {
+ "Processor" => 0,
+ "Graphics" => 1,
+ "Memory" => 2,
+ "Storage" => 3,
+ "Display" => 4,
+ "Network" => 5,
+ _ => 6
+ };
+
[SupportedOSPlatform("windows")]
private static IReadOnlyList TryQueryDevices(
string category,
diff --git a/src/YMhut.Box.Core/Tools/ToolCatalog.cs b/src/YMhut.Box.Core/Tools/ToolCatalog.cs
index 8aae9b9..57269a1 100644
--- a/src/YMhut.Box.Core/Tools/ToolCatalog.cs
+++ b/src/YMhut.Box.Core/Tools/ToolCatalog.cs
@@ -2,6 +2,13 @@ namespace YMhut.Box.Core.Tools;
public sealed class ToolCatalog
{
+ private static readonly HashSet ToolboxNativeSurfaceIds = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "hardware",
+ "optimization",
+ "music"
+ };
+
private readonly List _modules;
public ToolCatalog(IEnumerable? modules = null)
@@ -39,14 +46,25 @@ public sealed class ToolCatalog
public static IEnumerable DefaultModules()
{
- return RawToolData
+ return SortForDisplay(RawToolData
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseModule)
- .Concat([DevEnvironmentConfigModule()])
- .OrderBy(module => CategorySortIndex(module.Metadata.Category))
+ .Concat([DevEnvironmentConfigModule()]));
+ }
+
+ public static IOrderedEnumerable SortForDisplay(
+ IEnumerable modules,
+ Func? secondaryGroup = null)
+ {
+ return modules
+ .OrderByDescending(module => module.Metadata.AddedOrder)
+ .ThenBy(module => secondaryGroup?.Invoke(module) ?? 0)
+ .ThenBy(module => CategorySortIndex(module.Metadata.Category))
.ThenBy(module => module.Metadata.Name, StringComparer.CurrentCulture);
}
+ public static bool IsToolboxNativeSurface(string id) => ToolboxNativeSurfaceIds.Contains(id);
+
private static IToolModule DevEnvironmentConfigModule()
{
return new ToolModule(new ToolMetadata(
@@ -71,8 +89,11 @@ public sealed class ToolCatalog
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
+ var addedOrder = int.TryParse(parts.ElementAtOrDefault(6), out var parsedAddedOrder)
+ ? parsedAddedOrder
+ : 0;
- return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category)));
+ return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category), addedOrder));
}
private static ToolCategory ParseCategory(string? value)
@@ -133,6 +154,9 @@ public sealed class ToolCatalog
}
private const string RawToolData = """
+hardware 硬件与系统状态 实时传感器、静态硬件清单、提供方状态与报告导出。 system true 硬件;系统;状态;传感器;CPU;GPU 300
+optimization 优化中心 预览系统变更、按需提权、执行日志和可用回滚。 system true 优化;系统;回滚;清理;网络 290
+music 网络音乐播放器 搜索、歌单、播放队列、歌词与系统媒体控制。 network false 音乐;播放器;歌单;歌词;媒体 280
html_js_playground HTML/JS 渲染器 运行 HTML、CSS、JS、引用脚本并查看输出日志 dev false html;js;webview;plugin;playground
json_formatter JSON 格式化 美化、压缩并校验 JSON 内容 dev true json;format;pretty;minify
base64_codec Base64 编解码 在 Base64 与 UTF-8 文本之间转换 dev true base64;encode;decode
diff --git a/src/YMhut.Box.Core/Tools/ToolMetadata.cs b/src/YMhut.Box.Core/Tools/ToolMetadata.cs
index 884f868..4fc59f0 100644
--- a/src/YMhut.Box.Core/Tools/ToolMetadata.cs
+++ b/src/YMhut.Box.Core/Tools/ToolMetadata.cs
@@ -7,4 +7,5 @@ public sealed record ToolMetadata(
ToolCategory Category,
IReadOnlyList Keywords,
bool OfflineCapable,
- string IconGlyph);
+ string IconGlyph,
+ int AddedOrder = 0);
diff --git a/src/YMhut.Box.Core/Tools/ToolboxLayoutCalculator.cs b/src/YMhut.Box.Core/Tools/ToolboxLayoutCalculator.cs
index b906700..2b2a8c4 100644
--- a/src/YMhut.Box.Core/Tools/ToolboxLayoutCalculator.cs
+++ b/src/YMhut.Box.Core/Tools/ToolboxLayoutCalculator.cs
@@ -9,19 +9,19 @@ public readonly record struct ToolboxGridLayout(
public static class ToolboxLayoutCalculator
{
- public const double MinCardWidth = 264;
- public const double MaxCardWidth = 360;
- public const double ItemGap = 14;
+ public const double MinCardWidth = 236;
+ public const double MaxCardWidth = 340;
+ public const double ItemGap = 12;
public static ToolboxGridLayout Calculate(double availableWidth)
{
if (double.IsNaN(availableWidth) || double.IsInfinity(availableWidth) || availableWidth <= 0)
{
- return new ToolboxGridLayout(3, 318, 332, ShowCategoryRail: true, IsCompact: false);
+ return new ToolboxGridLayout(3, 286, 298, ShowCategoryRail: true, IsCompact: false);
}
- var showCategoryRail = availableWidth >= 980;
- var usable = Math.Max(MinCardWidth, availableWidth - 28);
+ var showCategoryRail = availableWidth >= 1180;
+ var usable = Math.Max(MinCardWidth, availableWidth - 24);
var columns = ColumnsFor(usable);
var cardWidth = Math.Floor((usable - (columns - 1) * ItemGap) / columns);
cardWidth = Math.Clamp(cardWidth, MinCardWidth, MaxCardWidth);
@@ -36,27 +36,27 @@ public static class ToolboxLayoutCalculator
private static int ColumnsFor(double usableWidth)
{
- if (usableWidth < 620)
+ if (usableWidth < 560)
{
return 1;
}
- if (usableWidth < 900)
+ if (usableWidth < 820)
{
return 2;
}
- if (usableWidth < 1220)
+ if (usableWidth < 1120)
{
return 3;
}
- if (usableWidth < 1540)
+ if (usableWidth < 1420)
{
return 4;
}
- if (usableWidth < 1880)
+ if (usableWidth < 1720)
{
return 5;
}
diff --git a/src/YMhut.Box.InstallerBootstrap/App.xaml b/src/YMhut.Box.InstallerBootstrap/App.xaml
new file mode 100644
index 0000000..bec7ea8
--- /dev/null
+++ b/src/YMhut.Box.InstallerBootstrap/App.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs b/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs
index b44361c..44ae7b8 100644
--- a/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs
+++ b/src/YMhut.Box.InstallerBootstrap/InstallerApp.cs
@@ -2,7 +2,7 @@ using Microsoft.UI.Xaml;
namespace YMhut.Box.InstallerBootstrap;
-public sealed class InstallerApp : Application
+public sealed partial class InstallerApp : Application
{
private readonly string[] _arguments;
private Window? _window;
@@ -10,11 +10,55 @@ public sealed class InstallerApp : Application
public InstallerApp(string[] arguments)
{
_arguments = arguments;
+ InitializeComponent();
+ UnhandledException += (_, args) =>
+ {
+ InstallerDiagnostics.Write(args.Exception, "Application.UnhandledException");
+ args.Handled = true;
+ };
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_window = new InstallerWindow(InstallerOptions.Parse(_arguments));
_window.Activate();
+ if (_arguments.Any(argument => argument.Equals("/WINDOWSELFTEST", StringComparison.OrdinalIgnoreCase)))
+ {
+ _ = CompleteWindowSelfTestAsync((InstallerWindow)_window);
+ }
+ }
+
+ private static async Task CompleteWindowSelfTestAsync(InstallerWindow window)
+ {
+ var success = await window.RunWindowNavigationSelfTestAsync();
+ window.Close();
+ Environment.Exit(success ? 0 : 4);
+ }
+}
+
+internal static class InstallerDiagnostics
+{
+ private static int _errorCount;
+
+ internal static string LogPath { get; } = Path.Combine(
+ Path.GetTempPath(),
+ "YMhutBox",
+ "installer-bootstrap.log");
+
+ internal static int ErrorCount => Volatile.Read(ref _errorCount);
+
+ internal static void Write(Exception exception, string context)
+ {
+ Interlocked.Increment(ref _errorCount);
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
+ File.AppendAllText(
+ LogPath,
+ $"[{DateTimeOffset.Now:O}] {context}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}");
+ }
+ catch
+ {
+ }
}
}
diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs b/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs
index 7f35180..2bcd462 100644
--- a/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs
+++ b/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs
@@ -54,15 +54,16 @@ public static class InstallerEngine
public const long RequiredInstallBytes = 700L * 1024 * 1024;
private static readonly Lazy ResolvedEngineCache = new(ResolveEngineCore, LazyThreadSafetyMode.ExecutionAndPublication);
- private sealed record ResolvedEngine(string Path, string? Sha256);
+ private sealed record ResolvedEngine(string Path);
public static bool SelfTest()
{
try
{
var engine = ResolveEngine();
- if (engine is null || !IsPortableExecutable(engine.Path)) return false;
- return engine.Sha256 is null || FileMatches(engine.Path, new FileInfo(engine.Path).Length, engine.Sha256);
+ // Embedded engines are hash-verified during atomic extraction. Re-hashing the
+ // complete installer here doubles preflight time on slower storage.
+ return engine is not null && IsPortableExecutable(engine.Path);
}
catch
{
@@ -95,10 +96,21 @@ public static class InstallerEngine
{
var engine = ResolveEngine();
if (engine is null) return 3;
- var process = Start(engine.Path, arguments, elevate: false);
- if (process is null) return 4;
- await process.WaitForExitAsync().ConfigureAwait(false);
- return process.ExitCode;
+ try
+ {
+ var options = InstallerOptions.Parse(arguments);
+ var process = Start(engine.Path, arguments, RequiresElevationForInstallation(options.InstallDirectory));
+ if (process is null) return 4;
+ using (process)
+ {
+ await process.WaitForExitAsync().ConfigureAwait(false);
+ return process.ExitCode;
+ }
+ }
+ catch (Win32Exception exception) when (exception.NativeErrorCode == 1223)
+ {
+ return 1223;
+ }
}
public static async Task InstallAsync(
@@ -106,7 +118,9 @@ public static class InstallerEngine
IProgress? progress = null,
CancellationToken cancellationToken = default)
{
- var engine = ResolveEngine();
+ // Hashing and extracting the embedded engine can take noticeable time on slow disks.
+ // Keep that work off the WinUI dispatcher before starting the Inno process.
+ var engine = await Task.Run(ResolveEngine, cancellationToken).ConfigureAwait(false);
if (engine is null) return new(false, 3, string.Empty, "安装引擎 YMhutBox.Engine.exe 不存在。");
var logRoot = Path.Combine(Path.GetTempPath(), "YMhutBoxSetup");
Directory.CreateDirectory(logRoot);
@@ -124,17 +138,27 @@ public static class InstallerEngine
$"/TASKS={string.Join(',', tasks)}",
$"/LOG={logPath}"
};
- progress?.Report(new(null, "准备安装", "正在启动成熟的 Inno 安装引擎。"));
+ var prerequisites = GetPrerequisiteStatus();
+ var missingPrerequisites = new List();
+ if (!prerequisites.WebView2Installed) missingPrerequisites.Add("WebView2 Runtime");
+ if (!prerequisites.VcRuntimeInstalled) missingPrerequisites.Add("Visual C++ x64 Runtime");
+ progress?.Report(missingPrerequisites.Count == 0
+ ? new(null, "准备安装", "前置组件已就绪,正在启动安装引擎。")
+ : new(null, "检查前置组件", $"将下载并安装:{string.Join("、", missingPrerequisites)}。"));
Process? process = null;
Task monitor = Task.CompletedTask;
try
{
- process = Start(engine.Path, arguments, RequiresElevation(options.InstallDirectory));
+ process = Start(engine.Path, arguments, RequiresElevationForInstallation(options.InstallDirectory, prerequisites));
if (process is null) return new(false, 4, logPath, "无法启动安装引擎。");
monitor = MonitorLogAsync(logPath, process, progress, cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
await monitor.ConfigureAwait(false);
- if (process.ExitCode != 0) return new(false, process.ExitCode, logPath, $"安装引擎退出代码 {process.ExitCode}。");
+ if (process.ExitCode != 0)
+ {
+ var failure = ReadInstallerFailure(logPath) ?? $"安装引擎退出代码 {process.ExitCode}。";
+ return new(false, process.ExitCode, logPath, failure);
+ }
progress?.Report(new(100, "安装完成", "程序文件、快捷方式和运行库检查已完成。"));
return new(true, 0, logPath);
}
@@ -203,17 +227,43 @@ public static class InstallerEngine
return null;
}
+ public static InstallerPrerequisiteStatus GetPrerequisiteStatus()
+ {
+ var webView2Version = FindWebView2Version();
+ var (vcRuntimeInstalled, vcRuntimeVersion) = FindVcRuntime();
+ return new InstallerPrerequisiteStatus(
+ !string.IsNullOrWhiteSpace(webView2Version),
+ webView2Version,
+ vcRuntimeInstalled,
+ vcRuntimeVersion);
+ }
+
public static InstallerPreflightReport BuildPreflightReport(string targetDirectory)
{
var existing = DetectExistingInstallInfo();
var packageVersion = GetPackageVersion();
var plan = InstallerModeResolver.Resolve(packageVersion, existing);
+ var prerequisites = GetPrerequisiteStatus();
var items = new List
{
InstallerModeResolver.EvaluatePlatform(
OperatingSystem.IsWindows(),
Environment.OSVersion.Version,
System.Runtime.InteropServices.RuntimeInformation.OSArchitecture),
+ new(
+ "webview2",
+ "Microsoft Edge WebView2 Runtime",
+ prerequisites.WebView2Installed ? PreflightStatus.Passed : PreflightStatus.Warning,
+ prerequisites.WebView2Installed
+ ? $"已安装 {prerequisites.WebView2Version ?? "可用版本"}。"
+ : "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
+ new(
+ "vcruntime",
+ "Microsoft Visual C++ x64 Runtime",
+ prerequisites.VcRuntimeInstalled ? PreflightStatus.Passed : PreflightStatus.Warning,
+ prerequisites.VcRuntimeInstalled
+ ? $"已安装 {prerequisites.VcRuntimeVersion ?? "可用版本"}。"
+ : "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
EvaluateDirectory(targetDirectory),
SelfTest()
? new PreflightItem("engine", "安装引擎完整性", PreflightStatus.Passed, "嵌入引擎已通过哈希命名释放和 PE 完整性检查。")
@@ -244,6 +294,72 @@ public static class InstallerEngine
return new InstallerPreflightReport(plan, targetDirectory, RequiredInstallBytes, available, userData, items);
}
+ private static string? FindWebView2Version()
+ {
+ const string clients = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
+ const string state = @"SOFTWARE\Microsoft\EdgeUpdate\ClientState\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
+ foreach (var hive in new[] { Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryHive.CurrentUser })
+ {
+ foreach (var view in new[] { Microsoft.Win32.RegistryView.Registry64, Microsoft.Win32.RegistryView.Registry32 })
+ {
+ try
+ {
+ using var root = Microsoft.Win32.RegistryKey.OpenBaseKey(hive, view);
+ foreach (var path in new[] { clients, state })
+ {
+ using var key = root.OpenSubKey(path);
+ var version = key?.GetValue("pv")?.ToString()?.Trim();
+ if (!string.IsNullOrWhiteSpace(version) && version != "0.0.0.0") return version;
+ }
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ foreach (var root in new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "EdgeWebView", "Application"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "EdgeWebView", "Application")
+ })
+ {
+ try
+ {
+ if (!Directory.Exists(root)) continue;
+ foreach (var directory in Directory.EnumerateDirectories(root).OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase))
+ {
+ var executable = Path.Combine(directory, "msedgewebview2.exe");
+ if (!File.Exists(executable)) continue;
+ return FileVersionInfo.GetVersionInfo(executable).FileVersion ?? Path.GetFileName(directory);
+ }
+ }
+ catch
+ {
+ }
+ }
+ return null;
+ }
+
+ private static (bool Installed, string? Version) FindVcRuntime()
+ {
+ const string path = @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64";
+ foreach (var view in new[] { Microsoft.Win32.RegistryView.Registry64, Microsoft.Win32.RegistryView.Registry32 })
+ {
+ try
+ {
+ using var root = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, view);
+ using var key = root.OpenSubKey(path);
+ var installed = Convert.ToInt32(key?.GetValue("Installed") ?? 0) == 1;
+ if (installed) return (true, key?.GetValue("Version")?.ToString()?.Trim());
+ }
+ catch
+ {
+ }
+ }
+ return (false, null);
+ }
+
private static PreflightItem EvaluateDirectory(string targetDirectory)
{
try
@@ -307,40 +423,93 @@ public static class InstallerEngine
while (!process.HasExited)
{
cancellationToken.ThrowIfCancellationRequested();
- position = await ReadAvailableLogAsync(path, position, progress, cancellationToken).ConfigureAwait(false);
+ var update = await ReadAvailableLogAsync(path, position, cancellationToken).ConfigureAwait(false);
+ position = update.Position;
+ foreach (var item in update.Progress) progress?.Report(item);
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
}
- await ReadAvailableLogAsync(path, position, progress, cancellationToken).ConfigureAwait(false);
+ var finalUpdate = await ReadAvailableLogAsync(path, position, cancellationToken).ConfigureAwait(false);
+ foreach (var item in finalUpdate.Progress) progress?.Report(item);
}
- private static async Task ReadAvailableLogAsync(
+ private static async Task<(long Position, IReadOnlyList Progress)> ReadAvailableLogAsync(
string path,
long position,
- IProgress? progress,
CancellationToken cancellationToken)
{
- if (!File.Exists(path)) return position;
+ if (!File.Exists(path)) return (position, []);
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (position > stream.Length) position = 0;
stream.Position = position;
using var reader = new StreamReader(stream);
+ var updates = new List();
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
var phase = Classify(line);
- if (phase is not null) progress?.Report(phase);
+ if (phase is not null && (updates.Count == 0 || updates[^1] != phase)) updates.Add(phase);
}
- return stream.Position;
+ return (stream.Position, updates);
}
private static InstallerProgress? Classify(string line)
{
+ const string eventMarker = "YMHUT_EVENT:";
+ var eventIndex = line.IndexOf(eventMarker, StringComparison.Ordinal);
+ if (eventIndex >= 0)
+ {
+ var payload = line[(eventIndex + eventMarker.Length)..].Trim();
+ var fields = payload.Split('|', 3, StringSplitOptions.TrimEntries);
+ if (fields.Length >= 2 && fields[0].Equals("STAGE", StringComparison.OrdinalIgnoreCase))
+ {
+ return fields[1].ToLowerInvariant() switch
+ {
+ "prerequisites" => new(null, "检查前置组件", "正在检查 WebView2 与 Visual C++ 运行库。"),
+ "files" => new(0, "写入程序文件", "正在准备写入 YMhut Box 程序文件。"),
+ "shortcuts" => new(null, "创建快捷方式", "正在创建开始菜单、桌面和自启动入口。"),
+ "finalize" => new(null, "完成配置", "正在迁移用户数据并完成安装配置。"),
+ _ => null
+ };
+ }
+
+ if (fields.Length >= 3 && fields[0].Equals("DEPENDENCY", StringComparison.OrdinalIgnoreCase))
+ {
+ var dependencyDetail = fields[1].ToLowerInvariant() switch
+ {
+ "ready" => $"{fields[2]} 已就绪。",
+ "download" => $"正在从官方来源下载 {fields[2]}。",
+ "retry" => $"下载中断,正在重试 {fields[2]}。",
+ "bundled" => $"正在使用安装包内置的 {fields[2]}。",
+ "install" => $"正在安装 {fields[2]}。",
+ "complete" => $"{fields[2]} 安装完成。",
+ _ => fields[2]
+ };
+ return new(null, "检查前置组件", dependencyDetail);
+ }
+
+ if (fields.Length >= 2 && fields[0].Equals("PROGRESS", StringComparison.OrdinalIgnoreCase) &&
+ double.TryParse(fields[1], out var percent))
+ {
+ var fileName = fields.Length >= 3 ? Path.GetFileName(fields[2].Trim('"')) : string.Empty;
+ var fileDetail = string.IsNullOrWhiteSpace(fileName)
+ ? "正在写入程序文件。"
+ : $"正在写入 {fileName}";
+ return new(Math.Clamp(percent, 0, 100), "写入程序文件", fileDetail);
+ }
+
+ if (fields.Length >= 2 && fields[0].Equals("FAILURE", StringComparison.OrdinalIgnoreCase))
+ {
+ return new(null, "前置组件安装失败", LimitError(fields[1]));
+ }
+ }
+
var detail = LimitError(line);
if (line.Contains("Preparing to install", StringComparison.OrdinalIgnoreCase) || line.Contains("Prepare system prerequisites", StringComparison.OrdinalIgnoreCase))
return new(null, "检查环境", detail);
if (line.Contains("Extracting", StringComparison.OrdinalIgnoreCase))
return new(null, "写入程序文件", detail);
- if (line.Contains("Creating", StringComparison.OrdinalIgnoreCase))
+ if (line.Contains("Creating shortcut", StringComparison.OrdinalIgnoreCase) ||
+ line.Contains("Creating shortcuts", StringComparison.OrdinalIgnoreCase))
return new(null, "创建快捷方式", detail);
if (line.Contains("Finalizing installation", StringComparison.OrdinalIgnoreCase))
return new(null, "完成配置", detail);
@@ -351,6 +520,26 @@ public static class InstallerEngine
return null;
}
+ private static string? ReadInstallerFailure(string path)
+ {
+ const string marker = "YMHUT_EVENT:FAILURE|";
+ try
+ {
+ string? failure = null;
+ foreach (var line in File.ReadLines(path))
+ {
+ var index = line.IndexOf(marker, StringComparison.Ordinal);
+ if (index < 0) continue;
+ failure = LimitError(line[(index + marker.Length)..].Trim());
+ }
+ return failure;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
private static Process? Start(string engine, IEnumerable arguments, bool elevate)
{
var startInfo = new ProcessStartInfo
@@ -377,10 +566,10 @@ public static class InstallerEngine
}
var direct = Path.Combine(AppContext.BaseDirectory, "YMhutBox.Engine.exe");
- if (File.Exists(direct)) return new ResolvedEngine(direct, null);
+ if (File.Exists(direct)) return new ResolvedEngine(direct);
var adjacent = Directory.EnumerateFiles(AppContext.BaseDirectory, "YMhut_Box_WinUI_Setup_*.exe")
.FirstOrDefault(path => !string.Equals(path, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase));
- return adjacent is null ? null : new ResolvedEngine(adjacent, null);
+ return adjacent is null ? null : new ResolvedEngine(adjacent);
}
private static ResolvedEngine ExtractEmbeddedEngine(Stream resource)
@@ -419,7 +608,7 @@ public static class InstallerEngine
{
throw new InvalidDataException("嵌入安装引擎的 SHA-256 校验失败。");
}
- return new ResolvedEngine(extracted, hash);
+ return new ResolvedEngine(extracted);
}
private static bool FileMatches(string path, long length, string sha256)
@@ -451,6 +640,17 @@ public static class InstallerEngine
full.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.Windows), StringComparison.OrdinalIgnoreCase);
}
+ private static bool RequiresElevationForInstallation(
+ string directory,
+ InstallerPrerequisiteStatus? prerequisites = null)
+ {
+ if (IsAdministrator()) return false;
+ prerequisites ??= GetPrerequisiteStatus();
+ return RequiresElevation(directory) ||
+ !prerequisites.WebView2Installed ||
+ !prerequisites.VcRuntimeInstalled;
+ }
+
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerPlanning.cs b/src/YMhut.Box.InstallerBootstrap/InstallerPlanning.cs
index 5271bf5..a780f0f 100644
--- a/src/YMhut.Box.InstallerBootstrap/InstallerPlanning.cs
+++ b/src/YMhut.Box.InstallerBootstrap/InstallerPlanning.cs
@@ -41,6 +41,12 @@ public sealed record PreflightItem(
PreflightStatus Status,
string Detail);
+public sealed record InstallerPrerequisiteStatus(
+ bool WebView2Installed,
+ string? WebView2Version,
+ bool VcRuntimeInstalled,
+ string? VcRuntimeVersion);
+
public sealed record InstallerPreflightReport(
InstallerPlan Plan,
string TargetDirectory,
@@ -88,7 +94,12 @@ public static class InstallerModeResolver
return new("platform", "Windows 版本与架构", PreflightStatus.Failed, "需要 Windows 10 1809(内部版本 17763)或更高版本。");
}
- return new("platform", "Windows 版本与架构", PreflightStatus.Passed, $"Windows {osVersion} · x64");
+ var productName = osVersion.Build >= 22000 ? "Windows 11" : "Windows 10";
+ return new(
+ "platform",
+ "Windows 版本与架构",
+ PreflightStatus.Passed,
+ $"{productName} · 内部版本 {osVersion.Build}.{osVersion.Revision} · x64");
}
public static PreflightItem EvaluateDiskSpace(long availableBytes, long requiredBytes)
diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerWindow.cs b/src/YMhut.Box.InstallerBootstrap/InstallerWindow.cs
index 1b626ef..c0ea59b 100644
--- a/src/YMhut.Box.InstallerBootstrap/InstallerWindow.cs
+++ b/src/YMhut.Box.InstallerBootstrap/InstallerWindow.cs
@@ -1,10 +1,12 @@
using System.Diagnostics;
using Microsoft.UI;
+using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Text;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
using Windows.UI.Text;
using Windows.Graphics;
using Windows.Storage.Pickers;
@@ -14,15 +16,20 @@ namespace YMhut.Box.InstallerBootstrap;
public sealed class InstallerWindow : Window
{
- private static readonly SolidColorBrush BackgroundBrush = Brush("#F5F6F7");
- private static readonly SolidColorBrush SurfaceBrush = Brush("#FFFFFF");
- private static readonly SolidColorBrush StrokeBrush = Brush("#DDE1E4");
- private static readonly SolidColorBrush TextBrush = Brush("#1B1F22");
- private static readonly SolidColorBrush SecondaryBrush = Brush("#626A70");
- private static readonly SolidColorBrush AccentBrush = Brush("#087F72");
+ private static readonly SolidColorBrush BackgroundBrush = Brush("#DDF4F5F7");
+ private static readonly SolidColorBrush SurfaceBrush = Brush("#EBFFFFFF");
+ private static readonly SolidColorBrush StrokeBrush = Brush("#34747C88");
+ private static readonly SolidColorBrush TextBrush = Brush("#161A21");
+ private static readonly SolidColorBrush SecondaryBrush = Brush("#5D6673");
+ private static readonly SolidColorBrush AccentBrush = Brush("#2563D9");
private static readonly SolidColorBrush WhiteBrush = Brush("#FFFFFF");
private readonly ContentControl _pageHost = new();
+ private readonly Border _titleDragArea = new();
private readonly StackPanel _stepList = new() { Spacing = 3 };
+ private readonly StackPanel _preflightPanel = new() { Spacing = 12 };
+ private readonly StackPanel _completionActions = new() { Spacing = 10 };
+ private readonly InfoBar _navigationError = new() { IsOpen = false, IsClosable = true, Severity = InfoBarSeverity.Error };
+ private readonly UIElement?[] _pages = new UIElement?[7];
private readonly Button _backButton;
private readonly Button _nextButton;
private readonly Button _cancelButton;
@@ -33,31 +40,53 @@ public sealed class InstallerWindow : Window
private readonly CheckBox _startMenuShortcut = new() { Content = "创建开始菜单快捷方式", IsChecked = true };
private readonly CheckBox _autoStart = new() { Content = "登录 Windows 后自动启动" };
private readonly CheckBox _launchAfterInstall = new() { Content = "安装完成后启动 YMhut Box", IsChecked = true };
- private readonly ProgressBar _progress = new() { Height = 5, IsIndeterminate = true };
+ private readonly ProgressBar _progress = new() { Height = 7, IsIndeterminate = true };
private readonly TextBlock _phase = Text("等待开始", 18, FontWeights.SemiBold);
private readonly TextBlock _detail = Text("安装尚未开始。", 12, foreground: SecondaryBrush, maxLines: 3);
- private readonly TextBox _installLog = new() { IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 220 };
+ private readonly TextBlock _progressValue = Text("准备中", 12, FontWeights.SemiBold, AccentBrush);
+ private readonly TextBlock _latestOutput = Text("尚无安装输出", 11.5, foreground: SecondaryBrush, maxLines: 1);
+ private readonly TextBox _installLog = new()
+ {
+ IsReadOnly = true,
+ AcceptsReturn = true,
+ TextWrapping = TextWrapping.NoWrap,
+ FontFamily = new FontFamily("Cascadia Mono"),
+ FontSize = 11,
+ Height = 116
+ };
+ private readonly Expander _installLogExpander = new() { IsExpanded = false };
+ private readonly List _installStageViews = [];
private readonly TextBlock _completionTitle = Text("安装完成", 28, FontWeights.SemiBold);
private readonly TextBlock _completionDetail = Text("YMhut Box 已准备就绪。", 13, foreground: SecondaryBrush, maxLines: 4);
+ private readonly FontIcon _completionIcon = new() { Glyph = "\uE73E", FontSize = 46, Foreground = AccentBrush };
private InstallerOptions _options;
private int _step;
private bool _installing;
private bool _installed;
private string? _logPath;
private CancellationTokenSource? _installCts;
+ private CancellationTokenSource? _diskCheckCts;
private InstallerPreflightReport? _preflightReport;
private AppWindow? _appWindow;
+ private int _diskCheckVersion;
+ private int _preflightCheckVersion;
+ private bool _preflightChecking;
+ private int _activeInstallStage;
+ private int _installLogEntries;
+ private string? _lastInstallLogDetail;
public InstallerWindow(InstallerOptions options)
{
_options = options;
Title = "YMhut Box 安装程序";
_backButton = CommandButton("上一步", "\uE72B", () => Move(-1));
- _nextButton = CommandButton("下一步", "\uE72A", async () => await MoveNextAsync(), primary: true);
+ _nextButton = CommandButton("下一步", "\uE72A", MoveNextAsync, primary: true);
_cancelButton = CommandButton("取消", "\uE711", CancelOrClose);
Content = BuildShell();
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(_titleDragArea);
_directory.Text = InstallerEngine.DetectExistingInstall() ?? options.InstallDirectory;
- _directory.TextChanged += (_, _) => RefreshDiskSpace();
+ _directory.TextChanged += (_, _) => ScheduleDiskSpaceRefresh();
_licenseAccepted.Checked += (_, _) => RefreshButtons();
_licenseAccepted.Unchecked += (_, _) => RefreshButtons();
Activated += (_, _) => ConfigureWindow();
@@ -67,9 +96,27 @@ public sealed class InstallerWindow : Window
private void ConfigureWindow()
{
if (_appWindow is not null) return;
+ try
+ {
+ SystemBackdrop = new MicaBackdrop { Kind = MicaKind.BaseAlt };
+ }
+ catch
+ {
+ SystemBackdrop = null;
+ }
var hwnd = WindowNative.GetWindowHandle(this);
_appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd));
_appWindow.Resize(new SizeInt32(920, 640));
+ if (AppWindowTitleBar.IsCustomizationSupported())
+ {
+ _appWindow.TitleBar.ExtendsContentIntoTitleBar = true;
+ _appWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
+ _appWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
+ _appWindow.TitleBar.ButtonForegroundColor = Colors.White;
+ _appWindow.TitleBar.ButtonInactiveForegroundColor = ColorHelper.FromArgb(255, 172, 178, 188);
+ _appWindow.TitleBar.ButtonHoverBackgroundColor = ColorHelper.FromArgb(255, 55, 60, 69);
+ _appWindow.TitleBar.ButtonPressedBackgroundColor = ColorHelper.FromArgb(255, 70, 76, 88);
+ }
if (_appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.IsResizable = true;
@@ -87,11 +134,18 @@ public sealed class InstallerWindow : Window
private UIElement BuildShell()
{
var root = new Grid { Background = BackgroundBrush };
- root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
- root.ColumnDefinitions.Add(new ColumnDefinition());
+ root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(42) });
+ root.RowDefinitions.Add(new RowDefinition());
+ root.Children.Add(BuildTitleBar());
+
+ var body = new Grid();
+ body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
+ body.ColumnDefinitions.Add(new ColumnDefinition());
var sidebar = new Border
{
- Background = Brush("#17201F"),
+ Background = Brush("#D91D2026"),
+ BorderBrush = Brush("#385A6270"),
+ BorderThickness = new Thickness(0, 0, 1, 0),
Padding = new Thickness(20, 28, 16, 22),
Child = new Grid
{
@@ -109,11 +163,19 @@ public sealed class InstallerWindow : Window
}
}
};
- root.Children.Add(sidebar);
+ body.Children.Add(sidebar);
var main = new Grid { Padding = new Thickness(34, 28, 34, 24) };
main.RowDefinitions.Add(new RowDefinition());
main.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ main.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ _pageHost.HorizontalContentAlignment = HorizontalAlignment.Stretch;
+ _pageHost.VerticalContentAlignment = VerticalAlignment.Stretch;
+ _pageHost.HorizontalAlignment = HorizontalAlignment.Stretch;
+ _pageHost.VerticalAlignment = VerticalAlignment.Stretch;
main.Children.Add(_pageHost);
+ _navigationError.Margin = new Thickness(0, 12, 0, 0);
+ Grid.SetRow(_navigationError, 1);
+ main.Children.Add(_navigationError);
var footer = new Grid { Margin = new Thickness(0, 18, 0, 0), ColumnSpacing = 8 };
footer.ColumnDefinitions.Add(new ColumnDefinition());
footer.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -125,13 +187,35 @@ public sealed class InstallerWindow : Window
footer.Children.Add(_backButton);
Grid.SetColumn(_nextButton, 2);
footer.Children.Add(_nextButton);
- Grid.SetRow(footer, 1);
+ Grid.SetRow(footer, 2);
main.Children.Add(footer);
Grid.SetColumn(main, 1);
- root.Children.Add(main);
+ body.Children.Add(main);
+ Grid.SetRow(body, 1);
+ root.Children.Add(body);
return root;
}
+ private UIElement BuildTitleBar()
+ {
+ _titleDragArea.Background = Brush("#E61D2026");
+ _titleDragArea.BorderBrush = Brush("#385A6270");
+ _titleDragArea.BorderThickness = new Thickness(0, 0, 0, 1);
+ _titleDragArea.Padding = new Thickness(16, 0, 148, 0);
+ _titleDragArea.Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 9,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children =
+ {
+ AppIcon(18),
+ Text("YMhut Box 安装程序", 13, FontWeights.SemiBold, WhiteBrush)
+ }
+ };
+ return _titleDragArea;
+ }
+
private UIElement SidebarBrand()
{
var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 11 };
@@ -140,8 +224,11 @@ public sealed class InstallerWindow : Window
Width = 38,
Height = 38,
CornerRadius = new CornerRadius(8),
- Background = AccentBrush,
- Child = new FontIcon { Glyph = "\uE943", Foreground = WhiteBrush, FontSize = 19 }
+ Background = Brush("#F2FFFFFF"),
+ BorderBrush = Brush("#4DFFFFFF"),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(7),
+ Child = AppIcon(24)
});
panel.Children.Add(new StackPanel
{
@@ -149,7 +236,7 @@ public sealed class InstallerWindow : Window
Children =
{
Text("YMhut Box", 17, FontWeights.SemiBold, WhiteBrush),
- Text("安装引导器", 11, foreground: Brush("#AFC1BE"))
+ Text("安装引导器", 11, foreground: Brush("#C2C8D1"))
}
});
return panel;
@@ -163,7 +250,7 @@ public sealed class InstallerWindow : Window
{
Padding = new Thickness(10, 8, 10, 8),
CornerRadius = new CornerRadius(6),
- Child = Text(title, 13, FontWeights.SemiBold, Brush("#B9C7C5"), maxLines: 1)
+ Child = Text(title, 13, FontWeights.SemiBold, Brush("#C5CBD4"), maxLines: 1)
});
}
Grid.SetRow(_stepList, 1);
@@ -173,7 +260,7 @@ public sealed class InstallerWindow : Window
private UIElement SidebarFooter()
{
- var footer = Text("Windows 10 1809+ · x64\nGPL-3.0", 11, foreground: Brush("#91A6A2"), maxLines: 2);
+ var footer = Text("YMhut Box\n安全安装向导", 11, foreground: Brush("#9FA7B3"), maxLines: 2);
Grid.SetRow(footer, 2);
return footer;
}
@@ -182,7 +269,7 @@ public sealed class InstallerWindow : Window
{
var existingInfo = InstallerEngine.DetectExistingInstallInfo();
var plan = InstallerModeResolver.Resolve(
- InstallerEngine.GetPackageVersion(),
+ GetBootstrapVersion(),
existingInfo);
var existing = existingInfo?.InstallDirectory;
return PageStack(
@@ -197,20 +284,48 @@ public sealed class InstallerWindow : Window
private UIElement LicensePage()
{
var text = ReadEmbeddedText(
- "YMhutBox.LICENSE.txt",
- "YMhut Box is licensed under GNU General Public License v3.0.");
- var license = new TextBox
+ "YMhutBox.EULA.txt",
+ "请在安装和使用 YMhut Box 前阅读并同意软件许可与服务协议。");
+ var document = Text(text, 12, foreground: TextBrush);
+ document.IsTextSelectionEnabled = true;
+ document.TextWrapping = TextWrapping.Wrap;
+ var license = new Border
{
- Text = text,
- IsReadOnly = true,
- AcceptsReturn = true,
- TextWrapping = TextWrapping.Wrap,
- MinHeight = 360,
- FontFamily = new FontFamily("Cascadia Mono"),
- FontSize = 11.5
+ Margin = new Thickness(0, 16, 0, 12),
+ Padding = new Thickness(18, 14, 6, 14),
+ CornerRadius = new CornerRadius(8),
+ Background = SurfaceBrush,
+ BorderBrush = StrokeBrush,
+ BorderThickness = new Thickness(1),
+ Child = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
+ Padding = new Thickness(0, 0, 10, 0),
+ Content = document
+ }
};
- ScrollViewer.SetVerticalScrollBarVisibility(license, ScrollBarVisibility.Auto);
- return PageStack(Text("许可协议", 28, FontWeights.SemiBold), Text("继续安装前,请阅读开源许可。", 13, foreground: SecondaryBrush), license, _licenseAccepted);
+ _licenseAccepted.Content = "我已阅读并同意《YMhut Box 软件许可与服务协议》";
+ _licenseAccepted.HorizontalAlignment = HorizontalAlignment.Left;
+ _licenseAccepted.VerticalAlignment = VerticalAlignment.Center;
+ _licenseAccepted.MinHeight = 32;
+
+ var page = new Grid { HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch };
+ page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ page.RowDefinitions.Add(new RowDefinition());
+ page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ page.Children.Add(Text("软件许可与服务协议", 28, FontWeights.SemiBold));
+ var subtitle = Text("继续安装前,请阅读并确认以下条款。", 13, foreground: SecondaryBrush);
+ Grid.SetRow(subtitle, 1);
+ subtitle.Margin = new Thickness(0, 8, 0, 0);
+ page.Children.Add(subtitle);
+ Grid.SetRow(license, 2);
+ page.Children.Add(license);
+ Grid.SetRow(_licenseAccepted, 3);
+ page.Children.Add(_licenseAccepted);
+ return page;
}
private UIElement DirectoryPage()
@@ -225,7 +340,7 @@ public sealed class InstallerWindow : Window
Grid.SetColumn(browse, 1);
browse.VerticalAlignment = VerticalAlignment.Bottom;
row.Children.Add(browse);
- RefreshDiskSpace();
+ ScheduleDiskSpaceRefresh(immediate: true);
return PageStack(
Text("安装位置", 28, FontWeights.SemiBold),
Text("默认安装到当前用户目录;只有受保护目录和前置组件操作才会请求管理员权限。", 13, foreground: SecondaryBrush, maxLines: 3),
@@ -235,62 +350,267 @@ public sealed class InstallerWindow : Window
}
private UIElement OptionsPage()
- => PageStack(
+ {
+ var prerequisites = InstallerEngine.GetPrerequisiteStatus();
+ var prerequisiteDetail = string.Join("\n", new[]
+ {
+ $"WebView2 Runtime:{(prerequisites.WebView2Installed ? $"已安装 {prerequisites.WebView2Version}" : "缺失,将从微软官方地址下载并安装")}",
+ $"Visual C++ x64 Runtime:{(prerequisites.VcRuntimeInstalled ? $"已安装 {prerequisites.VcRuntimeVersion}" : "缺失,将从微软官方地址下载并安装")}",
+ prerequisites.WebView2Installed && prerequisites.VcRuntimeInstalled
+ ? "无需下载前置组件。"
+ : "开始安装后将自动下载;系统级运行库安装时会请求管理员权限。"
+ });
+ return PageStack(
Text("安装选项", 28, FontWeights.SemiBold),
- Text("这些选项会直接传给 Inno 安装引擎,并支持后续升级和修复。", 13, foreground: SecondaryBrush, maxLines: 2),
OptionBand("快捷方式", _desktopShortcut, _startMenuShortcut),
OptionBand("启动", _autoStart, _launchAfterInstall),
- InfoBand("前置组件", "安装引擎将检测 WebView2 Runtime 与 VC++ x64 Runtime;仅缺失时下载安装。"));
+ InfoBand("前置组件下载与安装", prerequisiteDetail));
+ }
private UIElement EnvironmentCheckPage()
{
- _preflightReport = InstallerEngine.BuildPreflightReport(_directory.Text.Trim());
- var panel = PageStack(
- Text("环境检查", 28, FontWeights.SemiBold),
- Text("开始写入前验证架构、目录、磁盘、安装引擎和数据保留策略。", 13, foreground: SecondaryBrush, maxLines: 2),
- InfoBand("安装模式", $"{_preflightReport.Plan.DisplayName} · 目标版本 {_preflightReport.Plan.PackageVersion}"));
- foreach (var item in _preflightReport.Items)
- {
- panel.Children.Add(PreflightBand(item));
- }
-
+ _preflightPanel.HorizontalAlignment = HorizontalAlignment.Stretch;
return new ScrollViewer
{
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
- Content = panel
+ Padding = new Thickness(0, 0, 8, 0),
+ Content = _preflightPanel
};
}
+ private void BeginEnvironmentCheck()
+ {
+ var version = ++_preflightCheckVersion;
+ _preflightChecking = true;
+ _preflightReport = null;
+ _preflightPanel.Children.Clear();
+ _preflightPanel.Children.Add(Text("环境检查", 28, FontWeights.SemiBold));
+ _preflightPanel.Children.Add(Text("正在检查安装引擎、目标目录和磁盘空间。", 13, foreground: SecondaryBrush, maxLines: 2));
+ RefreshButtons();
+ _ = RefreshEnvironmentCheckAsync(version, _directory.Text.Trim());
+ }
+
+ private async Task RefreshEnvironmentCheckAsync(int version, string targetDirectory)
+ {
+ InstallerPreflightReport? report = null;
+ Exception? failure = null;
+ try
+ {
+ report = await Task.Run(() => InstallerEngine.BuildPreflightReport(targetDirectory));
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ InstallerDiagnostics.Write(exception, "Environment preflight");
+ }
+
+ if (version != _preflightCheckVersion || _step != 4) return;
+
+ _preflightChecking = false;
+ _preflightPanel.Children.Clear();
+ _preflightPanel.Children.Add(Text("环境检查", 28, FontWeights.SemiBold));
+ if (failure is not null || report is null)
+ {
+ _preflightPanel.Children.Add(Text("环境检查未完成,请返回后重新进入此步骤。", 13, foreground: SecondaryBrush, maxLines: 2));
+ _navigationError.Title = "环境检查失败";
+ _navigationError.Message = LimitText(failure?.Message ?? "未知错误");
+ _navigationError.IsOpen = true;
+ RefreshButtons();
+ return;
+ }
+
+ _preflightReport = report;
+ _preflightPanel.Children.Add(Text("开始写入前验证架构、目录、磁盘、安装引擎和数据保留策略。", 13, foreground: SecondaryBrush, maxLines: 2));
+ _preflightPanel.Children.Add(InfoBand("安装模式", $"{report.Plan.DisplayName} · 目标版本 {report.Plan.PackageVersion}"));
+ foreach (var item in report.Items)
+ {
+ _preflightPanel.Children.Add(PreflightBand(item));
+ }
+ RefreshButtons();
+ }
+
private UIElement InstallingPage()
- => PageStack(
- Text("正在安装", 28, FontWeights.SemiBold),
- _phase,
- _detail,
- _progress,
- _installLog);
+ {
+ var phaseHeader = new Grid { ColumnSpacing = 12 };
+ phaseHeader.ColumnDefinitions.Add(new ColumnDefinition());
+ phaseHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ phaseHeader.Children.Add(_phase);
+ _progressValue.VerticalAlignment = VerticalAlignment.Center;
+ Grid.SetColumn(_progressValue, 1);
+ phaseHeader.Children.Add(_progressValue);
+
+ var currentStatus = new Border
+ {
+ Padding = new Thickness(16, 13, 16, 13),
+ CornerRadius = new CornerRadius(8),
+ Background = SurfaceBrush,
+ BorderBrush = StrokeBrush,
+ BorderThickness = new Thickness(1),
+ Child = new StackPanel
+ {
+ Spacing = 5,
+ Children =
+ {
+ phaseHeader,
+ _detail,
+ _progress
+ }
+ }
+ };
+
+ var stages = new StackPanel { Spacing = 3 };
+ foreach (var title in new[] { "准备安装", "前置组件", "写入文件", "创建入口", "完成配置" })
+ {
+ var view = CreateInstallStage(title);
+ _installStageViews.Add(view);
+ stages.Children.Add(view.Container);
+ }
+
+ var stagePanel = new Border
+ {
+ Padding = new Thickness(12, 9, 12, 9),
+ CornerRadius = new CornerRadius(8),
+ Background = SurfaceBrush,
+ BorderBrush = StrokeBrush,
+ BorderThickness = new Thickness(1),
+ Child = stages
+ };
+
+ var logHeader = new Grid { ColumnSpacing = 10 };
+ logHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ logHeader.ColumnDefinitions.Add(new ColumnDefinition());
+ logHeader.Children.Add(Text("详细安装输出", 12.5, FontWeights.SemiBold));
+ _latestOutput.HorizontalAlignment = HorizontalAlignment.Right;
+ _latestOutput.VerticalAlignment = VerticalAlignment.Center;
+ Grid.SetColumn(_latestOutput, 1);
+ logHeader.Children.Add(_latestOutput);
+ _installLogExpander.Header = logHeader;
+ _installLogExpander.Content = _installLog;
+ _installLogExpander.HorizontalAlignment = HorizontalAlignment.Stretch;
+ ScrollViewer.SetHorizontalScrollBarVisibility(_installLog, ScrollBarVisibility.Auto);
+ ScrollViewer.SetVerticalScrollBarVisibility(_installLog, ScrollBarVisibility.Auto);
+
+ var page = new StackPanel { Spacing = 12 };
+ page.Children.Add(Text("正在安装", 28, FontWeights.SemiBold));
+ page.Children.Add(Text("安装期间可查看当前阶段;详细输出会保留到安装日志。", 12.5, foreground: SecondaryBrush, maxLines: 2));
+ page.Children.Add(currentStatus);
+ page.Children.Add(stagePanel);
+ page.Children.Add(_installLogExpander);
+ ResetInstallProgressView();
+ return page;
+ }
+
+ private static InstallStageView CreateInstallStage(string title)
+ {
+ var icon = new FontIcon
+ {
+ Glyph = "\uE915",
+ FontSize = 14,
+ Foreground = SecondaryBrush,
+ Width = 24,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var label = Text(title, 12.5, FontWeights.SemiBold);
+ var status = Text("等待", 11.5, foreground: SecondaryBrush);
+ status.HorizontalAlignment = HorizontalAlignment.Right;
+ status.VerticalAlignment = VerticalAlignment.Center;
+ var row = new Grid { Height = 27, ColumnSpacing = 8 };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ row.ColumnDefinitions.Add(new ColumnDefinition());
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ row.Children.Add(icon);
+ Grid.SetColumn(label, 1);
+ row.Children.Add(label);
+ Grid.SetColumn(status, 2);
+ row.Children.Add(status);
+ return new InstallStageView(row, icon, label, status);
+ }
private UIElement CompletePage()
{
var panel = PageStack(
- new FontIcon { Glyph = _installed ? "\uE73E" : "\uEA39", FontSize = 46, Foreground = _installed ? AccentBrush : Brush("#C42B1C") },
+ _completionIcon,
_completionTitle,
- _completionDetail);
+ _completionDetail,
+ _completionActions);
+ return panel;
+ }
+
+ private void RefreshCompletePage()
+ {
+ _completionIcon.Glyph = _installed ? "\uE73E" : "\uEA39";
+ _completionIcon.Foreground = _installed ? AccentBrush : Brush("#C42B1C");
+ _completionActions.Children.Clear();
if (!string.IsNullOrWhiteSpace(_logPath))
{
- panel.Children.Add(InfoBand("安装日志", _logPath));
- panel.Children.Add(CommandButton("打开安装日志", "\uE8A5", OpenInstallLog));
+ _completionActions.Children.Add(InfoBand("安装日志", _logPath));
+ _completionActions.Children.Add(CommandButton("打开安装日志", "\uE8A5", OpenInstallLog));
}
if (!_installed)
{
- panel.Children.Add(InfoBand("下一步", "可返回环境检查后重新安装;失败日志不会自动删除。"));
+ _completionActions.Children.Add(InfoBand("下一步", "可返回环境检查后重新安装;失败日志不会自动删除。"));
}
- return panel;
}
private void ShowStep()
{
- _pageHost.Content = _step switch
+ _navigationError.IsOpen = false;
+ _pageHost.Content = GetOrCreatePage(_step);
+ if (_step == 2)
+ {
+ ScheduleDiskSpaceRefresh(immediate: true);
+ }
+ else if (_step == 4)
+ {
+ BeginEnvironmentCheck();
+ }
+ else if (_step == 6)
+ {
+ RefreshCompletePage();
+ }
+ for (var index = 0; index < _stepList.Children.Count; index++)
+ {
+ if (_stepList.Children[index] is not Border border || border.Child is not TextBlock text) continue;
+ border.Background = index == _step ? Brush("#4D4C78D8") : Brush("#001D2026");
+ text.Foreground = index == _step ? WhiteBrush : Brush("#C5CBD4");
+ }
+ RefreshButtons();
+ }
+
+ internal async Task RunWindowNavigationSelfTestAsync()
+ {
+ var initialErrorCount = InstallerDiagnostics.ErrorCount;
+ try
+ {
+ await Task.Delay(120);
+ foreach (var step in new[] { 0, 1, 2, 3, 4, 3, 2, 1, 0, 4, 5, 6 })
+ {
+ _step = step;
+ ShowStep();
+ if (step == 5)
+ {
+ ApplyInstallProgress(new InstallerProgress(42, "写入程序文件", "正在写入 YMhutBox.exe"));
+ _installLogExpander.IsExpanded = true;
+ }
+ await Task.Delay(20);
+ }
+ await Task.Delay(100);
+ return InstallerDiagnostics.ErrorCount == initialErrorCount;
+ }
+ catch (Exception exception)
+ {
+ InstallerDiagnostics.Write(exception, "Window navigation self-test");
+ return false;
+ }
+ }
+
+ private UIElement GetOrCreatePage(int step)
+ {
+ return _pages[step] ??= step switch
{
0 => WelcomePage(),
1 => LicensePage(),
@@ -300,13 +620,6 @@ public sealed class InstallerWindow : Window
5 => InstallingPage(),
_ => CompletePage()
};
- for (var index = 0; index < _stepList.Children.Count; index++)
- {
- if (_stepList.Children[index] is not Border border || border.Child is not TextBlock text) continue;
- border.Background = index == _step ? Brush("#243B37") : Brush("#0017201F");
- text.Foreground = index == _step ? WhiteBrush : Brush("#B9C7C5");
- }
- RefreshButtons();
}
private void RefreshButtons()
@@ -314,7 +627,7 @@ public sealed class InstallerWindow : Window
_backButton.IsEnabled = _step > 0 && !_installing && !_installed;
_nextButton.IsEnabled = !_installing &&
(_step != 1 || _licenseAccepted.IsChecked == true) &&
- (_step != 4 || _preflightReport?.CanInstall == true);
+ (_step != 4 || !_preflightChecking && _preflightReport?.CanInstall == true);
_cancelButton.IsEnabled = true;
SetButtonLabel(_cancelButton, _installing ? "停止安装" : _step == 6 ? "关闭" : "取消");
SetButtonLabel(_nextButton, _step switch
@@ -331,6 +644,7 @@ public sealed class InstallerWindow : Window
{
if (_installing) return;
_step = Math.Clamp(_step + delta, 0, 6);
+ if (_step != 4) _preflightCheckVersion++;
ShowStep();
}
@@ -376,25 +690,35 @@ public sealed class InstallerWindow : Window
_installing = true;
_installCts = new CancellationTokenSource();
ShowStep();
- var progress = new Progress(value =>
+ ResetInstallProgressView();
+ var progress = new Progress(ApplyInstallProgress);
+ InstallerResult result;
+ try
{
- _phase.Text = value.Phase;
- _detail.Text = value.Detail;
- _progress.IsIndeterminate = value.Percent is null;
- if (value.Percent is not null) _progress.Value = value.Percent.Value;
- if (!string.IsNullOrWhiteSpace(value.Detail))
- {
- AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {value.Detail}\r\n");
- _installLog.SelectionStart = _installLog.Text.Length;
- _installLog.SelectionLength = 0;
- }
- });
- var result = await InstallerEngine.InstallAsync(_options, progress, _installCts.Token);
- _installCts.Dispose();
- _installCts = null;
- _installing = false;
+ result = await InstallerEngine.InstallAsync(_options, progress, _installCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ result = new InstallerResult(false, -1, _logPath ?? string.Empty, "安装已由用户取消。");
+ }
+ catch (Exception exception)
+ {
+ InstallerDiagnostics.Write(exception, "InstallAsync");
+ result = new InstallerResult(false, -1, _logPath ?? string.Empty, LimitText(exception.Message));
+ }
+ finally
+ {
+ _installCts?.Dispose();
+ _installCts = null;
+ _installing = false;
+ }
_installed = result.Success;
_logPath = result.LogPath;
+ if (!result.Success)
+ {
+ _installLogExpander.IsExpanded = true;
+ AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {result.Error ?? $"安装引擎退出代码 {result.ExitCode}。"}\r\n");
+ }
_completionTitle.Text = result.Success ? "安装完成" : "安装未完成";
_completionDetail.Text = result.Success
? "程序文件、快捷方式、运行库检查与旧版数据迁移均已交由安装引擎完成。"
@@ -409,6 +733,11 @@ public sealed class InstallerWindow : Window
{
_cancelButton.IsEnabled = false;
SetButtonLabel(_cancelButton, "正在停止");
+ _phase.Text = "正在停止安装";
+ _detail.Text = "正在等待安装引擎安全退出,请勿关闭窗口。";
+ _progressValue.Text = "正在取消";
+ _latestOutput.Text = "已发送取消请求";
+ AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] 已请求取消安装。\r\n");
_installCts?.Cancel();
return;
}
@@ -438,6 +767,8 @@ public sealed class InstallerWindow : Window
{
const int maximumCharacters = 32000;
_installLog.Text += value;
+ _installLogEntries++;
+ _latestOutput.Text = $"{_installLogEntries} 条事件";
if (_installLog.Text.Length > maximumCharacters)
{
_installLog.Text = "[较早的安装状态已省略,完整内容请查看安装日志。]\r\n" + _installLog.Text[^maximumCharacters..];
@@ -453,22 +784,129 @@ public sealed class InstallerWindow : Window
if (folder is not null) _directory.Text = Path.Combine(folder.Path, "YMhut Box");
}
- private void RefreshDiskSpace()
+ private void ApplyInstallProgress(InstallerProgress value)
+ {
+ if (!string.IsNullOrWhiteSpace(value.Phase)) _phase.Text = value.Phase;
+ if (!string.IsNullOrWhiteSpace(value.Detail)) _detail.Text = value.Detail;
+ _progress.IsIndeterminate = value.Percent is null;
+ if (value.Percent is not null)
+ {
+ _progress.Value = Math.Clamp(value.Percent.Value, 0, 100);
+ _progressValue.Text = $"{_progress.Value:0}%";
+ }
+ else
+ {
+ _progressValue.Text = "进行中";
+ }
+
+ _activeInstallStage = Math.Max(_activeInstallStage, ResolveInstallStage(value.Phase, _activeInstallStage));
+ UpdateInstallStages(_activeInstallStage);
+ if (!string.IsNullOrWhiteSpace(value.Detail) && !string.Equals(value.Detail, _lastInstallLogDetail, StringComparison.Ordinal))
+ {
+ _lastInstallLogDetail = value.Detail;
+ AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {value.Detail}\r\n");
+ _installLog.SelectionStart = _installLog.Text.Length;
+ _installLog.SelectionLength = 0;
+ }
+ }
+
+ private void ResetInstallProgressView()
+ {
+ _activeInstallStage = 0;
+ _installLogEntries = 0;
+ _lastInstallLogDetail = null;
+ _installLog.Text = string.Empty;
+ _phase.Text = "准备安装";
+ _detail.Text = "正在准备安装引擎和目标目录。";
+ _progress.IsIndeterminate = true;
+ _progress.Value = 0;
+ _progressValue.Text = "准备中";
+ _latestOutput.Text = "尚无安装输出";
+ _installLogExpander.IsExpanded = false;
+ UpdateInstallStages(0);
+ }
+
+ private void UpdateInstallStages(int activeStage)
+ {
+ for (var index = 0; index < _installStageViews.Count; index++)
+ {
+ var view = _installStageViews[index];
+ if (index < activeStage)
+ {
+ view.Icon.Glyph = "\uE73E";
+ view.Icon.Foreground = AccentBrush;
+ view.Status.Text = "已完成";
+ view.Status.Foreground = AccentBrush;
+ view.Label.Foreground = TextBrush;
+ }
+ else if (index == activeStage)
+ {
+ view.Icon.Glyph = "\uE72C";
+ view.Icon.Foreground = AccentBrush;
+ view.Status.Text = "进行中";
+ view.Status.Foreground = AccentBrush;
+ view.Label.Foreground = TextBrush;
+ }
+ else
+ {
+ view.Icon.Glyph = "\uE915";
+ view.Icon.Foreground = SecondaryBrush;
+ view.Status.Text = "等待";
+ view.Status.Foreground = SecondaryBrush;
+ view.Label.Foreground = SecondaryBrush;
+ }
+ }
+ }
+
+ private static int ResolveInstallStage(string phase, int currentStage)
+ {
+ if (phase.Contains("准备安装", StringComparison.Ordinal) || phase.Contains("检查环境", StringComparison.Ordinal)) return 0;
+ if (phase.Contains("前置组件", StringComparison.Ordinal) || phase.Contains("运行库", StringComparison.Ordinal)) return 1;
+ if (phase.Contains("写入程序文件", StringComparison.Ordinal)) return 2;
+ if (phase.Contains("创建快捷方式", StringComparison.Ordinal)) return 3;
+ if (phase.Contains("完成配置", StringComparison.Ordinal) || phase.Contains("安装完成", StringComparison.Ordinal)) return 4;
+ return currentStage;
+ }
+
+ private void ScheduleDiskSpaceRefresh(bool immediate = false)
+ {
+ _diskCheckVersion++;
+ _diskCheckCts?.Cancel();
+ _diskCheckCts?.Dispose();
+ _diskCheckCts = new CancellationTokenSource();
+ _diskSpace.Text = "正在检查磁盘空间...";
+ _diskSpace.Foreground = SecondaryBrush;
+ _ = RefreshDiskSpaceAsync(_diskCheckVersion, immediate ? TimeSpan.Zero : TimeSpan.FromMilliseconds(300), _diskCheckCts.Token);
+ }
+
+ private async Task RefreshDiskSpaceAsync(int version, TimeSpan delay, CancellationToken cancellationToken)
{
try
{
- var root = Path.GetPathRoot(Path.GetFullPath(string.IsNullOrWhiteSpace(_directory.Text) ? _options.InstallDirectory : _directory.Text));
- var drive = new DriveInfo(root!);
- _diskSpace.Text = $"可用空间 {InstallerModeResolver.FormatBytes(drive.AvailableFreeSpace)} · 预计需要 {InstallerModeResolver.FormatBytes(InstallerEngine.RequiredInstallBytes)}";
- _diskSpace.Foreground = drive.AvailableFreeSpace < InstallerEngine.RequiredInstallBytes ? Brush("#C42B1C") : SecondaryBrush;
+ if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
+ var path = string.IsNullOrWhiteSpace(_directory.Text) ? _options.InstallDirectory : _directory.Text;
+ var result = await Task.Run(() => ReadDiskSpace(path), cancellationToken);
+ if (version != _diskCheckVersion || _step != 2 || cancellationToken.IsCancellationRequested) return;
+ _diskSpace.Text = $"可用空间 {InstallerModeResolver.FormatBytes(result.AvailableBytes)} · 预计需要 {InstallerModeResolver.FormatBytes(InstallerEngine.RequiredInstallBytes)}";
+ _diskSpace.Foreground = result.AvailableBytes < InstallerEngine.RequiredInstallBytes ? Brush("#C42B1C") : SecondaryBrush;
+ }
+ catch (OperationCanceledException)
+ {
}
catch
{
+ if (version != _diskCheckVersion || _step != 2) return;
_diskSpace.Text = "目录不可用,请选择有效路径。";
_diskSpace.Foreground = Brush("#C42B1C");
}
}
+ private static DiskSpaceResult ReadDiskSpace(string path)
+ {
+ var root = Path.GetPathRoot(Path.GetFullPath(path));
+ return new DiskSpaceResult(new DriveInfo(root!).AvailableFreeSpace);
+ }
+
private static StackPanel PageStack(params UIElement[] children)
{
var panel = new StackPanel { Spacing = 16 };
@@ -550,7 +988,14 @@ public sealed class InstallerWindow : Window
};
}
- private static Button CommandButton(string title, string glyph, Action action, bool primary = false)
+ private Button CommandButton(string title, string glyph, Action action, bool primary = false)
+ => CommandButton(title, glyph, () =>
+ {
+ action();
+ return Task.CompletedTask;
+ }, primary);
+
+ private Button CommandButton(string title, string glyph, Func action, bool primary = false)
{
var button = new Button
{
@@ -566,10 +1011,26 @@ public sealed class InstallerWindow : Window
Children = { Text(title, 13, FontWeights.SemiBold, primary ? WhiteBrush : TextBrush), new FontIcon { Glyph = glyph, FontSize = 13 } }
}
};
- button.Click += (_, _) => action();
+ button.Click += async (_, _) => await ExecuteCommandAsync(action);
return button;
}
+ private async Task ExecuteCommandAsync(Func action)
+ {
+ try
+ {
+ await action();
+ }
+ catch (Exception exception)
+ {
+ InstallerDiagnostics.Write(exception, "Installer command");
+ _navigationError.Title = "无法继续";
+ _navigationError.Message = LimitText(exception.Message);
+ _navigationError.IsOpen = true;
+ RefreshButtons();
+ }
+ }
+
private static void SetButtonLabel(Button button, string value)
{
if (button.Content is StackPanel panel && panel.Children.FirstOrDefault() is TextBlock text) text.Text = value;
@@ -590,6 +1051,15 @@ public sealed class InstallerWindow : Window
return block;
}
+ private static Image AppIcon(double size)
+ => new()
+ {
+ Width = size,
+ Height = size,
+ Stretch = Stretch.Uniform,
+ Source = new BitmapImage(new Uri("ms-appx:///Assets/Square44x44Logo.png"))
+ };
+
private static SolidColorBrush Brush(string hex)
{
var value = hex.TrimStart('#');
@@ -604,6 +1074,13 @@ public sealed class InstallerWindow : Window
private static string LimitText(string value)
=> string.IsNullOrWhiteSpace(value) ? "未知错误" : value.Length <= 180 ? value : value[..180];
+ private static string GetBootstrapVersion()
+ => typeof(InstallerWindow).Assembly.GetName().Version?.ToString() ?? "0.0.0";
+
+ private sealed record DiskSpaceResult(long AvailableBytes);
+
+ private sealed record InstallStageView(Grid Container, FontIcon Icon, TextBlock Label, TextBlock Status);
+
private static string ReadEmbeddedText(string resourceName, string fallback)
{
using var stream = typeof(InstallerWindow).Assembly.GetManifestResourceStream(resourceName);
diff --git a/src/YMhut.Box.InstallerBootstrap/Program.cs b/src/YMhut.Box.InstallerBootstrap/Program.cs
index 2535678..ebd77ab 100644
--- a/src/YMhut.Box.InstallerBootstrap/Program.cs
+++ b/src/YMhut.Box.InstallerBootstrap/Program.cs
@@ -9,6 +9,18 @@ public static class Program
[STAThread]
public static void Main(string[] args)
{
+ AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
+ {
+ if (eventArgs.ExceptionObject is Exception exception)
+ {
+ InstallerDiagnostics.Write(exception, "AppDomain.UnhandledException");
+ }
+ };
+ TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
+ {
+ InstallerDiagnostics.Write(eventArgs.Exception, "TaskScheduler.UnobservedTaskException");
+ eventArgs.SetObserved();
+ };
Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", AppContext.BaseDirectory);
if (args.Any(argument => argument.Equals("/SELFTEST", StringComparison.OrdinalIgnoreCase)))
{
diff --git a/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj b/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj
index b4c660b..bd26077 100644
--- a/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj
+++ b/src/YMhut.Box.InstallerBootstrap/YMhut.Box.InstallerBootstrap.csproj
@@ -12,7 +12,9 @@
true
true
true
- true
+ true
+ false
+ true
$(DefineConstants);DISABLE_XAML_GENERATED_MAIN
enable
enable
@@ -24,10 +26,16 @@
-
+
+ false
+
+
diff --git a/src/YMhut.Box.Tests/HardwareInfoServiceTests.cs b/src/YMhut.Box.Tests/HardwareInfoServiceTests.cs
new file mode 100644
index 0000000..1ac74d2
--- /dev/null
+++ b/src/YMhut.Box.Tests/HardwareInfoServiceTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using YMhut.Box.Core.System;
+
+namespace YMhut.Box.Tests;
+
+[TestClass]
+public sealed class HardwareInfoServiceTests
+{
+ [TestMethod]
+ public void SelectPrimaryGpuPrefersNvidiaDiscreteOverIntelIntegrated()
+ {
+ var selected = HardwareInfoService.SelectPrimaryGpu([
+ Gpu("Intel(R) Iris(R) Xe Graphics", "Intel", "PCI\\VEN_8086", 2),
+ Gpu("NVIDIA GeForce RTX 4070", "NVIDIA", "PCI\\VEN_10DE", 8)
+ ]);
+
+ Assert.AreEqual("NVIDIA GeForce RTX 4070", selected?.Name);
+ }
+
+ [TestMethod]
+ public void SelectPrimaryGpuPrefersAmdDiscreteOverIntegratedRadeon()
+ {
+ var selected = HardwareInfoService.SelectPrimaryGpu([
+ Gpu("AMD Radeon Graphics", "AMD", "PCI\\VEN_1002", 1),
+ Gpu("AMD Radeon RX 7800 XT", "AMD", "PCI\\VEN_1002", 16)
+ ]);
+
+ Assert.AreEqual("AMD Radeon RX 7800 XT", selected?.Name);
+ }
+
+ [TestMethod]
+ public void SelectPrimaryGpuDemotesBasicAndRemoteAdapters()
+ {
+ var selected = HardwareInfoService.SelectPrimaryGpu([
+ Gpu("Microsoft Basic Display Adapter", "Microsoft", "ROOT\\BASICDISPLAY", 0),
+ Gpu("Remote Display Adapter", "Microsoft", "ROOT\\RDPIDD", 0),
+ Gpu("Intel(R) Arc(TM) A770 Graphics", "Intel", "PCI\\VEN_8086", 16)
+ ]);
+
+ Assert.AreEqual("Intel(R) Arc(TM) A770 Graphics", selected?.Name);
+ }
+
+ private static HardwareInventoryDevice Gpu(
+ string name,
+ string manufacturer,
+ string pnpId,
+ int memoryGb)
+ {
+ return new HardwareInventoryDevice(
+ pnpId,
+ "Graphics",
+ name,
+ manufacturer,
+ name,
+ "1.0",
+ "OK",
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["AdapterCompatibility"] = manufacturer,
+ ["PNPDeviceID"] = pnpId,
+ ["AdapterRAM"] = ((ulong)memoryGb * 1024 * 1024 * 1024).ToString()
+ });
+ }
+}
diff --git a/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs b/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs
index 6d99100..c4dc77f 100644
--- a/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs
+++ b/src/YMhut.Box.Tests/RemoteMediaCatalogTests.cs
@@ -36,7 +36,7 @@ public sealed class RemoteMediaCatalogTests
[TestMethod]
public void ParsesLegacyMediaTypesSnapshot()
{
- var catalog = RemoteMediaCatalogParser.Parse(ReadRepoFile("box-old", "server", "media-types.json"));
+ var catalog = RemoteMediaCatalogParser.Parse(ReadTestData("media-types.legacy.json"));
Assert.AreEqual("1.0.8", catalog.LayoutVersion);
Assert.IsGreaterThanOrEqualTo(2, catalog.Categories.Count);
@@ -264,23 +264,6 @@ public sealed class RemoteMediaCatalogTests
}
}
- private static string ReadRepoFile(params string[] segments)
- {
- var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
- while (directory is not null)
- {
- var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
- if (File.Exists(candidate))
- {
- return File.ReadAllText(candidate);
- }
-
- directory = directory.Parent;
- }
-
- throw new DirectoryNotFoundException("Unable to locate repository sample file.");
- }
-
private static string ReadTestData(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
diff --git a/src/YMhut.Box.Tests/TestData/media-types.legacy.json b/src/YMhut.Box.Tests/TestData/media-types.legacy.json
new file mode 100644
index 0000000..5ac0044
--- /dev/null
+++ b/src/YMhut.Box.Tests/TestData/media-types.legacy.json
@@ -0,0 +1,46 @@
+{
+ "layout_version": "1.0.8",
+ "categories": [
+ {
+ "id": "image",
+ "name": "随机图片",
+ "enabled": true,
+ "layout": {
+ "columns": 1,
+ "aspect_ratio": "16:9",
+ "show_preview": true
+ },
+ "subcategories": [
+ { "id": "xjj", "api_url": "https://example.test/xjj", "supported_formats": ["jpg"] },
+ { "id": "baisi", "api_url": "https://example.test/baisi", "supported_formats": ["jpg"] },
+ { "id": "heisi", "api_url": "https://example.test/heisi", "supported_formats": ["jpg"] },
+ { "id": "acg", "api_url": "https://example.test/acg", "supported_formats": ["webp"] },
+ { "id": "miku", "api_url": "https://example.test/miku", "supported_formats": ["png"] },
+ { "id": "wallpaper", "api_url": "https://example.test/wallpaper", "supported_formats": ["jpg"] },
+ { "id": "comic", "api_url": "https://example.test/comic", "supported_formats": ["jpg"] }
+ ]
+ },
+ {
+ "id": "video",
+ "name": "随机视频",
+ "enabled": true,
+ "layout": {
+ "columns": 1,
+ "aspect_ratio": "16:9",
+ "show_preview": true,
+ "auto_play": false
+ },
+ "subcategories": [
+ {
+ "id": "radom_xjj_mv",
+ "name": "JK视频",
+ "api_url": "https://example.test/video",
+ "supported_formats": ["mp4", "webm"]
+ }
+ ]
+ }
+ ],
+ "ui_config": {
+ "default_view": "grid"
+ }
+}
diff --git a/src/YMhut.Box.Tests/ToolCatalogTests.cs b/src/YMhut.Box.Tests/ToolCatalogTests.cs
index 761013a..d7696d4 100644
--- a/src/YMhut.Box.Tests/ToolCatalogTests.cs
+++ b/src/YMhut.Box.Tests/ToolCatalogTests.cs
@@ -21,6 +21,11 @@ public sealed class ToolCatalogTests
Assert.IsNotNull(catalog.GetById("timezone_abbr_lookup"));
Assert.IsNotNull(catalog.GetById("percentage_change_calculator"));
Assert.IsNotNull(catalog.GetById("dev_environment_config"));
+ foreach (var id in new[] { "hardware", "optimization", "music" })
+ {
+ Assert.IsNotNull(catalog.GetById(id), id);
+ Assert.IsGreaterThan(0, catalog.GetById(id)!.Metadata.AddedOrder, id);
+ }
Assert.IsNull(catalog.GetById("mmd_model_studio"));
foreach (var id in new[]
{
@@ -49,6 +54,16 @@ public sealed class ToolCatalogTests
Assert.AreEqual("safe_browser", results[0].Id);
}
+ [TestMethod]
+ public void SearchFindsIntegratedToolboxSurfaces()
+ {
+ var catalog = new ToolCatalog();
+
+ Assert.IsTrue(catalog.Search("硬件").Any(module => module.Id == "hardware"));
+ Assert.IsTrue(catalog.Search("优化").Any(module => module.Id == "optimization"));
+ Assert.IsTrue(catalog.Search("音乐").Any(module => module.Id == "music"));
+ }
+
[TestMethod]
public void EveryCatalogToolHasOneStableDisplayGroup()
{
diff --git a/src/YMhut.Box.Tests/ToolboxLayoutTests.cs b/src/YMhut.Box.Tests/ToolboxLayoutTests.cs
index 307806f..c64493f 100644
--- a/src/YMhut.Box.Tests/ToolboxLayoutTests.cs
+++ b/src/YMhut.Box.Tests/ToolboxLayoutTests.cs
@@ -9,10 +9,10 @@ public sealed class ToolboxLayoutTests
[TestMethod]
[DataRow(480, 1)]
[DataRow(760, 2)]
- [DataRow(1120, 3)]
- [DataRow(1440, 4)]
- [DataRow(1720, 5)]
- [DataRow(2200, 6)]
+ [DataRow(920, 3)]
+ [DataRow(1280, 4)]
+ [DataRow(1580, 5)]
+ [DataRow(2000, 6)]
public void ToolboxLayoutUsesStableBreakpoints(double width, int expectedColumns)
{
var layout = ToolboxLayoutCalculator.Calculate(width);
@@ -25,6 +25,7 @@ public sealed class ToolboxLayoutTests
[TestMethod]
[DataRow(520, false)]
[DataRow(900, false)]
+ [DataRow(1120, false)]
[DataRow(1280, true)]
[DataRow(1680, true)]
public void ToolboxLayoutUsesResponsiveCategoryEntry(double width, bool showRail)
@@ -38,7 +39,28 @@ public sealed class ToolboxLayoutTests
var layout = ToolboxLayoutCalculator.Calculate(0);
Assert.AreEqual(3, layout.Columns);
- Assert.AreEqual(318, layout.CardWidth);
+ Assert.AreEqual(286, layout.CardWidth);
Assert.IsTrue(layout.ShowCategoryRail);
}
+
+ [TestMethod]
+ public void ToolboxSortKeepsNewToolsAheadAndUsesSecondaryGroupWithinSameLayer()
+ {
+ var normal = Module("normal", "普通", ToolCategory.Dev, addedOrder: 0);
+ var recentNew = Module("recent-new", "最近新增", ToolCategory.System, addedOrder: 100);
+ var favoriteNew = Module("favorite-new", "收藏新增", ToolCategory.Network, addedOrder: 100);
+
+ var sorted = ToolCatalog.SortForDisplay(
+ [normal, recentNew, favoriteNew],
+ module => module.Id == favoriteNew.Id ? 0 : module.Id == recentNew.Id ? 1 : 0)
+ .Select(module => module.Id)
+ .ToArray();
+
+ CollectionAssert.AreEqual(new[] { favoriteNew.Id, recentNew.Id, normal.Id }, sorted);
+ }
+
+ private static IToolModule Module(string id, string name, ToolCategory category, int addedOrder)
+ {
+ return new ToolModule(new ToolMetadata(id, name, string.Empty, category, [], true, string.Empty, addedOrder));
+ }
}
diff --git a/src/box-winUI/Controls/WeatherCapsuleControl.cs b/src/box-winUI/Controls/WeatherCapsuleControl.cs
index cec7653..8b6335e 100644
--- a/src/box-winUI/Controls/WeatherCapsuleControl.cs
+++ b/src/box-winUI/Controls/WeatherCapsuleControl.cs
@@ -122,7 +122,20 @@ public sealed class WeatherCapsuleControl : UserControl
{
var loadVersion = Interlocked.Increment(ref _loadVersion);
DispatcherQueue.TryEnqueue(() => ApplySnapshot(TitleWeatherSnapshot.Loading, loadingOverlay: true));
- var snapshot = await _weatherService.GetCurrentAsync(cancellationToken).ConfigureAwait(false);
+ TitleWeatherSnapshot snapshot;
+ try
+ {
+ snapshot = await _weatherService.GetCurrentAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception exception)
+ {
+ snapshot = TitleWeatherSnapshot.Offline(
+ AppLocalizer.SanitizeSensitiveText(exception.Message, 180));
+ }
if (loadVersion != _loadVersion)
{
return;
diff --git a/src/box-winUI/MainWindow.xaml.cs b/src/box-winUI/MainWindow.xaml.cs
index 91dbaaf..8f5c8fa 100644
--- a/src/box-winUI/MainWindow.xaml.cs
+++ b/src/box-winUI/MainWindow.xaml.cs
@@ -1200,6 +1200,13 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
private void ShowToolboxSurface(ToolboxSurfaceDefinition surface)
{
+ var module = _catalog.GetById(surface.Id);
+ if (module is not null)
+ {
+ ShowToolDetailPage(module);
+ return;
+ }
+
ClearActivePluginHost();
_activeToolboxSurfaceId = surface.Id;
_toolboxPage = null;
@@ -1228,7 +1235,6 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_toolboxPage = new ToolboxPage(
_catalog,
ShowToolDetailPage,
- ShowToolboxSurface,
_toolboxState.SearchQuery,
_toolboxState,
state => _toolboxState = state);
@@ -1248,6 +1254,11 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_toolboxState = _toolboxPage.CaptureState(module.Id);
}
+ if (TryShowToolboxNativeSurface(module))
+ {
+ return;
+ }
+
if (module is PluginToolModule pluginModule)
{
ShowPluginHostPage(pluginModule.Plugin, pluginModule.Surface);
@@ -1304,6 +1315,36 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_ = _logService.WriteAsync("Information", "tool", $"打开工具:{ToolText.Name(module)}", module.Id);
}
+ private bool TryShowToolboxNativeSurface(IToolModule module)
+ {
+ if (!ToolCatalog.IsToolboxNativeSurface(module.Id))
+ {
+ return false;
+ }
+
+ ClearActivePluginHost();
+ _activeToolboxSurfaceId = module.Id;
+ _ = _settingsService.RecordRecentToolAsync(module.Id);
+
+ Func pageFactory = module.Id.ToLowerInvariant() switch
+ {
+ "hardware" => () => new HardwarePage(),
+ "optimization" => () => new OptimizationPage(ShowToolDetailPage),
+ "music" => () => new NetworkMusicPage(),
+ _ => throw new InvalidOperationException($"Unsupported toolbox surface: {module.Id}")
+ };
+
+ SafeNavigate(
+ pageFactory,
+ ShellPage.Toolbox,
+ "tool",
+ $"Open toolbox tool: {module.Id}",
+ module.Id);
+ _shellNavigationService.NotifyExternalNavigation(module.Id);
+ _ = _logService.WriteAsync("Information", "tool", $"打开工具:{ToolText.Name(module)}", module.Id);
+ return true;
+ }
+
public void ShowDownloadManagerPage()
{
ClearActivePluginHost();
diff --git a/src/box-winUI/Services/TitleWeatherService.cs b/src/box-winUI/Services/TitleWeatherService.cs
index a6fc26d..dc90e59 100644
--- a/src/box-winUI/Services/TitleWeatherService.cs
+++ b/src/box-winUI/Services/TitleWeatherService.cs
@@ -92,6 +92,11 @@ public sealed class TitleWeatherService(
{
private const double DistrictSearchMaxDistanceKm = 180;
private const double CitySearchMaxDistanceKm = 500;
+ private static readonly TimeSpan WeatherOperationTimeout = TimeSpan.FromSeconds(12);
+ private static readonly HttpRequestPolicy WeatherRequestPolicy = new(
+ TimeSpan.FromSeconds(7),
+ MaxRetries: 0,
+ CacheMode: HttpCacheMode.UseProtocol);
private static readonly Uri IpApiLocationUri = new("https://ipapi.co/json/");
private static readonly Uri ClientLocationZhUri = BuildClientLocationUri("zh-Hans");
@@ -114,12 +119,15 @@ public sealed class TitleWeatherService(
public async Task GetCurrentAsync(CancellationToken cancellationToken = default)
{
+ using var operationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ operationCts.CancelAfter(WeatherOperationTimeout);
+ var requestToken = operationCts.Token;
try
{
- var location = await ResolveLocationAsync(cancellationToken).ConfigureAwait(false);
- var weatherPlace = await ResolveWeatherPlaceAsync(location, cancellationToken).ConfigureAwait(false);
+ var location = await ResolveLocationAsync(requestToken).ConfigureAwait(false);
+ var weatherPlace = await ResolveWeatherPlaceAsync(location, requestToken).ConfigureAwait(false);
var uri = BuildForecastUri(weatherPlace);
- var forecast = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ var forecast = await GetWeatherStringAsync(uri, requestToken).ConfigureAwait(false);
var snapshot = ParseForecast(location, weatherPlace, forecast);
await WriteLogAsync(
"Information",
@@ -128,7 +136,7 @@ public sealed class TitleWeatherService(
$"{snapshot.Location}; {snapshot.Condition}; query={weatherPlace.QueryLevel}").ConfigureAwait(false);
return snapshot;
}
- catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
+ catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false);
@@ -177,10 +185,10 @@ public sealed class TitleWeatherService(
{
try
{
- var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
return ParseClientLocation(content);
}
- catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
+ catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -190,7 +198,7 @@ public sealed class TitleWeatherService(
{
try
{
- var content = await httpService.GetStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
+ var content = await GetWeatherStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var latitude = GetDouble(root, "latitude");
@@ -212,65 +220,20 @@ public sealed class TitleWeatherService(
latitude.Value,
longitude.Value);
}
- catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
+ catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
- private async Task ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
+ private Task ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
{
- if (location.DistrictGeoNameId is long districtId &&
- await TryGetGeocodedPlaceByIdAsync(districtId, cancellationToken).ConfigureAwait(false) is { } districtPlace &&
- IsNearExpectedLocation(districtPlace, location, DistrictSearchMaxDistanceKm))
- {
- return new WeatherPlace(
- FormatDisplayLocation(location, preferDistrict: true),
- AppLocalizer.T("区/县", "District"),
- districtPlace.Latitude,
- districtPlace.Longitude);
- }
-
- if (location.CityGeoNameId is long cityId &&
- await TryGetGeocodedPlaceByIdAsync(cityId, cancellationToken).ConfigureAwait(false) is { } cityPlace &&
- IsNearExpectedLocation(cityPlace, location, CitySearchMaxDistanceKm))
- {
- return new WeatherPlace(
- FormatDisplayLocation(location, preferDistrict: false),
- AppLocalizer.T("市级", "City"),
- cityPlace.Latitude,
- cityPlace.Longitude);
- }
-
- foreach (var query in BuildNameSearchQueries(location.QueryDistrict))
- {
- if (await TrySearchGeocodedPlaceAsync(query, location, DistrictSearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
- {
- return new WeatherPlace(
- FormatDisplayLocation(location, preferDistrict: true),
- AppLocalizer.T("区/县", "District"),
- place.Latitude,
- place.Longitude);
- }
- }
-
- foreach (var query in BuildNameSearchQueries(location.QueryCity))
- {
- if (await TrySearchGeocodedPlaceAsync(query, location, CitySearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
- {
- return new WeatherPlace(
- FormatDisplayLocation(location, preferDistrict: false),
- AppLocalizer.T("市级", "City"),
- place.Latitude,
- place.Longitude);
- }
- }
-
- return new WeatherPlace(
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("经纬度", "Coordinates"),
location.Latitude,
- location.Longitude);
+ location.Longitude));
}
private async Task TryGetGeocodedPlaceByIdAsync(long id, CancellationToken cancellationToken)
@@ -278,11 +241,11 @@ public sealed class TitleWeatherService(
try
{
var uri = new Uri($"https://geocoding-api.open-meteo.com/v1/get?id={id}&language=en&format=json");
- var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null;
}
- catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
+ catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -303,7 +266,7 @@ public sealed class TitleWeatherService(
{
var uri = new Uri("https://geocoding-api.open-meteo.com/v1/search" +
$"?name={Uri.EscapeDataString(query)}&count=10&language=en&format=json");
- var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
if (!document.RootElement.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
@@ -327,12 +290,31 @@ public sealed class TitleWeatherService(
.Select(item => item.Place)
.FirstOrDefault();
}
- catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
+ catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
+ private async Task GetWeatherStringAsync(Uri uri, CancellationToken cancellationToken)
+ {
+ var response = await httpService.SendAsync(
+ uri,
+ policy: WeatherRequestPolicy,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ return response.Content;
+ }
+
+ private static bool IsRecoverableWeatherFailure(Exception exception)
+ => exception is HttpRequestException or
+ HttpRequestTimeoutException or
+ TimeoutException or
+ OperationCanceledException or
+ JsonException or
+ InvalidOperationException or
+ FormatException or
+ IOException;
+
private static Uri BuildForecastUri(WeatherPlace place)
{
var latitude = place.Latitude.ToString("0.####", CultureInfo.InvariantCulture);
diff --git a/src/box-winUI/Views/HardwarePage.cs b/src/box-winUI/Views/HardwarePage.cs
index 38ef48c..27cdb44 100644
--- a/src/box-winUI/Views/HardwarePage.cs
+++ b/src/box-winUI/Views/HardwarePage.cs
@@ -22,14 +22,13 @@ public sealed class HardwarePage : Page
private readonly MetricChartControl _gpuChart = new(AppLocalizer.T("GPU 实时趋势", "GPU live trend"), AppLocalizer.T("等待硬件传感器", "Waiting for a hardware sensor"), "\uE9D5");
private readonly StackPanel _providerRows = new() { Spacing = 0 };
private readonly StackPanel _inventoryRows = new() { Spacing = 0 };
- private readonly StackPanel _sensorRows = new() { Spacing = 0 };
+ private readonly StackPanel _sensorRows = new() { Spacing = 10 };
private readonly TextBlock _deviceTitle = ModernUi.Text("--", 21, FontWeights.SemiBold, maxLines: 1);
private readonly TextBlock _deviceSubtitle = ModernUi.Text("--", 12, foreground: ModernUi.TextSecondary, maxLines: 2);
private readonly TextBlock _captureStatus = ModernUi.Text("--", 12, foreground: ModernUi.TextSecondary, maxLines: 1);
private readonly Button _elevateButton;
private readonly Grid _charts = new() { ColumnSpacing = 12, RowSpacing = 12 };
private StackPanel? _contentStack;
- private UIElement? _sensorHeader;
private bool _compactLayout;
private bool _refreshing;
private HardwareSnapshot? _latest;
@@ -73,8 +72,8 @@ public sealed class HardwarePage : Page
_charts.Children.Add(_gpuChart);
root.Children.Add(_charts);
root.Children.Add(BuildProviderSection());
- root.Children.Add(BuildInventorySection());
root.Children.Add(BuildSensorSection());
+ root.Children.Add(BuildInventorySection());
ArrangeCharts();
return new ScrollViewer
@@ -204,10 +203,7 @@ public sealed class HardwarePage : Page
var header = SectionHeader(
AppLocalizer.T("传感器明细", "Sensor details"),
AppLocalizer.T("按硬件和指标排序,NVML 数值优先覆盖重复 GPU 指标。", "Sorted by device and metric; NVML wins duplicate GPU readings."));
- var sensorHeader = BuildSensorHeader();
- _sensorHeader = sensorHeader;
- var table = new StackPanel { Spacing = 0, Children = { sensorHeader, _sensorRows } };
- return new StackPanel { Spacing = 8, Children = { header, ModernUi.Card(table, new Thickness(0), radius: 8) } };
+ return new StackPanel { Spacing = 8, Children = { header, _sensorRows } };
}
private UIElement BuildInventorySection()
@@ -322,8 +318,8 @@ public sealed class HardwarePage : Page
heading,
ModernUi.Text(
provider.SampledAt is DateTimeOffset sampledAt
- ? $"{provider.Message} · {sampledAt.ToLocalTime():HH:mm:ss}"
- : provider.Message,
+ ? $"{ProviderMessage(provider)} · {sampledAt.ToLocalTime():HH:mm:ss}"
+ : ProviderMessage(provider),
11.5,
foreground: ModernUi.TextSecondary,
maxLines: 2)
@@ -348,9 +344,11 @@ public sealed class HardwarePage : Page
{
device.Manufacturer,
device.Model,
- string.IsNullOrWhiteSpace(device.DriverVersion) ? null : $"Driver {device.DriverVersion}"
+ string.IsNullOrWhiteSpace(device.DriverVersion)
+ ? null
+ : AppLocalizer.T($"驱动 {device.DriverVersion}", $"Driver {device.DriverVersion}")
}.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase));
- var category = ModernUi.Badge(device.Category, ModernUi.TextSecondary, ModernUi.SurfaceAlt, _compactLayout ? 72 : 92);
+ var category = ModernUi.Badge(InventoryCategoryText(device.Category), ModernUi.TextSecondary, ModernUi.SurfaceAlt, _compactLayout ? 72 : 92);
category.VerticalAlignment = VerticalAlignment.Center;
var heading = new Grid { ColumnSpacing = 8 };
heading.ColumnDefinitions.Add(new ColumnDefinition());
@@ -397,53 +395,51 @@ public sealed class HardwarePage : Page
private void ApplySensors(IReadOnlyList sensors)
{
_sensorRows.Children.Clear();
- foreach (var sensor in sensors.Take(80))
+ foreach (var group in sensors
+ .Take(80)
+ .GroupBy(sensor => sensor.Hardware, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(group => group.Key, StringComparer.CurrentCulture))
{
- if (_compactLayout)
+ var groupRows = new StackPanel { Spacing = 0 };
+ var groupHeader = new Grid
{
- var compactRow = new Grid { MinHeight = 54, Padding = new Thickness(12, 7, 12, 7), ColumnSpacing = 10 };
- compactRow.ColumnDefinitions.Add(new ColumnDefinition());
- compactRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- compactRow.Children.Add(new StackPanel
- {
- Spacing = 1,
- Children =
- {
- ModernUi.Text(sensor.Hardware, 12.5, FontWeights.SemiBold, maxLines: 1),
- ModernUi.Text($"{sensor.SensorType} · {sensor.Name}", 11.5, foreground: ModernUi.TextSecondary, maxLines: 1)
- }
- });
- var reading = new StackPanel
- {
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Center,
- Spacing = 1,
- Children =
- {
- ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1),
- ModernUi.Text(sensor.Provider, 10.5, foreground: ModernUi.TextSecondary, maxLines: 1)
- }
- };
- Grid.SetColumn(reading, 1);
- compactRow.Children.Add(reading);
- _sensorRows.Children.Add(compactRow);
- continue;
+ MinHeight = 44,
+ Padding = new Thickness(12, 8, 12, 8),
+ ColumnSpacing = 10,
+ Background = ModernUi.SurfaceAlt
+ };
+ groupHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ groupHeader.ColumnDefinitions.Add(new ColumnDefinition());
+ groupHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ groupHeader.Children.Add(ModernUi.IconTile(
+ group.Any(sensor => sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase)) ? "\uE9D5" : "\uE950",
+ 28,
+ ModernUi.AccentSoft,
+ ModernUi.Accent,
+ 13));
+ var groupName = ModernUi.Text(SensorHardwareText(group.Key), 13.5, FontWeights.SemiBold, maxLines: 1);
+ groupName.VerticalAlignment = VerticalAlignment.Center;
+ Grid.SetColumn(groupName, 1);
+ groupHeader.Children.Add(groupName);
+ var count = ModernUi.Badge(AppLocalizer.T($"{group.Count()} 项", $"{group.Count()} readings"), ModernUi.TextSecondary, ModernUi.Surface, 86);
+ count.VerticalAlignment = VerticalAlignment.Center;
+ Grid.SetColumn(count, 2);
+ groupHeader.Children.Add(count);
+ groupRows.Children.Add(groupHeader);
+
+ if (!_compactLayout)
+ {
+ groupRows.Children.Add(BuildSensorHeader());
}
- var row = SensorGrid();
- row.Children.Add(BodyText(sensor.Hardware));
- var metric = BodyText($"{sensor.SensorType} · {sensor.Name}");
- Grid.SetColumn(metric, 1);
- row.Children.Add(metric);
- var value = ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1);
- value.VerticalAlignment = VerticalAlignment.Center;
- Grid.SetColumn(value, 2);
- row.Children.Add(value);
- var source = BodyText(sensor.Provider);
- Grid.SetColumn(source, 3);
- row.Children.Add(source);
- _sensorRows.Children.Add(row);
+ foreach (var sensor in group.OrderBy(sensor => sensor.SensorType).ThenBy(sensor => sensor.Name))
+ {
+ groupRows.Children.Add(BuildSensorReadingRow(sensor));
+ }
+
+ _sensorRows.Children.Add(ModernUi.Card(groupRows, new Thickness(0), radius: 8));
}
+
if (sensors.Count == 0)
{
_sensorRows.Children.Add(new Border
@@ -454,6 +450,45 @@ public sealed class HardwarePage : Page
}
}
+ private FrameworkElement BuildSensorReadingRow(SensorReading sensor)
+ {
+ if (_compactLayout)
+ {
+ var compactRow = new Grid { MinHeight = 50, Padding = new Thickness(12, 7, 12, 7), ColumnSpacing = 10 };
+ compactRow.ColumnDefinitions.Add(new ColumnDefinition());
+ compactRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ compactRow.Children.Add(ModernUi.Text(SensorMetricText(sensor), 11.5, foreground: ModernUi.TextSecondary, maxLines: 1));
+ var reading = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 1,
+ Children =
+ {
+ ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1),
+ ModernUi.Text(sensor.Provider, 10.5, foreground: ModernUi.TextSecondary, maxLines: 1)
+ }
+ };
+ Grid.SetColumn(reading, 1);
+ compactRow.Children.Add(reading);
+ return compactRow;
+ }
+
+ var row = SensorGrid();
+ row.Children.Add(BodyText(SensorHardwareText(sensor.Hardware)));
+ var metric = BodyText(SensorMetricText(sensor));
+ Grid.SetColumn(metric, 1);
+ row.Children.Add(metric);
+ var value = ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1);
+ value.VerticalAlignment = VerticalAlignment.Center;
+ Grid.SetColumn(value, 2);
+ row.Children.Add(value);
+ var source = BodyText(sensor.Provider);
+ Grid.SetColumn(source, 3);
+ row.Children.Add(source);
+ return row;
+ }
+
private async Task EnableElevatedSensorsAsync()
{
_elevateButton.IsEnabled = false;
@@ -514,10 +549,6 @@ public sealed class HardwarePage : Page
? new Thickness(14, 16, 14, 24)
: new Thickness(28, 22, 36, 32);
}
- if (_sensorHeader is not null)
- {
- _sensorHeader.Visibility = compact ? Visibility.Collapsed : Visibility.Visible;
- }
if (_compactLayout == compact)
{
return;
@@ -553,13 +584,41 @@ public sealed class HardwarePage : Page
}
private static SensorReading? FindGpuSensor(HardwareSnapshot snapshot, string sensorType)
- => snapshot.Sensors.FirstOrDefault(sensor =>
- sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase) &&
- sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase) &&
- sensor.Name.Contains("Core", StringComparison.OrdinalIgnoreCase))
- ?? snapshot.Sensors.FirstOrDefault(sensor =>
+ {
+ return snapshot.Sensors
+ .Where(sensor =>
sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase) &&
- sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase));
+ sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(sensor => PrimaryGpuSensorScore(sensor, snapshot.Summary.GpuName))
+ .ThenByDescending(sensor => sensor.Name.Contains("Core", StringComparison.OrdinalIgnoreCase))
+ .FirstOrDefault();
+ }
+
+ private static int PrimaryGpuSensorScore(SensorReading sensor, string primaryGpuName)
+ {
+ if (string.IsNullOrWhiteSpace(primaryGpuName) || primaryGpuName.Equals("Unknown GPU", StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+
+ var hardware = sensor.Hardware.ToLowerInvariant();
+ var primary = primaryGpuName.ToLowerInvariant();
+ if (hardware.Contains(primary, StringComparison.OrdinalIgnoreCase) || primary.Contains(hardware, StringComparison.OrdinalIgnoreCase))
+ {
+ return 1000;
+ }
+
+ var score = primary
+ .Split([' ', '(', ')', '[', ']', '-', '_'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(token => token.Length >= 3)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count(token => hardware.Contains(token, StringComparison.OrdinalIgnoreCase)) * 25;
+
+ if (primary.Contains("nvidia") && hardware.Contains("nvidia")) score += 400;
+ if ((primary.Contains("amd") || primary.Contains("radeon")) && (hardware.Contains("amd") || hardware.Contains("radeon"))) score += 400;
+ if (primary.Contains("intel") && primary.Contains("arc") && hardware.Contains("intel") && hardware.Contains("arc")) score += 400;
+ return score;
+ }
private static string Percent(double? value) => value is null ? "--" : $"{value:0}%";
@@ -570,4 +629,91 @@ public sealed class HardwarePage : Page
HardwareProviderAvailability.Degraded => AppLocalizer.T("已降级", "Degraded"),
_ => AppLocalizer.T("不可用", "Unavailable")
};
+
+ private static string ProviderMessage(HardwareProviderStatus provider)
+ {
+ if (AppLocalizer.IsEnglish)
+ {
+ return provider.Message;
+ }
+
+ return provider.Id.ToLowerInvariant() switch
+ {
+ "windows" => "CPU、内存和磁盘计数器可用。",
+ "windows-inventory" when provider.Availability == HardwareProviderAvailability.Available => "已通过 SMBIOS、PnP、EDID 和 WMI 识别静态设备。",
+ "windows-inventory" => "当前无法读取静态设备身份。",
+ "libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.Available => "已读取主板及设备传感器。",
+ "libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.RequiresElevation => "部分传感器需要管理员权限。",
+ "libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.Degraded => "硬件传感器返回的数据不完整。",
+ "libre-hardware-monitor" => "硬件监控宿主未安装或无法启动。",
+ "nvidia-nvml" when provider.Availability == HardwareProviderAvailability.Available => "NVIDIA 设备遥测可用。",
+ "nvidia-nvml" when provider.Availability == HardwareProviderAvailability.Degraded => "NVIDIA 驱动遥测数据不完整。",
+ "nvidia-nvml" => "当前 NVIDIA 驱动未提供可用的 NVML 遥测。",
+ _ => AppLocalizer.SanitizeSensitiveText(provider.Message, 120)
+ };
+ }
+
+ private static string InventoryCategoryText(string category) => category switch
+ {
+ "Processor" => AppLocalizer.T("处理器", "Processor"),
+ "Graphics" => AppLocalizer.T("显卡", "Graphics"),
+ "Memory" => AppLocalizer.T("内存", "Memory"),
+ "Storage" => AppLocalizer.T("存储", "Storage"),
+ "Display" => AppLocalizer.T("显示器", "Display"),
+ "Network" => AppLocalizer.T("网络", "Network"),
+ _ => category
+ };
+
+ private static string SensorMetricText(SensorReading sensor)
+ => $"{SensorTypeText(sensor.SensorType)} · {SensorNameText(sensor.Name)}";
+
+ private static string SensorTypeText(string sensorType) => sensorType switch
+ {
+ "Load" => AppLocalizer.T("负载", "Load"),
+ "Temperature" => AppLocalizer.T("温度", "Temperature"),
+ "Clock" => AppLocalizer.T("频率", "Clock"),
+ "Frequency" => AppLocalizer.T("频率", "Frequency"),
+ "Power" => AppLocalizer.T("功耗", "Power"),
+ "Fan" => AppLocalizer.T("风扇", "Fan"),
+ "Flow" => AppLocalizer.T("流量", "Flow"),
+ "Data" => AppLocalizer.T("数据量", "Data"),
+ "SmallData" => AppLocalizer.T("数据量", "Data"),
+ "Throughput" => AppLocalizer.T("吞吐量", "Throughput"),
+ "Voltage" => AppLocalizer.T("电压", "Voltage"),
+ "Current" => AppLocalizer.T("电流", "Current"),
+ "Level" => AppLocalizer.T("水平", "Level"),
+ "Control" => AppLocalizer.T("控制", "Control"),
+ "Factor" => AppLocalizer.T("系数", "Factor"),
+ "TimeSpan" => AppLocalizer.T("时长", "Duration"),
+ "Energy" => AppLocalizer.T("能量", "Energy"),
+ "Noise" => AppLocalizer.T("噪声", "Noise"),
+ _ => sensorType
+ };
+
+ private static string SensorNameText(string name)
+ {
+ if (AppLocalizer.IsEnglish)
+ {
+ return name;
+ }
+
+ return name
+ .Replace("GPU Memory Used", "GPU 已用显存", StringComparison.OrdinalIgnoreCase)
+ .Replace("GPU Memory Total", "GPU 总显存", StringComparison.OrdinalIgnoreCase)
+ .Replace("GPU Memory", "GPU 显存", StringComparison.OrdinalIgnoreCase)
+ .Replace("GPU Core", "GPU 核心", StringComparison.OrdinalIgnoreCase)
+ .Replace("CPU Core", "CPU 核心", StringComparison.OrdinalIgnoreCase)
+ .Replace("Memory Used", "已用内存", StringComparison.OrdinalIgnoreCase)
+ .Replace("Memory Total", "总内存", StringComparison.OrdinalIgnoreCase)
+ .Replace("Total", "总计", StringComparison.OrdinalIgnoreCase)
+ .Replace("Used", "已用", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string SensorHardwareText(string hardware) => hardware switch
+ {
+ "System" => AppLocalizer.T("系统", "System"),
+ "System memory" => AppLocalizer.T("系统内存", "System memory"),
+ "System drive" => AppLocalizer.T("系统盘", "System drive"),
+ _ => hardware
+ };
}
diff --git a/src/box-winUI/Views/NetworkMusicPage.cs b/src/box-winUI/Views/NetworkMusicPage.cs
index 196d678..12700e8 100644
--- a/src/box-winUI/Views/NetworkMusicPage.cs
+++ b/src/box-winUI/Views/NetworkMusicPage.cs
@@ -25,15 +25,15 @@ public sealed class NetworkMusicPage : Page
private readonly IMusicProvider _provider = AppServices.GetRequiredService();
private readonly IMusicPlaybackService _playback = AppServices.GetRequiredService();
private readonly IDesktopOverlayService _overlayService = AppServices.GetRequiredService();
- private readonly AutoSuggestBox _search = new() { PlaceholderText = "搜索歌曲、歌手或歌单", MinWidth = 0 };
+ private readonly AutoSuggestBox _search = new() { PlaceholderText = AppLocalizer.T("搜索歌曲、歌手或歌单", "Search songs, artists, or playlists"), MinWidth = 0 };
private readonly ComboBox _searchKind = new() { Width = 112 };
private readonly ListView _results = new() { SelectionMode = ListViewSelectionMode.None, IsItemClickEnabled = true };
- private readonly TextBlock _sectionTitle = Text("发现音乐", 22, FontWeights.SemiBold);
- private readonly TextBlock _sectionMeta = Text("网易云音乐", 12, foreground: SecondaryTextBrush);
+ private readonly TextBlock _sectionTitle = Text(AppLocalizer.T("发现音乐", "Discover"), 22, FontWeights.SemiBold);
+ private readonly TextBlock _sectionMeta = Text(AppLocalizer.T("网易云音乐", "NetEase Cloud Music"), 12, foreground: SecondaryTextBrush);
private readonly StackPanel _lyrics = new() { Spacing = 12 };
private readonly ScrollViewer _lyricsScroll = new() { VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
- private readonly TextBlock _trackTitle = Text("未在播放", 14, FontWeights.SemiBold, maxLines: 1);
- private readonly TextBlock _trackArtist = Text("从搜索或歌单中选择歌曲", 11, foreground: SecondaryTextBrush, maxLines: 1);
+ private readonly TextBlock _trackTitle = Text(AppLocalizer.T("未在播放", "Nothing playing"), 14, FontWeights.SemiBold, maxLines: 1);
+ private readonly TextBlock _trackArtist = Text(AppLocalizer.T("从搜索或歌单中选择歌曲", "Choose a song from search or a playlist"), 11, foreground: SecondaryTextBrush, maxLines: 1);
private readonly Button _playPauseButton;
private readonly Button _modeButton;
private readonly Button _favoriteButton;
@@ -55,10 +55,10 @@ public sealed class NetworkMusicPage : Page
public NetworkMusicPage()
{
Background = BackgroundBrush;
- _playPauseButton = IconButton("\uE768", "播放或暂停", () => _playback.PlayPause());
- _modeButton = IconButton("\uE8EE", "顺序播放", ToggleMode);
- _favoriteButton = IconButton("\uEB51", "收藏当前歌曲", async () => await FavoriteCurrentAsync());
- _desktopLyricsButton = IconButton("\uE8D2", "桌面歌词", async () => await ToggleDesktopLyricsAsync());
+ _playPauseButton = IconButton("\uE768", AppLocalizer.T("播放或暂停", "Play or pause"), () => _playback.PlayPause());
+ _modeButton = IconButton("\uE8EE", AppLocalizer.T("顺序播放", "Play in order"), ToggleMode);
+ _favoriteButton = IconButton("\uEB51", AppLocalizer.T("收藏当前歌曲", "Favorite current song"), async () => await FavoriteCurrentAsync());
+ _desktopLyricsButton = IconButton("\uE8D2", AppLocalizer.T("桌面歌词", "Desktop lyrics"), async () => await ToggleDesktopLyricsAsync());
Content = BuildContent();
Loaded += NetworkMusicPage_Loaded;
Unloaded += NetworkMusicPage_Unloaded;
@@ -69,11 +69,18 @@ public sealed class NetworkMusicPage : Page
private UIElement BuildContent()
{
- _searchKind.Items.Add("歌曲");
- _searchKind.Items.Add("歌手");
- _searchKind.Items.Add("歌单");
+ _searchKind.Items.Add(AppLocalizer.T("歌曲", "Songs"));
+ _searchKind.Items.Add(AppLocalizer.T("歌手", "Artists"));
+ _searchKind.Items.Add(AppLocalizer.T("歌单", "Playlists"));
_searchKind.SelectedIndex = 0;
- foreach (var quality in new[] { "超清母带", "高清臻音", "无损", "极高", "标准" }) _quality.Items.Add(quality);
+ foreach (var quality in new[]
+ {
+ AppLocalizer.T("超清母带", "Hi-Res master"),
+ AppLocalizer.T("高清臻音", "Spatial audio"),
+ AppLocalizer.T("无损", "Lossless"),
+ AppLocalizer.T("极高", "Very high"),
+ AppLocalizer.T("标准", "Standard")
+ }) _quality.Items.Add(quality);
_quality.SelectedIndex = 1;
_quality.SelectionChanged += (_, _) => _playback.Quality = QualityFromIndex(_quality.SelectedIndex);
_volume.Value = _playback.Volume;
@@ -158,7 +165,7 @@ public sealed class NetworkMusicPage : Page
_search.Margin = new Thickness(18, 0, 0, 0);
grid.Children.Add(_search);
grid.Children.Add(_searchKind);
- var account = IconButton("\uE77B", "网易云账户", async () => await ShowAccountDialogAsync());
+ var account = IconButton("\uE77B", AppLocalizer.T("网易云账户", "NetEase Cloud Music account"), async () => await ShowAccountDialogAsync());
grid.Children.Add(account);
ArrangeTopBar(grid, brand, account, double.PositiveInfinity);
grid.SizeChanged += (_, args) => ArrangeTopBar(grid, brand, account, args.NewSize.Width);
@@ -210,15 +217,15 @@ public sealed class NetworkMusicPage : Page
private FrameworkElement BuildSidebar()
{
var panel = new StackPanel { Padding = new Thickness(10, 14, 10, 14), Spacing = 5 };
- panel.Children.Add(NavButton("\uE8D6", "发现音乐", async () => await LoadRecommendedAsync()));
- panel.Children.Add(NavButton("\uE121", "每日推荐", async () => await LoadDailyAsync()));
- panel.Children.Add(NavButton("\uE9D9", "官方榜单", async () => await LoadChartsAsync()));
- panel.Children.Add(NavButton("\uE8D5", "我的歌单", async () => await LoadUserPlaylistsAsync()));
+ panel.Children.Add(NavButton("\uE8D6", AppLocalizer.T("发现音乐", "Discover"), async () => await LoadRecommendedAsync()));
+ panel.Children.Add(NavButton("\uE121", AppLocalizer.T("每日推荐", "Daily recommendations"), async () => await LoadDailyAsync()));
+ panel.Children.Add(NavButton("\uE9D9", AppLocalizer.T("官方榜单", "Charts"), async () => await LoadChartsAsync()));
+ panel.Children.Add(NavButton("\uE8D5", AppLocalizer.T("我的歌单", "My playlists"), async () => await LoadUserPlaylistsAsync()));
panel.Children.Add(new Border { Height = 1, Margin = new Thickness(6, 10, 6, 10), Background = StrokeBrush });
- panel.Children.Add(NavButton("\uE8FD", "当前队列", () =>
+ panel.Children.Add(NavButton("\uE8FD", AppLocalizer.T("当前队列", "Queue"), () =>
{
- _sectionTitle.Text = "当前队列";
- _sectionMeta.Text = $"{_playback.Queue.Items.Count} 首歌曲";
+ _sectionTitle.Text = AppLocalizer.T("当前队列", "Queue");
+ _sectionMeta.Text = SongCount(_playback.Queue.Items.Count);
RenderSongs(_playback.Queue.Items);
}));
return new Border
@@ -254,7 +261,7 @@ public sealed class NetworkMusicPage : Page
private FrameworkElement BuildLyricsPanel()
{
- _lyrics.Children.Add(Text("播放歌曲后显示逐字与翻译歌词", 13, foreground: SecondaryTextBrush));
+ _lyrics.Children.Add(Text(AppLocalizer.T("播放歌曲后显示逐字与翻译歌词", "Word-timed and translated lyrics appear during playback"), 13, foreground: SecondaryTextBrush));
_lyricsScroll.Content = _lyrics;
return new Border
{
@@ -271,7 +278,7 @@ public sealed class NetworkMusicPage : Page
},
Children =
{
- Text("歌词", 17, FontWeights.SemiBold),
+ Text(AppLocalizer.T("歌词", "Lyrics"), 17, FontWeights.SemiBold),
LyricScroller()
}
}
@@ -305,9 +312,9 @@ public sealed class NetworkMusicPage : Page
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
- primaryTransport.Children.Add(IconButton("\uE892", "上一首", async () => await RunPlayerActionAsync(() => _playback.PreviousAsync())));
+ primaryTransport.Children.Add(IconButton("\uE892", AppLocalizer.T("上一首", "Previous"), async () => await RunPlayerActionAsync(() => _playback.PreviousAsync())));
primaryTransport.Children.Add(_playPauseButton);
- primaryTransport.Children.Add(IconButton("\uE893", "下一首", async () => await RunPlayerActionAsync(() => _playback.NextAsync())));
+ primaryTransport.Children.Add(IconButton("\uE893", AppLocalizer.T("下一首", "Next"), async () => await RunPlayerActionAsync(() => _playback.NextAsync())));
primaryTransport.Children.Add(_modeButton);
primaryTransport.Children.Add(_favoriteButton);
var transport = new Grid { ColumnSpacing = 8, VerticalAlignment = VerticalAlignment.Center };
@@ -425,20 +432,20 @@ public sealed class NetworkMusicPage : Page
{
var kind = _searchKind.SelectedIndex switch { 1 => MusicSearchKind.Artists, 2 => MusicSearchKind.Playlists, _ => MusicSearchKind.Songs };
var result = await _provider.SearchAsync(query, kind);
- _sectionTitle.Text = $"搜索:{query}";
+ _sectionTitle.Text = AppLocalizer.T($"搜索:{query}", $"Search: {query}");
if (kind == MusicSearchKind.Artists)
{
- _sectionMeta.Text = $"{result.Artists.Count} 位歌手";
+ _sectionMeta.Text = ArtistCount(result.Artists.Count);
RenderArtists(result.Artists);
}
else if (kind == MusicSearchKind.Playlists)
{
- _sectionMeta.Text = $"{result.Playlists.Count} 个歌单";
+ _sectionMeta.Text = PlaylistCount(result.Playlists.Count);
RenderPlaylists(result.Playlists);
}
else
{
- _sectionMeta.Text = $"{result.Songs.Count} 首歌曲";
+ _sectionMeta.Text = SongCount(result.Songs.Count);
RenderSongs(result.Songs);
}
});
@@ -449,33 +456,33 @@ public sealed class NetworkMusicPage : Page
private async Task LoadRecommendedCoreAsync()
{
var playlists = await _provider.GetRecommendedPlaylistsAsync();
- _sectionTitle.Text = "发现音乐";
- _sectionMeta.Text = "推荐歌单";
+ _sectionTitle.Text = AppLocalizer.T("发现音乐", "Discover");
+ _sectionMeta.Text = AppLocalizer.T("推荐歌单", "Recommended playlists");
RenderPlaylists(playlists);
}
private Task LoadDailyAsync() => RunBusyAsync(async () =>
{
- if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException("每日推荐需要先登录网易云音乐。");
+ if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException(AppLocalizer.T("每日推荐需要先登录网易云音乐。", "Sign in to NetEase Cloud Music to view daily recommendations."));
var songs = await _provider.GetDailySongsAsync();
- _sectionTitle.Text = "每日推荐";
- _sectionMeta.Text = $"{songs.Count} 首歌曲";
+ _sectionTitle.Text = AppLocalizer.T("每日推荐", "Daily recommendations");
+ _sectionMeta.Text = SongCount(songs.Count);
RenderSongs(songs);
});
private Task LoadChartsAsync() => RunBusyAsync(async () =>
{
var playlists = await _provider.GetChartsAsync();
- _sectionTitle.Text = "官方榜单";
- _sectionMeta.Text = "网易云音乐榜单";
+ _sectionTitle.Text = AppLocalizer.T("官方榜单", "Charts");
+ _sectionMeta.Text = AppLocalizer.T("网易云音乐榜单", "NetEase Cloud Music charts");
RenderPlaylists(playlists);
});
private Task LoadUserPlaylistsAsync() => RunBusyAsync(async () =>
{
- if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException("请先登录网易云音乐。");
+ if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException(AppLocalizer.T("请先登录网易云音乐。", "Sign in to NetEase Cloud Music first."));
var playlists = await _provider.GetUserPlaylistsAsync();
- _sectionTitle.Text = "我的歌单";
+ _sectionTitle.Text = AppLocalizer.T("我的歌单", "My playlists");
_sectionMeta.Text = _provider.LoginState.Nickname;
RenderPlaylists(playlists);
});
@@ -500,7 +507,7 @@ public sealed class NetworkMusicPage : Page
{
var songs = await _provider.GetPlaylistTracksAsync(playlist.Id, 0, 200);
_sectionTitle.Text = playlist.Name;
- _sectionMeta.Text = $"{playlist.Creator} · {playlist.TrackCount} 首";
+ _sectionMeta.Text = $"{playlist.Creator} · {SongCount(playlist.TrackCount)}";
RenderSongs(songs);
});
@@ -508,7 +515,7 @@ public sealed class NetworkMusicPage : Page
{
var songs = await _provider.GetArtistSongsAsync(artist.Id);
_sectionTitle.Text = artist.Name;
- _sectionMeta.Text = $"{songs.Count} 首热门歌曲";
+ _sectionMeta.Text = AppLocalizer.T($"{songs.Count} 首热门歌曲", $"{songs.Count} popular songs");
RenderSongs(songs);
});
@@ -566,7 +573,7 @@ public sealed class NetworkMusicPage : Page
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
grid.Children.Add(Cover(playlist.CoverUrl, 38));
AddColumn(grid, new StackPanel { Spacing = 2, Children = { Text(playlist.Name, 13, FontWeights.SemiBold, maxLines: 1), Text(playlist.Creator, 11, foreground: SecondaryTextBrush, maxLines: 1) } }, 1);
- AddColumn(grid, Text($"{playlist.TrackCount} 首", 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
+ AddColumn(grid, Text(SongCount(playlist.TrackCount), 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
return RowItem(playlist, grid);
}
@@ -578,7 +585,7 @@ public sealed class NetworkMusicPage : Page
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
grid.Children.Add(Cover(artist.PictureUrl, 38));
AddColumn(grid, Text(artist.Name, 13, FontWeights.SemiBold, maxLines: 1), 1);
- AddColumn(grid, Text($"{artist.TrackCount} 首", 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
+ AddColumn(grid, Text(SongCount(artist.TrackCount), 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
return RowItem(artist, grid);
}
@@ -588,7 +595,7 @@ public sealed class NetworkMusicPage : Page
_activeLyricIndex = -1;
if (lyrics.Lines.Count == 0)
{
- _lyrics.Children.Add(Text("当前歌曲没有可用歌词", 13, foreground: SecondaryTextBrush));
+ _lyrics.Children.Add(Text(AppLocalizer.T("当前歌曲没有可用歌词", "No lyrics are available for this song"), 13, foreground: SecondaryTextBrush));
return;
}
foreach (var line in lyrics.Lines)
@@ -603,8 +610,8 @@ public sealed class NetworkMusicPage : Page
private void RefreshPlaybackUi()
{
var song = _playback.CurrentSong;
- _trackTitle.Text = song?.Name ?? "未在播放";
- _trackArtist.Text = song is null ? "从搜索或歌单中选择歌曲" : $"{song.Artist} · {song.Album}";
+ _trackTitle.Text = song?.Name ?? AppLocalizer.T("未在播放", "Nothing playing");
+ _trackArtist.Text = song is null ? AppLocalizer.T("从搜索或歌单中选择歌曲", "Choose a song from search or a playlist") : $"{song.Artist} · {song.Album}";
if (_playPauseButton.Content is FontIcon icon) icon.Glyph = _playback.IsPlaying ? "\uE769" : "\uE768";
_syncingPosition = true;
_position.Maximum = Math.Max(1, _playback.Duration.TotalSeconds);
@@ -648,10 +655,14 @@ public sealed class NetworkMusicPage : Page
_overlayService.SetDesktopLyrics(
song?.Name ?? "YMhut Music",
song?.Artist ?? string.Empty,
- line?.Text ?? "播放歌曲后显示桌面歌词",
+ line?.Text ?? AppLocalizer.T("播放歌曲后显示桌面歌词", "Desktop lyrics appear during playback"),
line?.Translation);
await _overlayService.ToggleAsync(DesktopOverlayService.DesktopLyrics);
- ToolTipService.SetToolTip(_desktopLyricsButton, _overlayService.IsVisible(DesktopOverlayService.DesktopLyrics) ? "关闭桌面歌词" : "桌面歌词");
+ ToolTipService.SetToolTip(
+ _desktopLyricsButton,
+ _overlayService.IsVisible(DesktopOverlayService.DesktopLyrics)
+ ? AppLocalizer.T("关闭桌面歌词", "Close desktop lyrics")
+ : AppLocalizer.T("桌面歌词", "Desktop lyrics"));
}
private void ToggleMode()
@@ -664,9 +675,9 @@ public sealed class NetworkMusicPage : Page
};
var (glyph, text) = _playback.Queue.Mode switch
{
- MusicPlayMode.Shuffle => ("\uE8B1", "随机播放"),
- MusicPlayMode.RepeatOne => ("\uE8ED", "单曲循环"),
- _ => ("\uE8EE", "顺序播放")
+ MusicPlayMode.Shuffle => ("\uE8B1", AppLocalizer.T("随机播放", "Shuffle")),
+ MusicPlayMode.RepeatOne => ("\uE8ED", AppLocalizer.T("单曲循环", "Repeat one")),
+ _ => ("\uE8EE", AppLocalizer.T("顺序播放", "Play in order"))
};
if (_modeButton.Content is FontIcon icon) icon.Glyph = glyph;
ToolTipService.SetToolTip(_modeButton, text);
@@ -678,7 +689,7 @@ public sealed class NetworkMusicPage : Page
await RunBusyAsync(async () =>
{
await _provider.SetFavoriteAsync(_playback.CurrentSong.Id, true);
- ShowStatus("已收藏", "歌曲已加入我喜欢的音乐。", InfoBarSeverity.Success);
+ ShowStatus(AppLocalizer.T("已收藏", "Favorited"), AppLocalizer.T("歌曲已加入我喜欢的音乐。", "The song was added to your liked music."), InfoBarSeverity.Success);
});
}
@@ -686,35 +697,49 @@ public sealed class NetworkMusicPage : Page
{
await _provider.InitializeAsync();
var qrImage = new Image { Width = 190, Height = 190, Stretch = Stretch.Uniform };
- var qrStatus = Text("正在创建登录二维码…", 12, foreground: SecondaryTextBrush, maxLines: 2);
- var cookie = new TextBox { Header = "Cookie 登录", PlaceholderText = "粘贴包含 MUSIC_U 的 Cookie", AcceptsReturn = true, MinHeight = 72, TextWrapping = TextWrapping.Wrap };
+ var qrStatus = Text(AppLocalizer.T("正在创建登录二维码…", "Creating sign-in QR code..."), 12, foreground: SecondaryTextBrush, maxLines: 2);
+ var cookie = new TextBox
+ {
+ Header = AppLocalizer.T("Cookie 登录", "Cookie sign-in"),
+ PlaceholderText = AppLocalizer.T("粘贴包含 MUSIC_U 的 Cookie", "Paste a cookie containing MUSIC_U"),
+ AcceptsReturn = true,
+ MinHeight = 72,
+ TextWrapping = TextWrapping.Wrap
+ };
var qrCts = new CancellationTokenSource();
var content = new StackPanel { Spacing = 12 };
- var accountState = Text(_provider.LoginState.LoggedIn ? $"已登录:{_provider.LoginState.Nickname}" : "未登录", 14, FontWeights.SemiBold);
+ var accountState = Text(
+ _provider.LoginState.LoggedIn
+ ? AppLocalizer.T($"已登录:{_provider.LoginState.Nickname}", $"Signed in: {_provider.LoginState.Nickname}")
+ : AppLocalizer.T("未登录", "Not signed in"),
+ 14,
+ FontWeights.SemiBold);
content.Children.Add(accountState);
content.Children.Add(qrImage);
content.Children.Add(qrStatus);
content.Children.Add(cookie);
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
- actions.Children.Add(DarkButton("Cookie 登录", "\uE8D7", async () =>
+ actions.Children.Add(DarkButton(AppLocalizer.T("Cookie 登录", "Cookie sign-in"), "\uE8D7", async () =>
{
var state = await _provider.LoginWithCookieAsync(cookie.Text);
- accountState.Text = state.LoggedIn ? $"已登录:{state.Nickname}" : "Cookie 无效或已过期";
- if (state.LoggedIn) qrStatus.Text = "登录成功,凭据已使用 Windows DPAPI 加密保存。";
+ accountState.Text = state.LoggedIn
+ ? AppLocalizer.T($"已登录:{state.Nickname}", $"Signed in: {state.Nickname}")
+ : AppLocalizer.T("Cookie 无效或已过期", "The cookie is invalid or expired");
+ if (state.LoggedIn) qrStatus.Text = AppLocalizer.T("登录成功,凭据已使用 Windows DPAPI 加密保存。", "Signed in. Credentials are encrypted with Windows DPAPI.");
}, primary: true));
- actions.Children.Add(DarkButton("退出登录", "\uE8AC", async () =>
+ actions.Children.Add(DarkButton(AppLocalizer.T("退出登录", "Sign out"), "\uE8AC", async () =>
{
await _provider.LogoutAsync();
- accountState.Text = "未登录";
+ accountState.Text = AppLocalizer.T("未登录", "Not signed in");
}));
content.Children.Add(actions);
- content.Children.Add(Text("登录仅用于网易云音乐账户能力;不绕过会员、地区或版权限制。", 11, foreground: SecondaryTextBrush, maxLines: 3));
+ content.Children.Add(Text(AppLocalizer.T("登录仅用于网易云音乐账户能力;不绕过会员、地区或版权限制。", "Sign-in only enables account features; it does not bypass membership, regional, or copyright restrictions."), 11, foreground: SecondaryTextBrush, maxLines: 3));
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
- Title = "网易云音乐账户",
+ Title = AppLocalizer.T("网易云音乐账户", "NetEase Cloud Music account"),
Content = content,
- CloseButtonText = "关闭",
+ CloseButtonText = AppLocalizer.T("关闭", "Close"),
DefaultButton = ContentDialogButton.Close
};
_ = CreateAndPollQrAsync(dialog, qrImage, qrStatus, accountState, qrCts.Token);
@@ -729,16 +754,16 @@ public sealed class NetworkMusicPage : Page
{
var session = await _provider.CreateQrSessionAsync(cancellationToken);
image.Source = await QrBitmapAsync(session.LoginUrl);
- status.Text = "使用网易云音乐 App 扫码并确认登录";
+ status.Text = AppLocalizer.T("使用网易云音乐 App 扫码并确认登录", "Scan with the NetEase Cloud Music app and confirm sign-in");
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
var state = await _provider.CheckQrSessionAsync(session, cancellationToken);
- status.Text = state.Message;
+ status.Text = QrStatusText(state);
if (state.Completed)
{
- account.Text = $"已登录:{_provider.LoginState.Nickname}";
- status.Text = "登录成功,凭据已使用 Windows DPAPI 加密保存。";
+ account.Text = AppLocalizer.T($"已登录:{_provider.LoginState.Nickname}", $"Signed in: {_provider.LoginState.Nickname}");
+ status.Text = AppLocalizer.T("登录成功,凭据已使用 Windows DPAPI 加密保存。", "Signed in. Credentials are encrypted with Windows DPAPI.");
await Task.Delay(700, cancellationToken);
dialog.Hide();
break;
@@ -782,7 +807,7 @@ public sealed class NetworkMusicPage : Page
}
catch (Exception exception)
{
- ShowStatus("操作未完成", exception.Message, InfoBarSeverity.Error);
+ ShowStatus(AppLocalizer.T("操作未完成", "Action not completed"), exception.Message, InfoBarSeverity.Error);
}
finally
{
@@ -801,6 +826,24 @@ public sealed class NetworkMusicPage : Page
_status.IsOpen = true;
}
+ private static string SongCount(int count)
+ => AppLocalizer.T($"{count} 首歌曲", count == 1 ? "1 song" : $"{count} songs");
+
+ private static string ArtistCount(int count)
+ => AppLocalizer.T($"{count} 位歌手", count == 1 ? "1 artist" : $"{count} artists");
+
+ private static string PlaylistCount(int count)
+ => AppLocalizer.T($"{count} 个歌单", count == 1 ? "1 playlist" : $"{count} playlists");
+
+ private static string QrStatusText(MusicQrStatus state) => state.Code switch
+ {
+ 800 => AppLocalizer.T("二维码已过期", "The QR code has expired"),
+ 801 => AppLocalizer.T("等待扫码", "Waiting to be scanned"),
+ 802 => AppLocalizer.T("已扫码,等待确认", "Scanned; waiting for confirmation"),
+ 803 => AppLocalizer.T("登录成功", "Signed in"),
+ _ => AppLocalizer.IsEnglish ? state.Message : AppLocalizer.T("等待登录", "Waiting for sign-in")
+ };
+
private static Button NavButton(string glyph, string title, Func action)
=> DarkButton(title, glyph, async () => await action());
diff --git a/src/box-winUI/Views/OptimizationPage.cs b/src/box-winUI/Views/OptimizationPage.cs
index 7552115..83720a2 100644
--- a/src/box-winUI/Views/OptimizationPage.cs
+++ b/src/box-winUI/Views/OptimizationPage.cs
@@ -1,5 +1,6 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
@@ -13,7 +14,7 @@ public sealed class OptimizationPage : Page
private readonly GridView _grid = new()
{
SelectionMode = ListViewSelectionMode.None,
- IsItemClickEnabled = true,
+ IsItemClickEnabled = false,
HorizontalContentAlignment = HorizontalAlignment.Stretch
};
private readonly ComboBox _scope = new() { MinWidth = 160 };
@@ -26,10 +27,6 @@ public sealed class OptimizationPage : Page
.ToArray();
Background = ModernUi.AppBackground;
Content = BuildContent();
- _grid.ItemClick += (_, args) =>
- {
- if (args.ClickedItem is GridViewItem { Tag: NexNativeToolModule module }) _openTool(module);
- };
_scope.SelectionChanged += (_, _) => RefreshItems();
_scope.SelectedIndex = 0;
}
@@ -100,16 +97,27 @@ public sealed class OptimizationPage : Page
ModernUi.SmallBadge(RiskText(module.Definition.Risk), RiskBrush(module.Definition.Risk), ModernUi.SurfaceAlt)
}
};
- _grid.Items.Add(new GridViewItem
+ var button = new Button
{
- Width = 224,
- Height = 118,
- Content = ModernUi.Card(content, new Thickness(13), radius: 8),
- Tag = module,
- Padding = new Thickness(4),
+ Width = 228,
+ Height = 112,
+ Content = content,
+ Padding = new Thickness(13),
+ CornerRadius = new CornerRadius(8),
+ Background = ModernUi.Surface,
+ BorderBrush = ModernUi.Stroke,
+ BorderThickness = new Thickness(1),
HorizontalContentAlignment = HorizontalAlignment.Stretch,
- VerticalContentAlignment = VerticalAlignment.Stretch
- });
+ VerticalContentAlignment = VerticalAlignment.Stretch,
+ UseSystemFocusVisuals = true
+ };
+ ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong);
+ button.Click += (_, _) => _openTool(module);
+ var name = module.Definition.CurrentName(IsEnglish);
+ ToolTipService.SetToolTip(button, name);
+ AutomationProperties.SetName(button, name);
+ AutomationProperties.SetHelpText(button, module.Definition.CurrentDescription(IsEnglish));
+ _grid.Items.Add(button);
}
}
diff --git a/src/box-winUI/Views/SettingsPage.cs b/src/box-winUI/Views/SettingsPage.cs
index 4f2f07b..8d05df1 100644
--- a/src/box-winUI/Views/SettingsPage.cs
+++ b/src/box-winUI/Views/SettingsPage.cs
@@ -623,7 +623,7 @@ public sealed class SettingsPage : Page
: FormatBytes(metrics.WorkingSetBytes);
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
var uptimeText = uptime.TotalDays >= 1
- ? $"{(int)uptime.TotalDays}d {uptime:hh\\:mm}"
+ ? AppLocalizer.T($"{(int)uptime.TotalDays} 天 {uptime:hh\\:mm}", $"{(int)uptime.TotalDays}d {uptime:hh\\:mm}")
: uptime.ToString("hh\\:mm\\:ss");
_controlUptimeStatus.Text = uptimeText;
_uptimeStatus.Text = uptimeText;
@@ -711,7 +711,7 @@ public sealed class SettingsPage : Page
}
return duration.TotalDays >= 1
- ? $"{(int)duration.TotalDays}d {duration:hh\\:mm}"
+ ? AppLocalizer.T($"{(int)duration.TotalDays} 天 {duration:hh\\:mm}", $"{(int)duration.TotalDays}d {duration:hh\\:mm}")
: duration.ToString("hh\\:mm\\:ss");
}
@@ -2373,13 +2373,13 @@ public sealed class SettingsPage : Page
{
var targets = new[]
{
- ("国内 · 央视网", new Uri("https://www.cctv.com/")),
- ("国内 · 中国政府网", new Uri("https://www.gov.cn/")),
- ("国内 · 百度", new Uri("https://www.baidu.com/")),
- ("国际 · Microsoft", new Uri("https://www.microsoft.com/")),
- ("国际 · Cloudflare", new Uri("https://www.cloudflare.com/cdn-cgi/trace")),
- ("国际 · GitHub", new Uri("https://github.com/")),
- ("更新 · YMhut", new Uri("https://update.ymhut.cn/update-info.json"))
+ (AppLocalizer.T("国内 · 央视网", "China · CCTV"), new Uri("https://www.cctv.com/")),
+ (AppLocalizer.T("国内 · 中国政府网", "China · State Council"), new Uri("https://www.gov.cn/")),
+ (AppLocalizer.T("国内 · 百度", "China · Baidu"), new Uri("https://www.baidu.com/")),
+ (AppLocalizer.T("国际 · Microsoft", "Global · Microsoft"), new Uri("https://www.microsoft.com/")),
+ (AppLocalizer.T("国际 · Cloudflare", "Global · Cloudflare"), new Uri("https://www.cloudflare.com/cdn-cgi/trace")),
+ (AppLocalizer.T("国际 · GitHub", "Global · GitHub"), new Uri("https://github.com/")),
+ (AppLocalizer.T("更新 · YMhut", "Updates · YMhut"), new Uri("https://update.ymhut.cn/update-info.json"))
};
var resultPanel = new StackPanel { Spacing = 10 };
@@ -2630,7 +2630,11 @@ public sealed class SettingsPage : Page
await _settingsService.UpdateAsync(settings => settings.PluginsEnabled = _pluginSwitch.IsOn);
}
_settings.PluginsEnabled = _pluginSwitch.IsOn;
- ToastService.Show(_pluginSwitch.IsOn ? "插件系统已开启" : "插件系统已关闭", ToastKind.Success);
+ ToastService.Show(
+ _pluginSwitch.IsOn
+ ? AppLocalizer.T("插件系统已开启", "Plugin system enabled")
+ : AppLocalizer.T("插件系统已关闭", "Plugin system disabled"),
+ ToastKind.Success);
}
private async void ToolboxCompactSwitch_Toggled(object sender, RoutedEventArgs e)
@@ -2763,7 +2767,7 @@ public sealed class SettingsPage : Page
_settings.PluginRootPath = folder.Path;
_pluginRootSummary.Text = CurrentPluginRoot();
Directory.CreateDirectory(CurrentPluginRoot());
- ToastService.Show("插件根目录已更新", ToastKind.Success);
+ ToastService.Show(AppLocalizer.T("插件根目录已更新", "Plugin root updated"), ToastKind.Success);
}
private async Task ResetPluginRootAsync()
@@ -2779,7 +2783,7 @@ public sealed class SettingsPage : Page
_settings.PluginRootPath = string.Empty;
Directory.CreateDirectory(CurrentPluginRoot());
_pluginRootSummary.Text = CurrentPluginRoot();
- ToastService.Show("插件根目录已恢复默认", ToastKind.Success);
+ ToastService.Show(AppLocalizer.T("插件根目录已恢复默认", "Plugin root reset to default"), ToastKind.Success);
}
private async Task OpenPluginRootAsync()
diff --git a/src/box-winUI/Views/ToolboxPage.cs b/src/box-winUI/Views/ToolboxPage.cs
index 78fa7f4..20cc07c 100644
--- a/src/box-winUI/Views/ToolboxPage.cs
+++ b/src/box-winUI/Views/ToolboxPage.cs
@@ -17,7 +17,6 @@ public sealed class ToolboxPage : Page
private readonly ToolCatalog _catalog;
private readonly Action _openTool;
- private readonly Action _openSurface;
private readonly Action? _stateChanged;
private readonly ISettingsService _settingsService = AppServices.GetRequiredService();
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService();
@@ -79,14 +78,12 @@ public sealed class ToolboxPage : Page
internal ToolboxPage(
ToolCatalog catalog,
Action openTool,
- Action openSurface,
string query = "",
ToolboxNavigationState? state = null,
Action? stateChanged = null)
{
_catalog = catalog.Modules.Count == 0 ? new ToolCatalog() : catalog;
_openTool = openTool;
- _openSurface = openSurface;
_stateChanged = stateChanged;
_selectedGroup = state?.SelectedGroup;
_query = !string.IsNullOrWhiteSpace(query) ? query : state?.SearchQuery ?? string.Empty;
@@ -286,7 +283,6 @@ public sealed class ToolboxPage : Page
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition());
root.SizeChanged += (_, e) =>
{
@@ -298,18 +294,14 @@ public sealed class ToolboxPage : Page
root.Children.Add(BuildHeader());
- var surfacePanel = BuildFeaturedSurfacePanel();
- Grid.SetRow(surfacePanel, 1);
- root.Children.Add(surfacePanel);
-
var scopeBar = BuildScopeBar();
- Grid.SetRow(scopeBar, 2);
+ Grid.SetRow(scopeBar, 1);
root.Children.Add(scopeBar);
var body = new Grid { ColumnSpacing = 16 };
body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
body.ColumnDefinitions.Add(new ColumnDefinition());
- Grid.SetRow(body, 3);
+ Grid.SetRow(body, 2);
root.Children.Add(body);
var categories = BuildCategoryPane();
@@ -341,156 +333,6 @@ public sealed class ToolboxPage : Page
return root;
}
- private FrameworkElement BuildFeaturedSurfacePanel()
- {
- var cardGrid = new Grid { ColumnSpacing = 10, RowSpacing = 10 };
- var cards = ToolboxSurfaceCatalog.All.Select(surface => BuildSurfaceCard(surface)).ToArray();
- foreach (var card in cards)
- {
- cardGrid.Children.Add(card);
- }
-
- var compactCardRow = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Spacing = 8
- };
- foreach (var surface in ToolboxSurfaceCatalog.All)
- {
- compactCardRow.Children.Add(BuildSurfaceCard(surface, compact: true));
- }
-
- var compactScroller = new ScrollViewer
- {
- Content = compactCardRow,
- HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
- HorizontalScrollMode = ScrollMode.Enabled,
- VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
- VerticalScrollMode = ScrollMode.Disabled,
- Visibility = Visibility.Collapsed
- };
-
- var cardHost = new Grid();
- cardHost.Children.Add(cardGrid);
- cardHost.Children.Add(compactScroller);
-
- void Arrange(double width)
- {
- var compact = width < 660;
- compactScroller.Visibility = compact ? Visibility.Visible : Visibility.Collapsed;
- cardGrid.Visibility = compact ? Visibility.Collapsed : Visibility.Visible;
- if (compact)
- {
- return;
- }
-
- var columns = width < 1040 ? 2 : 3;
- cardGrid.ColumnDefinitions.Clear();
- cardGrid.RowDefinitions.Clear();
- for (var column = 0; column < columns; column++)
- {
- cardGrid.ColumnDefinitions.Add(new ColumnDefinition());
- }
-
- var rows = (int)Math.Ceiling(cards.Length / (double)columns);
- for (var row = 0; row < rows; row++)
- {
- cardGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- }
-
- for (var index = 0; index < cards.Length; index++)
- {
- Grid.SetColumn(cards[index], index % columns);
- Grid.SetRow(cards[index], index / columns);
- }
- }
-
- cardHost.SizeChanged += (_, args) => Arrange(args.NewSize.Width);
- Arrange(1200);
-
- return new StackPanel
- {
- Spacing = 9,
- Children =
- {
- new Grid
- {
- Children =
- {
- ModernUi.Text(AppLocalizer.T("系统中心", "System centers"), 16, FontWeights.SemiBold),
- new TextBlock
- {
- Text = AppLocalizer.T("独立界面", "Dedicated interfaces"),
- FontSize = 12,
- Foreground = ModernUi.TextSecondary,
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Center
- }
- }
- },
- cardHost
- }
- };
- }
-
- private Button BuildSurfaceCard(ToolboxSurfaceDefinition surface, bool compact = false)
- {
- var text = new StackPanel
- {
- Spacing = compact ? 2 : 4,
- VerticalAlignment = VerticalAlignment.Center
- };
- text.Children.Add(ModernUi.Text(surface.CurrentTitle, compact ? 14 : 16, FontWeights.SemiBold, maxLines: 1));
- if (!compact)
- {
- text.Children.Add(ModernUi.Text(surface.CurrentDescription, 12.5, foreground: ModernUi.TextSecondary, maxLines: 2));
- }
- text.Children.Add(ModernUi.Text(surface.Capability, compact ? 11 : 11.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1));
-
- var content = new Grid { ColumnSpacing = compact ? 9 : 12 };
- content.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- content.ColumnDefinitions.Add(new ColumnDefinition());
- content.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- content.Children.Add(ModernUi.IconTile(
- surface.IconGlyph,
- compact ? 38 : 46,
- ModernUi.AccentSoft,
- ModernUi.Accent,
- compact ? 17 : 20));
- Grid.SetColumn(text, 1);
- content.Children.Add(text);
- var chevron = new FontIcon
- {
- Glyph = "\uE76C",
- FontSize = 13,
- Foreground = ModernUi.TextSecondary,
- VerticalAlignment = VerticalAlignment.Center
- };
- Grid.SetColumn(chevron, 2);
- content.Children.Add(chevron);
-
- var button = new Button
- {
- Width = compact ? 248 : double.NaN,
- Height = compact ? 80 : double.NaN,
- MinHeight = compact ? 80 : 112,
- Padding = compact ? new Thickness(12) : new Thickness(14),
- CornerRadius = new CornerRadius(8),
- Background = ModernUi.Surface,
- BorderBrush = ModernUi.Stroke,
- BorderThickness = new Thickness(1),
- HorizontalAlignment = HorizontalAlignment.Stretch,
- HorizontalContentAlignment = HorizontalAlignment.Stretch,
- Content = content,
- UseSystemFocusVisuals = true
- };
- ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong);
- button.Click += (_, _) => _openSurface(surface);
- ToolTipService.SetToolTip(button, surface.CurrentTitle);
- AutomationProperties.SetName(button, surface.CurrentTitle);
- return button;
- }
-
private FrameworkElement BuildHeader()
{
var title = new StackPanel
@@ -1023,7 +865,7 @@ public sealed class ToolboxPage : Page
yield return (AppLocalizer.T("标签", "Tags"), string.Join(", ", external.Tool.Tags));
break;
case BuiltinReferenceToolModule builtin:
- yield return (AppLocalizer.T("内置类型", "Built-in kind"), builtin.Definition.Kind.ToString());
+ yield return (AppLocalizer.T("内置类型", "Built-in kind"), BuiltinKindText(builtin.Definition.Kind));
yield return (AppLocalizer.T("风险等级", "Risk level"), RiskLabel(builtin.Definition.RiskLevel));
yield return (AppLocalizer.T("主操作", "Primary action"), builtin.Definition.PrimaryAction);
yield return (AppLocalizer.T("关键词", "Keywords"), string.Join(", ", builtin.Definition.Keywords));
@@ -1187,14 +1029,13 @@ public sealed class ToolboxPage : Page
{
try
{
- var modules = _catalog.Modules
+ var filteredModules = _catalog.Modules
.Where(module => _selectedGroup is null ||
ToolPresentationCatalog.For(module.Metadata.Category).Group == _selectedGroup)
.Where(MatchesScope)
.Where(MatchesFilters)
- .Where(module => ToolText.Matches(module, _query))
- .OrderBy(SortGroup)
- .ThenBy(module => ToolText.Name(module), StringComparer.CurrentCulture)
+ .Where(module => ToolText.Matches(module, _query));
+ var modules = ToolCatalog.SortForDisplay(filteredModules, SortGroup)
.ToArray();
var layout = ToolboxLayoutCalculator.Calculate(_toolGridView.ActualWidth);
@@ -1267,13 +1108,13 @@ public sealed class ToolboxPage : Page
string.Equals(_settingsService.Current.ToolDisplayMode, "list", StringComparison.OrdinalIgnoreCase);
var buttonWidth = Math.Max(ToolboxLayoutCalculator.MinCardWidth, _toolCardWidth);
var contentWidth = Math.Max(232, buttonWidth - 26);
- var cardHeight = compact ? 158 : 198;
+ var cardHeight = compact ? 146 : 178;
var grid = new Grid
{
Width = contentWidth,
Height = cardHeight - 28,
- Padding = new Thickness(14),
- RowSpacing = compact ? 7 : 9
+ Padding = new Thickness(12),
+ RowSpacing = compact ? 6 : 8
};
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition());
@@ -1656,6 +1497,19 @@ public sealed class ToolboxPage : Page
};
}
+ private static string BuiltinKindText(BuiltinReferenceToolKind kind)
+ {
+ return kind switch
+ {
+ BuiltinReferenceToolKind.Information => AppLocalizer.T("信息页", "Information"),
+ BuiltinReferenceToolKind.Dialog => AppLocalizer.T("对话框", "Dialog"),
+ BuiltinReferenceToolKind.Command => AppLocalizer.T("命令操作", "Command"),
+ BuiltinReferenceToolKind.SystemEntry => AppLocalizer.T("系统入口", "System entry"),
+ BuiltinReferenceToolKind.ExternalHelper => AppLocalizer.T("外部辅助程序", "External helper"),
+ _ => kind.ToString()
+ };
+ }
+
private static string ExperienceText(ToolPageExperienceKind experience)
{
return experience switch
diff --git a/src/box-winUI/Views/ToolboxSurface.cs b/src/box-winUI/Views/ToolboxSurface.cs
index 4f65bc8..2f0bbd1 100644
--- a/src/box-winUI/Views/ToolboxSurface.cs
+++ b/src/box-winUI/Views/ToolboxSurface.cs
@@ -21,12 +21,15 @@ internal sealed record ToolboxSurfaceDefinition(
string EnglishDescription,
string IconGlyph,
string Capability,
+ string EnglishCapability,
ToolboxSurfaceKind Kind,
IReadOnlyList Keywords)
{
internal string CurrentTitle => AppLocalizer.T(Title, EnglishTitle);
internal string CurrentDescription => AppLocalizer.T(Description, EnglishDescription);
+
+ internal string CurrentCapability => AppLocalizer.T(Capability, EnglishCapability);
}
internal static class ToolboxSurfaceCatalog
@@ -40,6 +43,7 @@ internal static class ToolboxSurfaceCatalog
"实时传感器、静态硬件清单、提供方状态与报告导出。",
"Live sensors, hardware inventory, provider status, and report export.",
"\uE950",
+ "实时遥测",
"Live telemetry",
ToolboxSurfaceKind.Hardware,
["hardware", "sensor", "monitor", "system status", "硬件", "传感器", "系统状态"]),
@@ -50,6 +54,7 @@ internal static class ToolboxSurfaceCatalog
"预览系统变更、按需提权、执行日志和可用回滚。",
"Preview system changes, elevate on demand, review logs, and roll back.",
"\uE9D9",
+ "预览与回滚",
"Plan and rollback",
ToolboxSurfaceKind.Optimization,
["optimization", "optimize", "memory", "power", "network", "优化", "内存", "电源"]),
@@ -60,6 +65,7 @@ internal static class ToolboxSurfaceCatalog
"搜索、歌单、播放队列、歌词与系统媒体控制。",
"Search, playlists, playback queue, lyrics, and system media controls.",
"\uE8D6",
+ "在线媒体",
"Online media",
ToolboxSurfaceKind.Music,
["music", "netease", "player", "playlist", "音乐", "网易云", "播放器"])
@@ -132,7 +138,7 @@ internal sealed class ToolboxSurfaceHostPage : Page
header.Children.Add(backButton);
Grid.SetColumn(title, 1);
header.Children.Add(title);
- var capability = ModernUi.Badge(definition.Capability, ModernUi.Accent, ModernUi.AccentSoft);
+ var capability = ModernUi.Badge(definition.CurrentCapability, ModernUi.Accent, ModernUi.AccentSoft);
capability.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(capability, 2);
header.Children.Add(capability);
diff --git a/src/box-winUI/Views/Tools/ToolPageRegistry.cs b/src/box-winUI/Views/Tools/ToolPageRegistry.cs
index a14ba8f..0b2089b 100644
--- a/src/box-winUI/Views/Tools/ToolPageRegistry.cs
+++ b/src/box-winUI/Views/Tools/ToolPageRegistry.cs
@@ -90,6 +90,7 @@ public sealed partial class ToolPageRegistry : IToolPageFactory
private static bool RequiresNativeRegistration(IToolModule module)
{
return module is ToolModule &&
+ !ToolCatalog.IsToolboxNativeSurface(module.Id) &&
!PluginIds.IsPluginToolId(module.Id) &&
!ExternalToolModule.IsExternalToolId(module.Id) &&
!BuiltinReferenceToolModule.IsBuiltinToolId(module.Id);
diff --git a/version.json b/version.json
index df678db..0308051 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
"version": "2.0.7",
- "build": "10",
+ "build": "12",
"channel": "stable"
}