diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss index e2e3903..2356ce2 100644 --- a/installer/ymhut_box_winui.iss +++ b/installer/ymhut_box_winui.iss @@ -160,7 +160,8 @@ Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:Startup Name: "launchapp"; Description: "{cm:LaunchApp}"; GroupDescription: "{cm:StartupGroup}"; Flags: checkedonce [Files] -Source: "{#PayloadDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile +Source: "{#PayloadDir}\*"; DestDir: "{app}"; Excludes: "*.pri"; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile +Source: "{#PayloadDir}\*.pri"; DestDir: "{app}"; Attribs: hidden; Flags: ignoreversion recursesubdirs createallsubdirs; BeforeInstall: LogCurrentExtractFile #if Int(BundleVCRedist) == 1 Source: "{#PayloadDir}\prereqs\vc_redist.x64.exe"; Flags: dontcopy #endif @@ -259,6 +260,7 @@ var InstallOutputLineCount: Integer; InstallOutputPendingLineCount: Integer; InstallOutputBuffer: string; + LastExtractFileName: string; function SendMessage(hWnd: Longint; Msg: Longint; wParam: Longint; lParam: Longint): Longint; external 'SendMessageW@user32.dll stdcall'; @@ -493,7 +495,10 @@ var begin FileName := ExpandConstant(CurrentFileName); if FileName <> '' then + begin + LastExtractFileName := FileName; QueueInstallOutput(CustomMessage('InstallOutputFile') + ' ' + FileName, False, True); + end; end; function SelectedTaskText(const TaskName, LabelText: string): string; @@ -784,7 +789,8 @@ begin RaiseException(FormatCustomMessage2('DependencyDownloadFailed', FriendlyName, 'downloaded file is missing')); LogBootstrapEvent('DEPENDENCY', 'install|' + FriendlyName); - if not Exec(ExecutablePath, Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + LogBootstrapEvent('DEPENDENCY', 'waiting|' + FriendlyName); + if not Exec(ExecutablePath, Arguments, '', SW_SHOW, ewWaitUntilTerminated, ResultCode) then RaiseException(FormatCustomMessage('DependencyStartFailed', FriendlyName)); if (ResultCode <> 0) and (ResultCode <> 1638) and (ResultCode <> 3010) and (ResultCode <> 1641) then @@ -975,7 +981,7 @@ begin begin AcquireAndInstallPrerequisite( '{#WebView2BootstrapperUrl}', 'MicrosoftEdgeWebView2Setup.exe', - 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/silent /install', + 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', '/install', GetCustomMessageValue('WebView2RuntimeName'), BundledWebView2Included = 1, NeedsRestart); if not WaitForWebView2Installed(60) then @@ -991,7 +997,7 @@ begin begin AcquireAndInstallPrerequisite( '{#VCRedistUrl}', 'vc_redist.x64.exe', 'vc_redist.x64.exe', - '/install /quiet /norestart', GetCustomMessageValue('VCRuntimeName'), + '/install /norestart', GetCustomMessageValue('VCRuntimeName'), BundledVCRedistIncluded = 1, NeedsRestart); if not WaitForVCRedistInstalled(10) then @@ -1016,6 +1022,7 @@ begin if CurStep = ssInstall then begin LastInstallProgressPercent := -1; + LastExtractFileName := ''; LogBootstrapEvent('STAGE', 'files'); AppendInstallOutput(CustomMessage('InstallOutputClean')); CleanLegacyInstallLayout(); @@ -1044,7 +1051,7 @@ begin if LastInstallProgressPercent <> 100 then begin LastInstallProgressPercent := 100; - LogBootstrapEvent('PROGRESS', '100|' + ExpandConstant(CurrentFileName)); + LogBootstrapEvent('PROGRESS', '100|' + LastExtractFileName); FlushInstallOutput(); end; Exit; @@ -1056,7 +1063,7 @@ begin if ProgressPercent <> LastInstallProgressPercent then begin LastInstallProgressPercent := ProgressPercent; - LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + ExpandConstant(CurrentFileName)); + LogBootstrapEvent('PROGRESS', IntToStr(ProgressPercent) + '|' + LastExtractFileName); FlushInstallOutput(); end; end; diff --git a/server/unified-management/internal/auth/auth.go b/server/unified-management/internal/auth/auth.go index 81c3d32..ed353b8 100644 --- a/server/unified-management/internal/auth/auth.go +++ b/server/unified-management/internal/auth/auth.go @@ -59,6 +59,15 @@ type Captcha struct { Image string `json:"image"` } +type LoginFailure string + +const ( + LoginFailureNone LoginFailure = "" + LoginFailureLocked LoginFailure = "locked" + LoginFailureCaptcha LoginFailure = "captcha" + LoginFailureCredentials LoginFailure = "credentials" +) + func NewService(store *db.Store) *Service { return &Service{ store: store, @@ -103,21 +112,26 @@ func (s *Service) NewCaptcha() (Captcha, error) { } func (s *Service) Login(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, bool, error) { + sessionID, csrf, failure, err := s.LoginDetailed(ctx, username, password, captchaID, captcha, clientKeys...) + return sessionID, csrf, err == nil && failure == LoginFailureNone, err +} + +func (s *Service) LoginDetailed(ctx context.Context, username, password, captchaID, captcha string, clientKeys ...string) (string, string, LoginFailure, error) { attemptKey := loginAttemptKey(username, clientKeys...) if s.loginLocked(attemptKey) { - return "", "", false, nil + return "", "", LoginFailureLocked, nil } if !s.consumeCaptcha(captchaID, captcha) { s.recordLoginFailure(attemptKey) - return "", "", false, nil + return "", "", LoginFailureCaptcha, nil } user, ok, err := s.store.VerifyAdminPassword(ctx, username, password) if err != nil { - return "", "", false, err + return "", "", LoginFailureNone, err } if !ok { s.recordLoginFailure(attemptKey) - return "", "", false, nil + return "", "", LoginFailureCredentials, nil } sessionID := randomToken(32) csrf := randomToken(32) @@ -126,7 +140,7 @@ func (s *Service) Login(ctx context.Context, username, password, captchaID, capt s.sessions[sessionID] = sessionEntry{username: user.Username, csrf: csrf, expiresAt: time.Now().Add(sessionTTL)} delete(s.loginAttempts, attemptKey) s.mu.Unlock() - return sessionID, csrf, true, nil + return sessionID, csrf, LoginFailureNone, nil } func (s *Service) Logout(w http.ResponseWriter, r *http.Request) { diff --git a/server/unified-management/internal/auth/auth_test.go b/server/unified-management/internal/auth/auth_test.go index 8386d68..6e0b9e0 100644 --- a/server/unified-management/internal/auth/auth_test.go +++ b/server/unified-management/internal/auth/auth_test.go @@ -120,8 +120,29 @@ func TestLoginLocksAfterRepeatedFailures(t *testing.T) { service.mu.Lock() answer := service.captchas[captcha.ID].answer service.mu.Unlock() - if _, _, ok, err := service.Login(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || ok { - t.Fatalf("locked login should fail without error, ok=%v err=%v", ok, err) + if _, _, failure, err := service.LoginDetailed(context.Background(), "admin", "admin", captcha.ID, answer, "127.0.0.1"); err != nil || failure != LoginFailureLocked { + t.Fatalf("locked login returned failure=%q err=%v", failure, err) + } +} + +func TestLoginDetailedDistinguishesCaptchaFailure(t *testing.T) { + root := t.TempDir() + store, err := db.Open(&config.Config{ + StorageDir: root, + Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "captcha.sqlite"), 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) + _, _, failure, err := service.LoginDetailed(context.Background(), "admin", "admin", "missing", "00000", "127.0.0.1") + if err != nil || failure != LoginFailureCaptcha { + t.Fatalf("captcha login returned failure=%q err=%v", failure, err) } } diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 92a6a30..2bbe1fa 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -313,7 +313,7 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF return Package{}, ErrUploadedPackageEmpty } 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) diff --git a/server/unified-management/internal/web/admin_release_routes.go b/server/unified-management/internal/web/admin_release_routes.go index 10d1147..6f73377 100644 --- a/server/unified-management/internal/web/admin_release_routes.go +++ b/server/unified-management/internal/web/admin_release_routes.go @@ -1,6 +1,7 @@ package web import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -65,6 +66,9 @@ func newReleaseUploadError(status int, code string, err error) error { func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) { limit := r.releaseUploadLimit() + if req.ContentLength > limit+(8<<20) { + return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("release package or form data exceeds upload limit")) + } req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20)) reader, err := req.MultipartReader() if err != nil { @@ -146,11 +150,18 @@ func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (rel }() hash := sha256.New() limited := &io.LimitedReader{R: part, N: limit + 1} - written, err := io.Copy(tmp, io.TeeReader(limited, hash)) + written, err := io.Copy(io.MultiWriter(tmp, hash), limited) + if err == nil { + err = tmp.Sync() + } if closeErr := tmp.Close(); err == nil { err = closeErr } if err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err) + } return releases.UploadedPackageFile{}, classifyMultipartReadError(err) } if written > limit { @@ -179,6 +190,9 @@ func classifyMultipartReadError(err error) error { if errors.As(err, &maxErr) { return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限")) } + if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, context.Canceled) { + return newReleaseUploadError(http.StatusBadRequest, "UPLOAD_INTERRUPTED", errors.New("upload was interrupted before the package was complete")) + } return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err) } diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index a3f8668..0b97872 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -79,6 +79,9 @@ func localizedErrorMessage(code, message string) string { byCode := map[string]string{ "UNAUTHORIZED": "需要登录后继续操作", "LOGIN_FAILED": "登录失败,请检查密码和验证码", + "LOGIN_LOCKED": "登录失败次数过多,请 5 分钟后重试", + "CAPTCHA_INVALID": "验证码错误或已过期,请输入新的验证码", + "CREDENTIALS_INVALID": "账号或密码不正确", "LOGIN_TIMEOUT": "登录校验超时,请稍后重试", "PASSWORD_CHANGE_FAILED": "密码修改失败", "INVALID_PAYLOAD": "提交内容格式不正确", @@ -93,6 +96,11 @@ func localizedErrorMessage(code, message string) string { "NOTICE_VALIDATE_FAILED": "版本日志校验失败", "NOTICE_RESTORE_FAILED": "版本日志恢复失败", "PACKAGE_UPLOAD_FAILED": "发布包上传失败", + "PACKAGE_EMPTY": "发布包不能为空", + "PACKAGE_TOO_LARGE": "发布包超过服务端上传上限", + "UPLOAD_STORAGE_FAILED": "服务端无法保存上传文件", + "MANIFEST_UPDATE_FAILED": "发布包已回滚,更新清单写入失败", + "UPLOAD_INTERRUPTED": "上传连接已中断,请重新上传", "SOURCE_SAVE_FAILED": "接口源保存失败", "CHECK_FAILED": "接口健康检测失败", "SYNC_FAILED": "同步操作失败", diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index 45fd8d6..1dec8ab 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "strings" "time" @@ -32,7 +33,7 @@ type router struct { publicSnapshots *publicSnapshotService } -const loginRequestTimeout = 5 * time.Second +const loginRequestTimeout = 8 * 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{ @@ -189,6 +190,11 @@ func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) { } func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) + return + } + w.Header().Set("Cache-Control", "no-store") captcha, err := r.auth.NewCaptcha() if err != nil { writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err) @@ -219,7 +225,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) { } 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) + clientAddress := remoteHost(req.RemoteAddr) + sessionID, csrf, failure, err := r.auth.LoginDetailed(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, clientAddress) 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")) @@ -228,13 +235,33 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) { writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err) return } - if !ok { - writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha")) + if failure != auth.LoginFailureNone { + code := "LOGIN_FAILED" + message := "invalid username or password" + switch failure { + case auth.LoginFailureLocked: + code = "LOGIN_LOCKED" + message = "too many login attempts; try again later" + case auth.LoginFailureCaptcha: + code = "CAPTCHA_INVALID" + message = "captcha is invalid or expired" + case auth.LoginFailureCredentials: + code = "CREDENTIALS_INVALID" + } + writeError(w, http.StatusOK, code, errors.New(message)) return } auth.SetSessionCookieForRequest(w, req, sessionID) 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()) + go r.recordLoginAudit(body.Username, clientAddress, req.UserAgent()) +} + +func remoteHost(remoteAddress string) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress)) + if err == nil && host != "" { + return host + } + return strings.TrimSpace(remoteAddress) } func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) { diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index 7c93853..1caddcf 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -678,6 +678,31 @@ func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) { } } +func TestAdminReleasePackageUploadReportsInterruptedMultipart(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, csrf, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + const boundary = "ymhut-interrupted-upload" + body := bytes.NewBufferString("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"package.exe\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\npartial package") + req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", body) + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + 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.StatusBadRequest || !strings.Contains(res.Body.String(), "UPLOAD_INTERRUPTED") { + t.Fatalf("interrupted upload returned %d %s", res.Code, res.Body.String()) + } +} + func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request { t.Helper() var body bytes.Buffer @@ -950,7 +975,7 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) { 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") { + if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "CAPTCHA_INVALID") { t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String()) } if got := res.Header().Get("Cache-Control"); got != "no-store" { @@ -958,6 +983,15 @@ func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) { } } +func TestRemoteHostRemovesEphemeralPort(t *testing.T) { + if got := remoteHost("127.0.0.1:51842"); got != "127.0.0.1" { + t.Fatalf("remoteHost returned %q", got) + } + if got := remoteHost("[::1]:51842"); got != "::1" { + t.Fatalf("remoteHost returned %q", got) + } +} + func readTestCaptcha(dataURL string) (string, error) { const prefix = "data:image/png;base64," raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix)) diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index 637dbaa..4df8936 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -67,6 +67,7 @@ const router = useRouter(); const currentPath = computed(() => normalizeAdminPath(route.path)); const loading = ref(false); const loginPending = ref(false); +const captchaPending = ref(false); const toast = ref(null); const autoRefreshPaused = ref(false); const databaseFormEditing = ref(false); @@ -75,6 +76,7 @@ let refreshTimer: number | undefined; let systemRefreshTimer: number | undefined; let toastTimer: number | undefined; let events: EventSource | null = null; +let captchaRequestSerial = 0; const authStore = createAuthStore(); const dashboardStore = createDashboardStore(); @@ -491,7 +493,26 @@ function isAuthError(raw: string, message: string) { } async function loadCaptcha() { - captcha.value = await adminFetch("/api/admin/auth/captcha", {}, { timeoutMs: 5000 }); + const serial = ++captchaRequestSerial; + captchaPending.value = true; + try { + const next = await adminFetch("/api/admin/auth/captcha", {}, { timeoutMs: 5000 }); + if (serial === captchaRequestSerial) { + captcha.value = next; + loginForm.captcha = ""; + } + } finally { + if (serial === captchaRequestSerial) captchaPending.value = false; + } +} + +async function refreshCaptcha() { + try { + await loadCaptcha(); + } catch { + captcha.value = null; + setToast("验证码加载失败,请检查服务端连接", "error"); + } } async function loadAuthBootstrap() { @@ -511,7 +532,7 @@ async function login() { const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }), - }, { timeoutMs: 8000 }); + }, { timeoutMs: 12000 }); csrf.value = data.csrfToken; sessionStorage.setItem("ymhut.csrf", csrf.value); localStorage.removeItem("ymhut.csrf"); @@ -520,8 +541,7 @@ async function login() { } catch (error) { const message = toChineseError(error instanceof Error ? error.message : String(error)); setToast(message, "error"); - loginForm.captcha = ""; - void loadCaptcha().catch(() => { + await loadCaptcha().catch(() => { captcha.value = null; }); } finally { @@ -1742,9 +1762,9 @@ function connectAdminEvents() { 验证码
-
diff --git a/server/unified-management/web/admin/src/api/admin.ts b/server/unified-management/web/admin/src/api/admin.ts index d7e551f..b42d0ce 100644 --- a/server/unified-management/web/admin/src/api/admin.ts +++ b/server/unified-management/web/admin/src/api/admin.ts @@ -32,6 +32,9 @@ const exactMessages: Record = { const codeMessages: Record = { UNAUTHORIZED: "需要登录后继续操作", LOGIN_FAILED: "登录失败,请检查密码和验证码", + LOGIN_LOCKED: "登录失败次数过多,请 5 分钟后重试", + CAPTCHA_INVALID: "验证码错误或已过期,请输入新的验证码", + CREDENTIALS_INVALID: "账号或密码不正确", LOGIN_TIMEOUT: "登录校验超时,请稍后重试", PASSWORD_CHANGE_FAILED: "密码修改失败", INVALID_PAYLOAD: "提交内容格式不正确", @@ -50,6 +53,7 @@ const codeMessages: Record = { UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件", MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败", PACKAGE_UPLOAD_FAILED: "发布包上传失败", + UPLOAD_INTERRUPTED: "上传连接已中断,请保持页面打开后重试", SOURCE_SAVE_FAILED: "接口源保存失败", CHECK_FAILED: "接口健康检测失败", SYNC_FAILED: "同步操作失败", diff --git a/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs b/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs index 2bcd462..c8d4c14 100644 --- a/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs +++ b/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs @@ -256,14 +256,14 @@ public static class InstallerEngine 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 完整性检查。") @@ -481,6 +481,7 @@ public static class InstallerEngine "retry" => $"下载中断,正在重试 {fields[2]}。", "bundled" => $"正在使用安装包内置的 {fields[2]}。", "install" => $"正在安装 {fields[2]}。", + "waiting" => $"请在弹出的安装窗口中完成 {fields[2]} 安装;窗口关闭后将自动继续。", "complete" => $"{fields[2]} 安装完成。", _ => fields[2] }; diff --git a/src/YMhut.Box.Tests/ToolCatalogTests.cs b/src/YMhut.Box.Tests/ToolCatalogTests.cs index d7696d4..4eb6885 100644 --- a/src/YMhut.Box.Tests/ToolCatalogTests.cs +++ b/src/YMhut.Box.Tests/ToolCatalogTests.cs @@ -64,6 +64,19 @@ public sealed class ToolCatalogTests Assert.IsTrue(catalog.Search("音乐").Any(module => module.Id == "music")); } + [TestMethod] + public void FullToolboxCatalogIncludesMigratedToolFamilies() + { + var builtin = new BuiltinReferenceToolCatalog().GetModules(); + var catalog = new ToolCatalog(ToolCatalog.DefaultModules() + .Concat(NexNativeToolCatalog.Modules) + .Concat(builtin)); + + Assert.IsNotNull(catalog.GetById("hotboard")); + Assert.IsTrue(catalog.Modules.Any(module => module is NexNativeToolModule)); + Assert.IsTrue(catalog.Modules.Any(module => module is BuiltinReferenceToolModule)); + } + [TestMethod] public void EveryCatalogToolHasOneStableDisplayGroup() { diff --git a/src/box-winUI/MainWindow.xaml.cs b/src/box-winUI/MainWindow.xaml.cs index 8f5c8fa..ce45067 100644 --- a/src/box-winUI/MainWindow.xaml.cs +++ b/src/box-winUI/MainWindow.xaml.cs @@ -1562,6 +1562,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost var builtinTools = _builtinToolCatalog.GetModules(); var modules = ToolCatalog.DefaultModules() + .Concat(NexNativeToolCatalog.Modules) .Concat(builtinTools) .Concat(externalTools) .Concat(pluginTools) @@ -1573,7 +1574,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost "Information", "tool-catalog", "Tool catalog rebuilt", - $"native={ToolCatalog.DefaultModules().Count()}; builtin={builtinTools.Count}; external={externalTools.Count}; plugin={pluginTools.Count()}", + $"native={ToolCatalog.DefaultModules().Count()}; nex={NexNativeToolCatalog.Modules.Count}; builtin={builtinTools.Count}; external={externalTools.Count}; plugin={pluginTools.Count()}", cancellationToken); return new ToolCatalog(modules); diff --git a/src/box-winUI/Services/StartupInitializationService.cs b/src/box-winUI/Services/StartupInitializationService.cs index ac16ac3..ca9a89a 100644 --- a/src/box-winUI/Services/StartupInitializationService.cs +++ b/src/box-winUI/Services/StartupInitializationService.cs @@ -255,7 +255,11 @@ public sealed class StartupInitializationService( context.Report(0.25, T("正在扫描内置与随包工具...", "Scanning built-in and bundled tools...")); var externalTools = await externalToolCatalog.GetModulesAsync(token).ConfigureAwait(false); var builtinTools = builtinToolCatalog.GetModules(); - var count = ToolCatalog.DefaultModules().Count() + builtinTools.Count + externalTools.Count; + var count = ToolCatalog.DefaultModules() + .Concat(NexNativeToolCatalog.Modules) + .Concat(builtinTools) + .Concat(externalTools) + .Count(); setToolCount(count); context.Report(1, T($"工具目录已就绪:{count} 个工具。", $"Tool catalog ready: {count} tools.")); });