Implement catalog startup diagnostics (T-616)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||
runtime := application.NewRuntime(1)
|
||||
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||
}))
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
event := <-runtime.Events()
|
||||
if event.Type != application.EventCatalogRefreshed {
|
||||
t.Fatalf("event type = %q", event.Type)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
@@ -59,10 +63,18 @@ func run() error {
|
||||
window.Invalidate,
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
relay.Close()
|
||||
if bootstrapErr := <-catalogDone; bootstrapErr != nil &&
|
||||
!errors.Is(bootstrapErr, context.Canceled) &&
|
||||
!errors.Is(bootstrapErr, application.ErrCatalogSourceUnconfigured) &&
|
||||
!errors.Is(bootstrapErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
if pumpErr := <-pumpDone; pumpErr != nil &&
|
||||
!errors.Is(pumpErr, context.Canceled) &&
|
||||
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
|
||||
@@ -85,3 +97,15 @@ func run() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCatalogBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.CatalogSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewCatalogBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@ func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||
emptyShell := NewAppShell(adapterContractEdition)
|
||||
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
|
||||
t.Fatal("empty catalog did not render the catalog-unavailable state")
|
||||
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
|
||||
t.Fatal("empty catalog did not render the catalog-loading state")
|
||||
}
|
||||
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||
t.Fatal("empty catalog rendered a filter recovery action")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
type catalogPresentationState string
|
||||
|
||||
const (
|
||||
catalogStateLoading catalogPresentationState = "loading"
|
||||
catalogStateReady catalogPresentationState = "ready"
|
||||
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||
)
|
||||
|
||||
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
switch payload.Type {
|
||||
case application.EventCatalogRefreshed:
|
||||
shell.SetItems(payload.Items)
|
||||
case application.EventCatalogRejected:
|
||||
switch payload.FailureCode {
|
||||
case application.CatalogFailureSourceUnconfigured:
|
||||
shell.catalogState = catalogStateUnconfigured
|
||||
case application.CatalogFailureLoadFailed:
|
||||
shell.catalogState = catalogStateLoadFailed
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (shell *AppShell) catalogStatusText() string {
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
return "目录状态:正在加载已验证 Catalog"
|
||||
case catalogStateUnconfigured:
|
||||
return "目录状态:Catalog 来源尚未配置"
|
||||
case catalogStateLoadFailed:
|
||||
return "目录状态:Catalog 加载失败"
|
||||
case catalogStateReady:
|
||||
return "目录状态:已加载已验证 Catalog"
|
||||
default:
|
||||
return "目录状态:未知"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||
shell := NewAppShell("Modern")
|
||||
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||
shell := NewAppShell("Modern")
|
||||
if shell.catalogState != catalogStateLoading {
|
||||
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||
}
|
||||
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||
t.Fatal("unconfigured state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||
t.Fatal("load-failed state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||
t.Fatal("loaded-empty state was not visible")
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("bad payload error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
|
||||
|
||||
// ApplyEvent validates and applies an application event on the UI goroutine.
|
||||
func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||
if err != nil || !handled {
|
||||
return err
|
||||
|
||||
@@ -213,8 +213,8 @@ func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
|
||||
|
||||
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
|
||||
shell := NewAppShell("Test")
|
||||
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
|
||||
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
|
||||
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventIconReady,
|
||||
|
||||
@@ -34,6 +34,7 @@ type AppShell struct {
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
@@ -58,6 +59,9 @@ func NewAppShell(
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
if len(items) == 0 {
|
||||
shell.catalogState = catalogStateLoading
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
shell.catalogState = catalogStateReady
|
||||
|
||||
nextRows := make(map[string]*rowControls, len(items))
|
||||
nextIcons := make(map[string]paint.ImageOp, len(items))
|
||||
|
||||
@@ -297,6 +297,20 @@ func (shell *AppShell) layoutEmptyState(
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
title = "正在加载软件目录"
|
||||
body = "正在等待已验证 Catalog 快照。"
|
||||
case catalogStateUnconfigured:
|
||||
title = "Catalog 来源尚未配置"
|
||||
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||
case catalogStateLoadFailed:
|
||||
title = "Catalog 加载失败"
|
||||
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||
case catalogStateReady:
|
||||
title = "Catalog 暂无可显示软件"
|
||||
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
|
||||
@@ -138,7 +138,7 @@ func (shell *AppShell) layoutFooter(
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "目录状态:等待已验证 Catalog")
|
||||
label := material.Caption(theme, shell.catalogStatusText())
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||
runtime := application.NewRuntime(1)
|
||||
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||
}))
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
event := <-runtime.Events()
|
||||
if event.Type != application.EventCatalogRefreshed {
|
||||
t.Fatalf("event type = %q", event.Type)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
@@ -59,10 +63,18 @@ func run() error {
|
||||
window.Invalidate,
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
relay.Close()
|
||||
if bootstrapErr := <-catalogDone; bootstrapErr != nil &&
|
||||
!errors.Is(bootstrapErr, context.Canceled) &&
|
||||
!errors.Is(bootstrapErr, application.ErrCatalogSourceUnconfigured) &&
|
||||
!errors.Is(bootstrapErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
if pumpErr := <-pumpDone; pumpErr != nil &&
|
||||
!errors.Is(pumpErr, context.Canceled) &&
|
||||
!errors.Is(pumpErr, application.ErrEventRelayClosed) {
|
||||
@@ -85,3 +97,15 @@ func run() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCatalogBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.CatalogSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewCatalogBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@ func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||
emptyShell := NewAppShell(adapterContractEdition)
|
||||
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
|
||||
t.Fatal("empty catalog did not render the catalog-unavailable state")
|
||||
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
|
||||
t.Fatal("empty catalog did not render the catalog-loading state")
|
||||
}
|
||||
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||
t.Fatal("empty catalog rendered a filter recovery action")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
type catalogPresentationState string
|
||||
|
||||
const (
|
||||
catalogStateLoading catalogPresentationState = "loading"
|
||||
catalogStateReady catalogPresentationState = "ready"
|
||||
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||
)
|
||||
|
||||
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
switch payload.Type {
|
||||
case application.EventCatalogRefreshed:
|
||||
shell.SetItems(payload.Items)
|
||||
case application.EventCatalogRejected:
|
||||
switch payload.FailureCode {
|
||||
case application.CatalogFailureSourceUnconfigured:
|
||||
shell.catalogState = catalogStateUnconfigured
|
||||
case application.CatalogFailureLoadFailed:
|
||||
shell.catalogState = catalogStateLoadFailed
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (shell *AppShell) catalogStatusText() string {
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
return "目录状态:正在加载已验证 Catalog"
|
||||
case catalogStateUnconfigured:
|
||||
return "目录状态:Catalog 来源尚未配置"
|
||||
case catalogStateLoadFailed:
|
||||
return "目录状态:Catalog 加载失败"
|
||||
case catalogStateReady:
|
||||
return "目录状态:已加载已验证 Catalog"
|
||||
default:
|
||||
return "目录状态:未知"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||
shell := NewAppShell("Legacy")
|
||||
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||
shell := NewAppShell("Legacy")
|
||||
if shell.catalogState != catalogStateLoading {
|
||||
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||
}
|
||||
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||
t.Fatal("unconfigured state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||
t.Fatal("load-failed state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||
t.Fatal("loaded-empty state was not visible")
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("bad payload error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
|
||||
|
||||
// ApplyEvent validates and applies an application event on the UI goroutine.
|
||||
func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||
if err != nil || !handled {
|
||||
return err
|
||||
|
||||
@@ -213,8 +213,8 @@ func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
|
||||
|
||||
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
|
||||
shell := NewAppShell("Test")
|
||||
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); err != nil {
|
||||
t.Fatalf("ApplyEvent(non-icon) error = %v", err)
|
||||
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventIconReady,
|
||||
|
||||
@@ -34,6 +34,7 @@ type AppShell struct {
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
catalogState catalogPresentationState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
@@ -58,6 +59,9 @@ func NewAppShell(
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
if len(items) == 0 {
|
||||
shell.catalogState = catalogStateLoading
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
shell.catalogState = catalogStateReady
|
||||
|
||||
nextRows := make(map[string]*rowControls, len(items))
|
||||
nextIcons := make(map[string]paint.ImageOp, len(items))
|
||||
@@ -146,7 +151,7 @@ func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
shell.edition+" · Legacy · Windows 7 SP1 x64",
|
||||
shell.catalogStatusText()+" · "+shell.edition+" · Legacy · Windows 7 SP1 x64",
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
|
||||
@@ -270,6 +270,20 @@ func (shell *AppShell) layoutEmptyState(
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或读取已验证缓存后会显示软件。"
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
title = "正在加载软件目录"
|
||||
body = "正在等待已验证 Catalog 快照。"
|
||||
case catalogStateUnconfigured:
|
||||
title = "Catalog 来源尚未配置"
|
||||
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||
case catalogStateLoadFailed:
|
||||
title = "Catalog 加载失败"
|
||||
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||
case catalogStateReady:
|
||||
title = "Catalog 暂无可显示软件"
|
||||
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||
}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCatalogSourceUnconfigured means this build has no trusted Catalog
|
||||
// endpoint/key composition. It must never be replaced with test data.
|
||||
ErrCatalogSourceUnconfigured = errors.New("catalog source is unconfigured")
|
||||
ErrCatalogBootstrapInvalid = errors.New("catalog bootstrap is invalid")
|
||||
ErrCatalogEventPayload = errors.New("catalog event payload is invalid")
|
||||
)
|
||||
|
||||
// CatalogSource identifies the verified source that produced a snapshot.
|
||||
type CatalogSource string
|
||||
|
||||
const (
|
||||
CatalogSourceRemote CatalogSource = "remote"
|
||||
CatalogSourceCache CatalogSource = "cache"
|
||||
)
|
||||
|
||||
// CatalogFailureCode is the public, non-sensitive reason shown by adapters.
|
||||
type CatalogFailureCode string
|
||||
|
||||
const (
|
||||
CatalogFailureSourceUnconfigured CatalogFailureCode = "catalog_source_unconfigured"
|
||||
CatalogFailureLoadFailed CatalogFailureCode = "catalog_load_failed"
|
||||
)
|
||||
|
||||
// CatalogSnapshot is an IO-free, verified and target-filtered list prepared
|
||||
// before it crosses into the UI event boundary.
|
||||
type CatalogSnapshot struct {
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoader prepares an already verified in-memory snapshot. Its
|
||||
// implementation belongs in composition/infrastructure, never in Gio Layout.
|
||||
type CatalogSnapshotLoader interface {
|
||||
LoadCatalogSnapshot(context.Context) (CatalogSnapshot, error)
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoaderFunc adapts a function to CatalogSnapshotLoader.
|
||||
type CatalogSnapshotLoaderFunc func(context.Context) (CatalogSnapshot, error)
|
||||
|
||||
func (function CatalogSnapshotLoaderFunc) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
return function(ctx)
|
||||
}
|
||||
|
||||
// EventPublisher is the narrow runtime boundary used by CatalogBootstrap.
|
||||
type EventPublisher interface {
|
||||
Publish(context.Context, Event) error
|
||||
}
|
||||
|
||||
// CatalogEvent is the typed, sanitized payload accepted by Gio adapters.
|
||||
type CatalogEvent struct {
|
||||
Type EventType
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
FailureCode CatalogFailureCode
|
||||
}
|
||||
|
||||
// CatalogBootstrap invokes one loader and publishes exactly one catalog result.
|
||||
// It is safe to run only in a background goroutine.
|
||||
type CatalogBootstrap struct {
|
||||
loader CatalogSnapshotLoader
|
||||
publisher EventPublisher
|
||||
}
|
||||
|
||||
// NewCatalogBootstrap creates the pure-core startup bridge.
|
||||
func NewCatalogBootstrap(loader CatalogSnapshotLoader, publisher EventPublisher) *CatalogBootstrap {
|
||||
return &CatalogBootstrap{loader: loader, publisher: publisher}
|
||||
}
|
||||
|
||||
// Run publishes a sanitized success or failure event. The returned error keeps
|
||||
// the original loader/publisher cause for non-UI diagnostics.
|
||||
func (bootstrap *CatalogBootstrap) Run(ctx context.Context) error {
|
||||
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||
return ErrCatalogBootstrapInvalid
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := bootstrap.loader.LoadCatalogSnapshot(ctx)
|
||||
if err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
if err := validateCatalogSnapshot(snapshot); err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
return bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRefreshed,
|
||||
Payload: CatalogEvent{
|
||||
Type: EventCatalogRefreshed,
|
||||
Items: cloneCatalogItems(snapshot.Items),
|
||||
Source: snapshot.Source,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (bootstrap *CatalogBootstrap) publishFailure(ctx context.Context, cause error) error {
|
||||
publishErr := bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRejected,
|
||||
Payload: CatalogEvent{Type: EventCatalogRejected, FailureCode: catalogFailureCode(cause)},
|
||||
})
|
||||
if publishErr != nil {
|
||||
return errors.Join(cause, publishErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// ParseCatalogEvent validates and deep-copies the payload before an adapter
|
||||
// changes UI state. Non-catalog events are left for other event handlers.
|
||||
func ParseCatalogEvent(event Event) (CatalogEvent, bool, error) {
|
||||
if event.Type != EventCatalogRefreshed && event.Type != EventCatalogRejected {
|
||||
return CatalogEvent{}, false, nil
|
||||
}
|
||||
payload, ok := event.Payload.(CatalogEvent)
|
||||
if !ok || payload.Type != event.Type || event.RequestID != "" || event.AppID != "" {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
switch payload.Type {
|
||||
case EventCatalogRefreshed:
|
||||
if payload.FailureCode != "" || validateCatalogSnapshot(CatalogSnapshot{Items: payload.Items, Source: payload.Source}) != nil {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
payload.Items = cloneCatalogItems(payload.Items)
|
||||
return payload, true, nil
|
||||
case EventCatalogRejected:
|
||||
if len(payload.Items) != 0 || payload.Source != "" || !payload.FailureCode.valid() {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
return payload, true, nil
|
||||
default:
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
}
|
||||
|
||||
// UnconfiguredCatalogLoader is the fail-closed default for builds where the
|
||||
// trusted endpoint and public key have not been provisioned.
|
||||
type UnconfiguredCatalogLoader struct{}
|
||||
|
||||
func (UnconfiguredCatalogLoader) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
return CatalogSnapshot{}, ErrCatalogSourceUnconfigured
|
||||
}
|
||||
|
||||
func validateCatalogSnapshot(snapshot CatalogSnapshot) error {
|
||||
if !snapshot.Source.valid() {
|
||||
return fmt.Errorf("%w: unrecognized catalog source", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(snapshot.Items))
|
||||
for _, item := range snapshot.Items {
|
||||
if item.ID == "" || item.Name == "" || item.Version == "" {
|
||||
return fmt.Errorf("%w: incomplete catalog item", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate catalog item %q", ErrCatalogBootstrapInvalid, item.ID)
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func catalogFailureCode(err error) CatalogFailureCode {
|
||||
if errors.Is(err, ErrCatalogSourceUnconfigured) {
|
||||
return CatalogFailureSourceUnconfigured
|
||||
}
|
||||
return CatalogFailureLoadFailed
|
||||
}
|
||||
|
||||
func (source CatalogSource) valid() bool {
|
||||
return source == CatalogSourceRemote || source == CatalogSourceCache
|
||||
}
|
||||
|
||||
func (code CatalogFailureCode) valid() bool {
|
||||
return code == CatalogFailureSourceUnconfigured || code == CatalogFailureLoadFailed
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestCatalogBootstrapPublishesIndependentVerifiedSnapshot(t *testing.T) {
|
||||
items := []CatalogListItem{{
|
||||
ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Tags: []string{"json"}, Status: domain.StatusNotInstalled,
|
||||
}}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Items: items, Source: CatalogSourceCache}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
items[0].Name = "mutated"
|
||||
items[0].Tags[0] = "mutated"
|
||||
event := <-runtime.Events()
|
||||
payload, handled, err := ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("ParseCatalogEvent() = %#v, %v, %v", payload, handled, err)
|
||||
}
|
||||
if payload.Source != CatalogSourceCache || payload.Items[0].Name != "JSON Tool" || payload.Items[0].Tags[0] != "json" {
|
||||
t.Fatalf("payload = %#v, want independent verified snapshot", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapPublishesStableFailureCode(t *testing.T) {
|
||||
loadErr := errors.New("network endpoint details must not reach UI")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
err error
|
||||
code CatalogFailureCode
|
||||
}{
|
||||
{name: "unconfigured", err: ErrCatalogSourceUnconfigured, code: CatalogFailureSourceUnconfigured},
|
||||
{name: "load failed", err: loadErr, code: CatalogFailureLoadFailed},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, test.err
|
||||
}), runtime)
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Fatalf("Run() error = %v, want original loader error", err)
|
||||
}
|
||||
payload, handled, parseErr := ParseCatalogEvent(<-runtime.Events())
|
||||
if parseErr != nil || !handled || payload.FailureCode != test.code {
|
||||
t.Fatalf("failure payload = %#v, handled=%v, error=%v", payload, handled, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapFailsClosedForInvalidDependenciesAndPayload(t *testing.T) {
|
||||
if err := (*CatalogBootstrap)(nil).Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("nil bootstrap error = %v", err)
|
||||
}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("invalid snapshot error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRejected, Payload: CatalogEvent{Type: EventCatalogRejected}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid rejection payload error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRefreshed, Payload: CatalogEvent{Type: EventCatalogRefreshed, Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid refresh payload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredCatalogLoaderHonorsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (UnconfiguredCatalogLoader{}).LoadCatalogSnapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadCatalogSnapshot() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapHonorsCancellationAndPreservesPublisherFailure(t *testing.T) {
|
||||
called := false
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
called = true
|
||||
return CatalogSnapshot{}, nil
|
||||
}), NewRuntime(1))
|
||||
if err := bootstrap.Run(ctx); !errors.Is(err, context.Canceled) || called {
|
||||
t.Fatalf("canceled Run() = %v, loader called=%v", err, called)
|
||||
}
|
||||
publishErr := errors.New("runtime publish unavailable")
|
||||
loaderErr := errors.New("loader failed")
|
||||
bootstrap = NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, loaderErr
|
||||
}), catalogFailPublisher{err: publishErr})
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, loaderErr) || !errors.Is(err, publishErr) {
|
||||
t.Fatalf("combined failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type catalogFailPublisher struct{ err error }
|
||||
|
||||
func (publisher catalogFailPublisher) Publish(context.Context, Event) error { return publisher.err }
|
||||
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新编排;T-403 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换。T-616 已正式落成,用于关闭 production cmd 未投递 Catalog 快照、导致空白/无说明空目录的缺口;物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新编排;T-403 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换;T-616 已完成 production cmd 的 Catalog 快照事件投递和明确空态诊断。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
|
||||
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
|
||||
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;T-403 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复。T-616 已正式落成,当前执行 Catalog 快照启动投递与明确空态诊断;完成后再按路线图正式落成 Phase 5 的 T-501。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;T-403 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复;T-616 已完成 Catalog 快照启动投递与明确空态诊断。下一步按路线图正式落成 Phase 5 的 T-501。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ UI 固定交互模式:
|
||||
|
||||
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
|
||||
|
||||
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。
|
||||
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-616 新增纯 core `CatalogBootstrap`:composition 注入的 loader 只能返回已验签、协议校验和目标过滤后的内存快照,再经既有 runtime/relay 投递 `CatalogRefreshed`/`CatalogRejected`;Gio Frame 解析强类型 payload 后才 `SetItems`,并以 loading、source_unconfigured、load_failed、loaded-empty、filter-empty 区分空态,失败不清除最后成功快照。当前 cmd 没有真实发布 URL/公钥,所以明确注入 fail-closed 未配置 loader,而不读取 testdata、环境变量或未签名本地文件;可信 client/cache 的实际 composition 仍待发布配置。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。
|
||||
|
||||
T-610 在两个隔离 `ui/gio` package 内采用相同文件职责:`shell.go` 只保存 AppShell 状态/生命周期和根编排,`shell_header.go` 保存 header/navigation,`shell_catalog.go` 保存 content/list/row/icon/empty state,`shell_detail.go` 保存详情,`shell_style.go` 保存主题与绘制 helper。该拆分没有增加 package/API/状态边界,modern 与 Win7 的 Gio 版本特有布局继续分别实现;适配器交互契约仍负责证明两端事件接线和可见行为一致。
|
||||
|
||||
|
||||
+4
-2
@@ -330,8 +330,8 @@ Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName`
|
||||
|
||||
| 事件 | 触发时机 | 负载 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| CatalogRefreshed | 清单验签并缓存成功 | catalog 摘要、generated_at | 列表刷新 |
|
||||
| CatalogRejected | 清单验签失败 | 原因码 | 提示 + 继续用缓存 |
|
||||
| CatalogRefreshed | 已验证、目标过滤的内存快照准备完成 | `CatalogEvent{source: remote\|cache, items: []CatalogListItem}` | UI Frame 深拷贝后刷新列表 |
|
||||
| CatalogRejected | 无法得到可消费快照 | `CatalogEvent{failure_code: catalog_source_unconfigured\|catalog_load_failed}` | 仅显示稳定诊断,保留最后成功快照 |
|
||||
| DownloadStarted | 一个下载 attempt 开始/因安全重下而重置 | request_id, app_id, attempt, done, total_known, total | 状态 → downloading |
|
||||
| DownloadProgress | 当前 attempt 进度更新 | request_id, app_id, attempt, done, total_known, total, speed | 进度条刷新;done 在同 attempt 内单调 |
|
||||
| DownloadPaused | 用户暂停且旧 worker 已完全退出 | request_id, app_id, attempt, done | 状态 → queued(暂停态) |
|
||||
@@ -347,6 +347,8 @@ Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName`
|
||||
|
||||
错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。`unsafe_cache` 详情只使用 event 中已经验证的 app ID、icon_ref 与 DPI 生成 `<digest>-<dpi>.icon` locator;不得把 cache root、原始错误或 link target 补进 event/UI。
|
||||
|
||||
T-616 固定 Catalog 启动事件的强类型 payload:`CatalogRefreshed` 只携带已经验签、协议校验、目标过滤并转为内存 `CatalogListItem` 的深拷贝快照,`source` 只允许 `remote` 或 `cache`;`CatalogRejected` 不携带列表、URL、路径、密钥或原始错误,只允许 `catalog_source_unconfigured`、`catalog_load_failed`。UI 必须拒绝类型、source、item 身份或 code 不匹配的 payload,且失败不得清除已成功显示的快照。当前生产 cmd 没有真实 URL/公钥/发布配置,故启动时只发布 `catalog_source_unconfigured`;不得以 testdata、环境变量、未签名本地文件或 allow-all verifier 填充目录。
|
||||
|
||||
## 5. CLI 参数合约
|
||||
|
||||
### 5.1 SoftBox.exe
|
||||
|
||||
@@ -13,24 +13,25 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-19
|
||||
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务已完成
|
||||
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-614 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务已完成
|
||||
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
|
||||
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
|
||||
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
|
||||
- T-403 自更新:`core/updater` 只接受正 PID、真实 `<root>/app` 与同 root `<root>/staging/<safe-request-id>`,先等待旧 PID 自然退出,随后恢复遗留 journal 或以 `prepared → target_backed_up → staging_activated → launched → committed` 切换;目录 rename、原子 JSON 和目录 durability 均由受限路径与平台同步栅栏保护。助手只启动固定 `<root>/app/SoftBox.exe --softbox-update-health <request-id>`,主程序在 Gio Layout 前从自身 EXE 写最小 health 确认;失败优先恢复旧 app,Windows 锁阻止恢复时保留 backup/journal,不强杀。两端有无 Gio `cmd/softboxupdater`,非 Windows 平台边界 fail closed;没有自更新下载、签名消费、版本选择或 UI 触发器。
|
||||
- T-616 启动 Catalog:`core/application.CatalogBootstrap` 只消费 composition 注入的已验证、目标过滤内存快照,以深拷贝 `CatalogRefreshed`/稳定 `CatalogRejected` payload 经现有 runtime/relay 交给 UI Frame;双端 shell 在 UI goroutine 更新 `SetItems` 并明确显示 loading、未配置、加载失败、已加载空目录与筛选空结果。当前 cmd 有意注入 `UnconfiguredCatalogLoader`,故 `dist/SoftBox.exe` 不会显示 fake 软件,而会显示“Catalog 来源尚未配置”;可信 URL/公钥/缓存 composition 仍待发布配置。
|
||||
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
|
||||
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||
- 当前 blocker:`dist/SoftBox.exe` 只创建空 Gio shell,尚未向 `AppShell.SetItems` 投递已验证 Catalog/cache 快照;T-616 已正式落成以补齐后台投递和稳定空态诊断。真实 Catalog URL/公钥/发布配置仍未入库,不得用 testdata、未签名本地文件或 allow-all verifier 伪装可用列表。可信自更新包下载/签名、版本选择、许可证策略与完整端到端 cmd/UI 编排也仍未装配。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰和真实图形环境仍需 T-601 的目标 Windows VM/真机故障注入
|
||||
- 当前 blocker:真实 Catalog URL/公钥/发布配置仍未入库,所以默认 `dist/SoftBox.exe` 明确显示 `catalog_source_unconfigured`,不得用 testdata、未签名本地文件或 allow-all verifier 伪装可用列表。可信自更新包下载/签名、版本选择、许可证策略与完整端到端 cmd/UI 编排也仍未装配。T-614 的外部 `softbox-catalog` 消费 corpus CI 证据仍需跨仓库协调;物理断电、文件锁/杀毒软件干扰和真实图形环境仍需 T-601 的目标 Windows VM/真机故障注入
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-615、T-401~T-403 已完成;T-616 已正式落成,正在执行 |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-616、T-401~T-403 已完成 |
|
||||
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
|
||||
@@ -42,8 +43,8 @@
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`~`T-403`。
|
||||
- 正在进行:T-616(依赖 T-204/T-607 已完成);完成、验证并提交后,再按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-614`;Phase 4 的 `T-401`~`T-403`。
|
||||
- 正在进行:无;下一步可按路线图正式落成 Phase 5 的 T-501。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ T-608 用 modern/win7 同场景适配器契约固定上述接线:Editor 和视
|
||||
|
||||
T-609 固定 `VisibleItems()` 的只读 snapshot generation:筛选或 Catalog 变化时由 model 完整构造新 backing array 后发布,已被上一帧持有的旧 generation 保持稳定;同一 generation 的每帧读取不复制。两个 Gio 适配仍只在 UI owner goroutine 读取,不得修改返回 slice、item 或 Tags,该契约不提供并发读写安全。
|
||||
|
||||
T-616 固定启动快照投递:`cmd` 只在后台运行 `CatalogBootstrap`,它将已准备内存快照或稳定失败 code 发布到 runtime;relay 后的 UI Frame 通过 `ApplyEvent` 更新 shell。初始 loading、`catalog_source_unconfigured`、`catalog_load_failed`、已加载空 Catalog 和筛选无结果必须有不同的可见文本,错误不清除已加载快照。当前没有可信发布配置时,只能显示未配置诊断,不能从 testdata、环境变量、任意 URL、未签名本地文件或 allow-all verifier 生成列表;任何这些 I/O/校验仍不得进入 Layout。
|
||||
|
||||
T-204 已落地的详情/图标约束:
|
||||
|
||||
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
||||
|
||||
+5
-3
@@ -3,12 +3,12 @@ id: T-616
|
||||
title: Catalog 启动投递与空白界面诊断
|
||||
phase: 2
|
||||
deps: [T-204, T-607]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-19
|
||||
issue: null
|
||||
context_ref: null
|
||||
context_ref: 06234e11bf6782945e3ac5c7f2ea5dc33f6cbdeb
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
work_branch: agent/codex/T-616
|
||||
write_paths:
|
||||
- docs/tasks/T-616.md
|
||||
- core/application/catalog_bootstrap.go
|
||||
@@ -63,3 +63,5 @@ write_paths:
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-19:正式落成。根据 `dist/SoftBox.exe` 白色/空白界面诊断,冻结“受测后台 Catalog 快照投递 + UI 明确空态”的最小闭环;明确当前无可信发布配置时默认发行物必须 fail closed 并说明原因,而不是读取 testdata 或伪装可用列表。
|
||||
- 2026-07-19:领取任务,基于 `06234e11bf6782945e3ac5c7f2ea5dc33f6cbdeb` 在 `agent/codex/T-616` 执行;先重跑基线,再实现 Catalog 快照投递和空态诊断。
|
||||
- 2026-07-19:完成。新增纯 core `CatalogBootstrap`、强类型深拷贝 Catalog event payload、稳定 `catalog_source_unconfigured` / `catalog_load_failed` 和默认 fail-closed loader;双端 cmd 在后台启动它并经现有 runtime/relay 投递,双端 Gio Frame 只解析内存事件后更新 `SetItems`。初始加载、未配置、加载失败、已加载空 Catalog 和筛选无结果均有不同可见文本,失败不清除最后成功快照;无真实 URL/公钥时不使用 testdata、环境变量、未签名文件或 allow-all verifier。新增 Windows 手工首帧 smoke runbook;它不替代 T-601/T-602 真机图形矩阵。验证通过:`go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C core test -count=10 ./application`、两个 app 的 `cmd/softbox`/`ui/gio` 与全包测试、双端 Windows amd64 主程序构建、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Windows UI 首帧手工 Smoke
|
||||
|
||||
> 本文只记录 T-616 的人工可见性检查。它不替代 T-601/T-602 对 Win7、RDP、DPI、GPU、杀毒软件或文件锁的真机矩阵结论。
|
||||
|
||||
## 前提
|
||||
|
||||
- 在仓库根执行 `./scripts/verify_phase0.ps1`,确认生成 `dist/SoftBox.exe`。
|
||||
- 当前构建没有可信 Catalog 发布 URL 与 Ed25519 公钥配置;预期不会显示真实软件列表,也不得使用 `testdata/` 作为替代。
|
||||
|
||||
## 步骤与预期
|
||||
|
||||
1. 双击 `dist/SoftBox.exe`,等待至多 5 秒。
|
||||
2. 确认首帧可见窗口标题 `SoftBox`、搜索框、分类/视图导航、底部状态文字和内容区;不应只有无文字的白色窗口。
|
||||
3. 确认内容区显示 `Catalog 来源尚未配置`,并说明“此构建未装配可信发布配置,因此未显示任何软件”。
|
||||
4. 确认不显示 URL、公钥、文件路径、原始错误、测试软件或测试数据。
|
||||
5. 关闭窗口后确认进程正常退出。
|
||||
|
||||
若第 2 步失败,记录 Windows 版本、GPU/远程桌面状态、窗口截图和 Application 事件日志;不要把手工重启、修改 DLL、关闭安全软件或加载未验证 Catalog 当作修复。若第 3 步失败,按 T-616 的 runtime/relay/UI 测试排查;真实发布配置接入需另行评审。
|
||||
Reference in New Issue
Block a user