diff --git a/app-modern/cmd/softbox/main.go b/app-modern/cmd/softbox/main.go index 4f3e27e..e252b96 100644 --- a/app-modern/cmd/softbox/main.go +++ b/app-modern/cmd/softbox/main.go @@ -1,6 +1,8 @@ package main import ( + "context" + "errors" "log" "os" @@ -11,8 +13,11 @@ import ( "softbox.local/app-modern/platform/windows" softboxgio "softbox.local/app-modern/ui/gio" "softbox.local/core" + "softbox.local/core/application" ) +const applicationEventCapacity = 32 + func main() { go func() { if err := run(); err != nil { @@ -33,6 +38,31 @@ func run() error { theme := softboxgio.NewTheme() shell := softboxgio.NewAppShell(string(platform.Edition())) + runtime := application.NewRuntime(applicationEventCapacity) + relay, err := application.NewEventRelay(applicationEventCapacity) + if err != nil { + return err + } + eventContext, cancelEvents := context.WithCancel(context.Background()) + pumpDone := make(chan error, 1) + go func() { + pumpDone <- application.PumpEvents( + eventContext, + runtime.Events(), + relay, + window.Invalidate, + ) + }() + defer func() { + cancelEvents() + runtime.Close() + relay.Close() + if pumpErr := <-pumpDone; pumpErr != nil && + !errors.Is(pumpErr, context.Canceled) && + !errors.Is(pumpErr, application.ErrEventRelayClosed) { + log.Printf("application event pump stopped: %v", pumpErr) + } + }() var operations op.Ops for { @@ -40,6 +70,9 @@ func run() error { case app.DestroyEvent: return event.Err case app.FrameEvent: + if err := relay.Drain(shell.ApplyEvent); err != nil { + log.Printf("apply application event: %v", err) + } context := app.NewContext(&operations, event) shell.Layout(context, theme) event.Frame(context.Ops) diff --git a/app-modern/ui/gio/icon_events.go b/app-modern/ui/gio/icon_events.go new file mode 100644 index 0000000..1f39107 --- /dev/null +++ b/app-modern/ui/gio/icon_events.go @@ -0,0 +1,102 @@ +package gio + +import ( + "errors" + "fmt" + + "softbox.local/core/application" +) + +var ErrIconRequestStale = errors.New("icon request no longer matches catalog") + +// ExpectIcon records the newest request identity on the UI goroutine. +func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error { + validated, err := application.NewIconEventIdentity( + identity.RequestID, + identity.AppID, + identity.Reference, + identity.DPI, + ) + if err != nil { + return err + } + currentReference, exists := shell.iconReferences[validated.AppID] + if !exists || currentReference != validated.Reference { + return fmt.Errorf( + "%w: app=%q reference=%q", + ErrIconRequestStale, + validated.AppID, + validated.Reference, + ) + } + if applied, exists := shell.iconApplied[validated.AppID]; !exists || !sameIconResource(applied, validated) { + delete(shell.icons, validated.AppID) + delete(shell.iconApplied, validated.AppID) + } + shell.iconRequests[validated.AppID] = validated + delete(shell.iconFailures, validated.AppID) + return nil +} + +// CancelIconRequest invalidates the matching pending request on the UI goroutine. +func (shell *AppShell) CancelIconRequest(appID, requestID string) bool { + pending, exists := shell.iconRequests[appID] + if !exists || pending.RequestID != requestID { + return false + } + delete(shell.iconRequests, appID) + return true +} + +// ApplyEvent validates and applies an application event on the UI goroutine. +func (shell *AppShell) ApplyEvent(event application.Event) error { + iconEvent, handled, err := application.ParseIconEvent(event) + if err != nil || !handled { + return err + } + identity := iconEvent.Identity + pending, exists := shell.iconRequests[identity.AppID] + if !exists || pending != identity { + return nil + } + if shell.iconReferences[identity.AppID] != identity.Reference { + return nil + } + + delete(shell.iconRequests, identity.AppID) + switch iconEvent.Type { + case application.EventIconReady: + shell.ApplyIcon(identity.AppID, iconEvent.Image) + shell.iconApplied[identity.AppID] = identity + case application.EventIconFailed: + applied, hasApplied := shell.iconApplied[identity.AppID] + if !hasApplied || !sameIconResource(applied, identity) { + shell.ApplyIcon(identity.AppID, nil) + } + shell.iconFailures[identity.AppID] = iconEvent.ErrorCode + } + return nil +} + +// IconFailure exposes the last failure for diagnostics without raw network data. +func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) { + failure, exists := shell.iconFailures[appID] + return failure, exists +} + +func canonicalIconReference(reference string) string { + canonical, err := application.NormalizeIconReference(reference) + if err != nil { + return reference + } + return canonical +} + +func sameIconResource( + left application.IconEventIdentity, + right application.IconEventIdentity, +) bool { + return left.AppID == right.AppID && + left.Reference == right.Reference && + left.DPI == right.DPI +} diff --git a/app-modern/ui/gio/icon_events_test.go b/app-modern/ui/gio/icon_events_test.go new file mode 100644 index 0000000..022c8e2 --- /dev/null +++ b/app-modern/ui/gio/icon_events_test.go @@ -0,0 +1,264 @@ +package gio + +import ( + "context" + "errors" + "image" + "strings" + "testing" + "time" + + "softbox.local/core/application" +) + +func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) { + reference := testIconReference("11") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: reference, + }) + identity := testIconIdentity(t, "request-one", "app-one", reference, 96) + if err := shell.ExpectIcon(identity); err != nil { + t.Fatal(err) + } + ready, err := application.NewIconReadyEvent( + identity, + image.NewNRGBA(image.Rect(0, 0, 24, 24)), + ) + if err != nil { + t.Fatal(err) + } + relay, err := application.NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + submitted := make(chan error, 1) + go func() { + submitted <- relay.Submit(context.Background(), ready) + }() + if err := waitIconSubmit(submitted); err != nil { + t.Fatalf("Submit() error = %v", err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("background relay changed shell before UI drain") + } + if err := relay.Drain(shell.ApplyEvent); err != nil { + t.Fatalf("Drain() error = %v", err) + } + icon, exists := shell.icons["app-one"] + if !exists || icon.Size() != image.Pt(24, 24) { + t.Fatalf("applied icon = (%t, %v)", exists, icon.Size()) + } +} + +func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) { + reference := testIconReference("22") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: reference, + }) + oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96) + newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96) + if err := shell.ExpectIcon(oldIdentity); err != nil { + t.Fatal(err) + } + if err := shell.ExpectIcon(newIdentity); err != nil { + t.Fatal(err) + } + oldReady, err := application.NewIconReadyEvent( + oldIdentity, + image.NewNRGBA(image.Rect(0, 0, 12, 12)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("stale request inserted an icon") + } + + newReady, err := application.NewIconReadyEvent( + newIdentity, + image.NewNRGBA(image.Rect(0, 0, 30, 30)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) { + t.Fatalf("latest icon size = %v", got) + } + + retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96) + if err := shell.ExpectIcon(retryIdentity); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; !exists { + t.Fatal("same-resource retry discarded an already valid icon") + } + failed, err := application.NewIconFailedEvent( + retryIdentity, + application.IconFailureUnavailable, + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(failed); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; !exists { + t.Fatal("matching failure discarded an already valid icon") + } + if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable { + t.Fatalf("IconFailure() = (%q, %t)", failure, exists) + } + + dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144) + if err := shell.ExpectIcon(dpiIdentity); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("different-DPI request retained an unmatching image") + } + dpiFailed, err := application.NewIconFailedEvent( + dpiIdentity, + application.IconFailureUnavailable, + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(dpiFailed); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("different-DPI failure restored an unmatching image") + } +} + +func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) { + oldReference := testIconReference("33") + newReference := testIconReference("44") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: oldReference, + }) + oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96) + if err := shell.ExpectIcon(oldIdentity); err != nil { + t.Fatal(err) + } + oldReady, err := application.NewIconReadyEvent( + oldIdentity, + image.NewNRGBA(image.Rect(0, 0, 20, 20)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + shell.SetItems([]application.CatalogListItem{{ + ID: "app-one", + Name: "One", + IconRef: newReference, + }}) + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("IconRef change retained the previous image") + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("old IconRef result was reinserted") + } + + newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96) + if err := shell.ExpectIcon(newIdentity); err != nil { + t.Fatal(err) + } + if !shell.CancelIconRequest("app-one", newIdentity.RequestID) { + t.Fatal("CancelIconRequest() did not cancel the latest request") + } + newReady, err := application.NewIconReadyEvent( + newIdentity, + image.NewNRGBA(image.Rect(0, 0, 22, 22)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("canceled result was applied") + } + + shell.SetItems(nil) + if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) { + t.Fatalf("ExpectIcon(removed app) error = %v", err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("removed app was reinserted by a late result") + } +} + +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) + } + err := shell.ApplyEvent(application.Event{ + Type: application.EventIconReady, + RequestID: "request", + AppID: "app-one", + Payload: "wrong", + }) + if !errors.Is(err, application.ErrInvalidIconEvent) { + t.Fatalf("ApplyEvent(invalid payload) error = %v", err) + } + if len(shell.icons) != 0 { + t.Fatal("invalid payload polluted icon state") + } +} + +func testIconReference(pair string) string { + return "sha256:" + strings.Repeat(pair, 32) +} + +func testIconIdentity( + t *testing.T, + requestID string, + appID string, + reference string, + dpi int, +) application.IconEventIdentity { + t.Helper() + identity, err := application.NewIconEventIdentity( + requestID, + appID, + reference, + dpi, + ) + if err != nil { + t.Fatal(err) + } + return identity +} + +func waitIconSubmit(result <-chan error) error { + select { + case err := <-result: + return err + case <-time.After(2 * time.Second): + return errors.New("timed out waiting for icon relay") + } +} diff --git a/app-modern/ui/gio/shell.go b/app-modern/ui/gio/shell.go index ba203e4..5d61fce 100644 --- a/app-modern/ui/gio/shell.go +++ b/app-modern/ui/gio/shell.go @@ -66,6 +66,10 @@ type AppShell struct { categoryControls map[string]*widget.Clickable rows map[string]*rowControls icons map[string]paint.ImageOp + iconReferences map[string]string + iconRequests map[string]application.IconEventIdentity + iconApplied map[string]application.IconEventIdentity + iconFailures map[string]application.IconFailureCode lastRendered int detailRendered bool } @@ -83,14 +87,21 @@ func NewAppShell( categoryControls: make(map[string]*widget.Clickable), rows: make(map[string]*rowControls), icons: make(map[string]paint.ImageOp), + iconReferences: make(map[string]string), + iconRequests: make(map[string]application.IconEventIdentity), + iconApplied: make(map[string]application.IconEventIdentity), + iconFailures: make(map[string]application.IconFailureCode), } shell.search.SingleLine = true shell.SetItems(items) return shell } -// ApplyIcon stores a background-decoded image for future Layout calls. +// ApplyIcon stores a decoded image for future Layout calls. +// It is UI-goroutine-only; background workers must publish application events. func (shell *AppShell) ApplyIcon(appID string, icon image.Image) { + delete(shell.iconApplied, appID) + delete(shell.iconFailures, appID) if icon == nil { delete(shell.icons, appID) return @@ -104,18 +115,39 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { nextRows := make(map[string]*rowControls, len(items)) nextIcons := make(map[string]paint.ImageOp, len(items)) + nextReferences := make(map[string]string, len(items)) + nextRequests := make(map[string]application.IconEventIdentity, len(items)) + nextApplied := make(map[string]application.IconEventIdentity, len(items)) + nextFailures := make(map[string]application.IconFailureCode, len(items)) for _, item := range items { controls := shell.rows[item.ID] if controls == nil { controls = new(rowControls) } nextRows[item.ID] = controls - if icon, exists := shell.icons[item.ID]; exists { - nextIcons[item.ID] = icon + reference := canonicalIconReference(item.IconRef) + nextReferences[item.ID] = reference + if previous, exists := shell.iconReferences[item.ID]; exists && previous == reference { + if icon, exists := shell.icons[item.ID]; exists { + nextIcons[item.ID] = icon + } + if request, exists := shell.iconRequests[item.ID]; exists && request.Reference == reference { + nextRequests[item.ID] = request + } + if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference { + nextApplied[item.ID] = applied + } + if failure, exists := shell.iconFailures[item.ID]; exists { + nextFailures[item.ID] = failure + } } } shell.rows = nextRows shell.icons = nextIcons + shell.iconReferences = nextReferences + shell.iconRequests = nextRequests + shell.iconApplied = nextApplied + shell.iconFailures = nextFailures nextCategories := make(map[string]*widget.Clickable) for _, category := range append([]string{""}, shell.model.Categories()...) { diff --git a/app-win7/cmd/softbox/main.go b/app-win7/cmd/softbox/main.go index 255e355..d622b40 100644 --- a/app-win7/cmd/softbox/main.go +++ b/app-win7/cmd/softbox/main.go @@ -1,6 +1,8 @@ package main import ( + "context" + "errors" "log" "os" @@ -11,8 +13,11 @@ import ( "softbox.local/app-win7/platform/windows" softboxgio "softbox.local/app-win7/ui/gio" "softbox.local/core" + "softbox.local/core/application" ) +const applicationEventCapacity = 32 + func main() { go func() { if err := run(); err != nil { @@ -33,6 +38,31 @@ func run() error { theme := softboxgio.NewTheme() shell := softboxgio.NewAppShell(string(platform.Edition())) + runtime := application.NewRuntime(applicationEventCapacity) + relay, err := application.NewEventRelay(applicationEventCapacity) + if err != nil { + return err + } + eventContext, cancelEvents := context.WithCancel(context.Background()) + pumpDone := make(chan error, 1) + go func() { + pumpDone <- application.PumpEvents( + eventContext, + runtime.Events(), + relay, + window.Invalidate, + ) + }() + defer func() { + cancelEvents() + runtime.Close() + relay.Close() + if pumpErr := <-pumpDone; pumpErr != nil && + !errors.Is(pumpErr, context.Canceled) && + !errors.Is(pumpErr, application.ErrEventRelayClosed) { + log.Printf("application event pump stopped: %v", pumpErr) + } + }() var operations op.Ops for { @@ -40,6 +70,9 @@ func run() error { case app.DestroyEvent: return event.Err case app.FrameEvent: + if err := relay.Drain(shell.ApplyEvent); err != nil { + log.Printf("apply application event: %v", err) + } context := app.NewContext(&operations, event) shell.Layout(context, theme) event.Frame(context.Ops) diff --git a/app-win7/ui/gio/icon_events.go b/app-win7/ui/gio/icon_events.go new file mode 100644 index 0000000..1f39107 --- /dev/null +++ b/app-win7/ui/gio/icon_events.go @@ -0,0 +1,102 @@ +package gio + +import ( + "errors" + "fmt" + + "softbox.local/core/application" +) + +var ErrIconRequestStale = errors.New("icon request no longer matches catalog") + +// ExpectIcon records the newest request identity on the UI goroutine. +func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error { + validated, err := application.NewIconEventIdentity( + identity.RequestID, + identity.AppID, + identity.Reference, + identity.DPI, + ) + if err != nil { + return err + } + currentReference, exists := shell.iconReferences[validated.AppID] + if !exists || currentReference != validated.Reference { + return fmt.Errorf( + "%w: app=%q reference=%q", + ErrIconRequestStale, + validated.AppID, + validated.Reference, + ) + } + if applied, exists := shell.iconApplied[validated.AppID]; !exists || !sameIconResource(applied, validated) { + delete(shell.icons, validated.AppID) + delete(shell.iconApplied, validated.AppID) + } + shell.iconRequests[validated.AppID] = validated + delete(shell.iconFailures, validated.AppID) + return nil +} + +// CancelIconRequest invalidates the matching pending request on the UI goroutine. +func (shell *AppShell) CancelIconRequest(appID, requestID string) bool { + pending, exists := shell.iconRequests[appID] + if !exists || pending.RequestID != requestID { + return false + } + delete(shell.iconRequests, appID) + return true +} + +// ApplyEvent validates and applies an application event on the UI goroutine. +func (shell *AppShell) ApplyEvent(event application.Event) error { + iconEvent, handled, err := application.ParseIconEvent(event) + if err != nil || !handled { + return err + } + identity := iconEvent.Identity + pending, exists := shell.iconRequests[identity.AppID] + if !exists || pending != identity { + return nil + } + if shell.iconReferences[identity.AppID] != identity.Reference { + return nil + } + + delete(shell.iconRequests, identity.AppID) + switch iconEvent.Type { + case application.EventIconReady: + shell.ApplyIcon(identity.AppID, iconEvent.Image) + shell.iconApplied[identity.AppID] = identity + case application.EventIconFailed: + applied, hasApplied := shell.iconApplied[identity.AppID] + if !hasApplied || !sameIconResource(applied, identity) { + shell.ApplyIcon(identity.AppID, nil) + } + shell.iconFailures[identity.AppID] = iconEvent.ErrorCode + } + return nil +} + +// IconFailure exposes the last failure for diagnostics without raw network data. +func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) { + failure, exists := shell.iconFailures[appID] + return failure, exists +} + +func canonicalIconReference(reference string) string { + canonical, err := application.NormalizeIconReference(reference) + if err != nil { + return reference + } + return canonical +} + +func sameIconResource( + left application.IconEventIdentity, + right application.IconEventIdentity, +) bool { + return left.AppID == right.AppID && + left.Reference == right.Reference && + left.DPI == right.DPI +} diff --git a/app-win7/ui/gio/icon_events_test.go b/app-win7/ui/gio/icon_events_test.go new file mode 100644 index 0000000..022c8e2 --- /dev/null +++ b/app-win7/ui/gio/icon_events_test.go @@ -0,0 +1,264 @@ +package gio + +import ( + "context" + "errors" + "image" + "strings" + "testing" + "time" + + "softbox.local/core/application" +) + +func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) { + reference := testIconReference("11") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: reference, + }) + identity := testIconIdentity(t, "request-one", "app-one", reference, 96) + if err := shell.ExpectIcon(identity); err != nil { + t.Fatal(err) + } + ready, err := application.NewIconReadyEvent( + identity, + image.NewNRGBA(image.Rect(0, 0, 24, 24)), + ) + if err != nil { + t.Fatal(err) + } + relay, err := application.NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + submitted := make(chan error, 1) + go func() { + submitted <- relay.Submit(context.Background(), ready) + }() + if err := waitIconSubmit(submitted); err != nil { + t.Fatalf("Submit() error = %v", err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("background relay changed shell before UI drain") + } + if err := relay.Drain(shell.ApplyEvent); err != nil { + t.Fatalf("Drain() error = %v", err) + } + icon, exists := shell.icons["app-one"] + if !exists || icon.Size() != image.Pt(24, 24) { + t.Fatalf("applied icon = (%t, %v)", exists, icon.Size()) + } +} + +func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) { + reference := testIconReference("22") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: reference, + }) + oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96) + newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96) + if err := shell.ExpectIcon(oldIdentity); err != nil { + t.Fatal(err) + } + if err := shell.ExpectIcon(newIdentity); err != nil { + t.Fatal(err) + } + oldReady, err := application.NewIconReadyEvent( + oldIdentity, + image.NewNRGBA(image.Rect(0, 0, 12, 12)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("stale request inserted an icon") + } + + newReady, err := application.NewIconReadyEvent( + newIdentity, + image.NewNRGBA(image.Rect(0, 0, 30, 30)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) { + t.Fatalf("latest icon size = %v", got) + } + + retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96) + if err := shell.ExpectIcon(retryIdentity); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; !exists { + t.Fatal("same-resource retry discarded an already valid icon") + } + failed, err := application.NewIconFailedEvent( + retryIdentity, + application.IconFailureUnavailable, + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(failed); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; !exists { + t.Fatal("matching failure discarded an already valid icon") + } + if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable { + t.Fatalf("IconFailure() = (%q, %t)", failure, exists) + } + + dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144) + if err := shell.ExpectIcon(dpiIdentity); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("different-DPI request retained an unmatching image") + } + dpiFailed, err := application.NewIconFailedEvent( + dpiIdentity, + application.IconFailureUnavailable, + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(dpiFailed); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("different-DPI failure restored an unmatching image") + } +} + +func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) { + oldReference := testIconReference("33") + newReference := testIconReference("44") + shell := NewAppShell("Test", application.CatalogListItem{ + ID: "app-one", + Name: "One", + IconRef: oldReference, + }) + oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96) + if err := shell.ExpectIcon(oldIdentity); err != nil { + t.Fatal(err) + } + oldReady, err := application.NewIconReadyEvent( + oldIdentity, + image.NewNRGBA(image.Rect(0, 0, 20, 20)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + shell.SetItems([]application.CatalogListItem{{ + ID: "app-one", + Name: "One", + IconRef: newReference, + }}) + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("IconRef change retained the previous image") + } + if err := shell.ApplyEvent(oldReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("old IconRef result was reinserted") + } + + newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96) + if err := shell.ExpectIcon(newIdentity); err != nil { + t.Fatal(err) + } + if !shell.CancelIconRequest("app-one", newIdentity.RequestID) { + t.Fatal("CancelIconRequest() did not cancel the latest request") + } + newReady, err := application.NewIconReadyEvent( + newIdentity, + image.NewNRGBA(image.Rect(0, 0, 22, 22)), + ) + if err != nil { + t.Fatal(err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("canceled result was applied") + } + + shell.SetItems(nil) + if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) { + t.Fatalf("ExpectIcon(removed app) error = %v", err) + } + if err := shell.ApplyEvent(newReady); err != nil { + t.Fatal(err) + } + if _, exists := shell.icons["app-one"]; exists { + t.Fatal("removed app was reinserted by a late result") + } +} + +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) + } + err := shell.ApplyEvent(application.Event{ + Type: application.EventIconReady, + RequestID: "request", + AppID: "app-one", + Payload: "wrong", + }) + if !errors.Is(err, application.ErrInvalidIconEvent) { + t.Fatalf("ApplyEvent(invalid payload) error = %v", err) + } + if len(shell.icons) != 0 { + t.Fatal("invalid payload polluted icon state") + } +} + +func testIconReference(pair string) string { + return "sha256:" + strings.Repeat(pair, 32) +} + +func testIconIdentity( + t *testing.T, + requestID string, + appID string, + reference string, + dpi int, +) application.IconEventIdentity { + t.Helper() + identity, err := application.NewIconEventIdentity( + requestID, + appID, + reference, + dpi, + ) + if err != nil { + t.Fatal(err) + } + return identity +} + +func waitIconSubmit(result <-chan error) error { + select { + case err := <-result: + return err + case <-time.After(2 * time.Second): + return errors.New("timed out waiting for icon relay") + } +} diff --git a/app-win7/ui/gio/shell.go b/app-win7/ui/gio/shell.go index c1c9324..74fc23d 100644 --- a/app-win7/ui/gio/shell.go +++ b/app-win7/ui/gio/shell.go @@ -66,6 +66,10 @@ type AppShell struct { categoryControls map[string]*widget.Clickable rows map[string]*rowControls icons map[string]paint.ImageOp + iconReferences map[string]string + iconRequests map[string]application.IconEventIdentity + iconApplied map[string]application.IconEventIdentity + iconFailures map[string]application.IconFailureCode lastRendered int detailRendered bool } @@ -83,14 +87,21 @@ func NewAppShell( categoryControls: make(map[string]*widget.Clickable), rows: make(map[string]*rowControls), icons: make(map[string]paint.ImageOp), + iconReferences: make(map[string]string), + iconRequests: make(map[string]application.IconEventIdentity), + iconApplied: make(map[string]application.IconEventIdentity), + iconFailures: make(map[string]application.IconFailureCode), } shell.search.SingleLine = true shell.SetItems(items) return shell } -// ApplyIcon stores a background-decoded image for future Layout calls. +// ApplyIcon stores a decoded image for future Layout calls. +// It is UI-goroutine-only; background workers must publish application events. func (shell *AppShell) ApplyIcon(appID string, icon image.Image) { + delete(shell.iconApplied, appID) + delete(shell.iconFailures, appID) if icon == nil { delete(shell.icons, appID) return @@ -104,18 +115,39 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) { nextRows := make(map[string]*rowControls, len(items)) nextIcons := make(map[string]paint.ImageOp, len(items)) + nextReferences := make(map[string]string, len(items)) + nextRequests := make(map[string]application.IconEventIdentity, len(items)) + nextApplied := make(map[string]application.IconEventIdentity, len(items)) + nextFailures := make(map[string]application.IconFailureCode, len(items)) for _, item := range items { controls := shell.rows[item.ID] if controls == nil { controls = new(rowControls) } nextRows[item.ID] = controls - if icon, exists := shell.icons[item.ID]; exists { - nextIcons[item.ID] = icon + reference := canonicalIconReference(item.IconRef) + nextReferences[item.ID] = reference + if previous, exists := shell.iconReferences[item.ID]; exists && previous == reference { + if icon, exists := shell.icons[item.ID]; exists { + nextIcons[item.ID] = icon + } + if request, exists := shell.iconRequests[item.ID]; exists && request.Reference == reference { + nextRequests[item.ID] = request + } + if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference { + nextApplied[item.ID] = applied + } + if failure, exists := shell.iconFailures[item.ID]; exists { + nextFailures[item.ID] = failure + } } } shell.rows = nextRows shell.icons = nextIcons + shell.iconReferences = nextReferences + shell.iconRequests = nextRequests + shell.iconApplied = nextApplied + shell.iconFailures = nextFailures nextCategories := make(map[string]*widget.Clickable) for _, category := range append([]string{""}, shell.model.Categories()...) { diff --git a/core/application/event.go b/core/application/event.go index e59b88a..032c6b2 100644 --- a/core/application/event.go +++ b/core/application/event.go @@ -17,6 +17,8 @@ const ( EventAppStarted EventType = "AppStarted" EventAppExited EventType = "AppExited" EventLicenseChanged EventType = "LicenseChanged" + EventIconReady EventType = "IconReady" + EventIconFailed EventType = "IconFailed" ) var validEventTypes = map[EventType]struct{}{ @@ -33,6 +35,8 @@ var validEventTypes = map[EventType]struct{}{ EventAppStarted: {}, EventAppExited: {}, EventLicenseChanged: {}, + EventIconReady: {}, + EventIconFailed: {}, } // Valid reports whether eventType is part of the documented event contract. diff --git a/core/application/event_relay.go b/core/application/event_relay.go new file mode 100644 index 0000000..244d0f2 --- /dev/null +++ b/core/application/event_relay.go @@ -0,0 +1,138 @@ +package application + +import ( + "context" + "errors" + "fmt" + "sync" +) + +var ( + ErrEventRelayClosed = errors.New("application event relay closed") + ErrEventRelayInvalid = errors.New("application event relay is invalid") +) + +// EventRelay is a bounded FIFO between background event pumps and the UI frame. +// Submit applies lossless backpressure; Drain must only run on the UI goroutine. +type EventRelay struct { + events chan Event + slots chan struct{} + done chan struct{} + mu sync.Mutex + closed bool + closeOnce sync.Once +} + +// NewEventRelay creates a relay with a strictly positive bounded capacity. +func NewEventRelay(capacity int) (*EventRelay, error) { + if capacity <= 0 { + return nil, fmt.Errorf( + "%w: capacity must be positive", + ErrEventRelayInvalid, + ) + } + relay := &EventRelay{ + events: make(chan Event, capacity), + slots: make(chan struct{}, capacity), + done: make(chan struct{}), + } + for index := 0; index < capacity; index++ { + relay.slots <- struct{}{} + } + return relay, nil +} + +// Submit queues one event or returns when the context/relay closes. +func (relay *EventRelay) Submit(ctx context.Context, event Event) error { + if relay == nil { + return fmt.Errorf("%w: nil relay", ErrEventRelayInvalid) + } + if !event.Type.Valid() { + return fmt.Errorf( + "%w: unknown type %q", + ErrInvalidEvent, + event.Type, + ) + } + select { + case <-relay.done: + return ErrEventRelayClosed + case <-ctx.Done(): + return ctx.Err() + case <-relay.slots: + } + + relay.mu.Lock() + defer relay.mu.Unlock() + if relay.closed { + relay.slots <- struct{}{} + return ErrEventRelayClosed + } + if err := ctx.Err(); err != nil { + relay.slots <- struct{}{} + return err + } + relay.events <- event + return nil +} + +// Drain applies the events present at entry without extending a UI frame forever. +func (relay *EventRelay) Drain(apply func(Event) error) error { + if relay == nil || apply == nil { + return fmt.Errorf("%w: nil relay or apply function", ErrEventRelayInvalid) + } + limit := len(relay.events) + var applyErrors []error + for index := 0; index < limit; index++ { + select { + case event := <-relay.events: + relay.slots <- struct{}{} + if err := apply(event); err != nil { + applyErrors = append(applyErrors, err) + } + default: + return errors.Join(applyErrors...) + } + } + return errors.Join(applyErrors...) +} + +// Close unblocks pending submissions. Queued events remain available to Drain. +func (relay *EventRelay) Close() { + if relay == nil { + return + } + relay.closeOnce.Do(func() { + relay.mu.Lock() + relay.closed = true + close(relay.done) + relay.mu.Unlock() + }) +} + +// PumpEvents forwards application events to a relay and requests a UI frame. +// invalidate may be called concurrently; no UI state may be mutated here. +func PumpEvents( + ctx context.Context, + events <-chan Event, + relay *EventRelay, + invalidate func(), +) error { + if events == nil || relay == nil || invalidate == nil { + return fmt.Errorf("%w: incomplete event pump", ErrEventRelayInvalid) + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, open := <-events: + if !open { + return nil + } + if err := relay.Submit(ctx, event); err != nil { + return err + } + invalidate() + } + } +} diff --git a/core/application/event_relay_test.go b/core/application/event_relay_test.go new file mode 100644 index 0000000..8c23ad1 --- /dev/null +++ b/core/application/event_relay_test.go @@ -0,0 +1,208 @@ +package application + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +func TestEventRelayUsesBoundedFIFOBackpressure(t *testing.T) { + relay, err := NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + first := Event{Type: EventCatalogRefreshed, RequestID: "first"} + second := Event{Type: EventCatalogRejected, RequestID: "second"} + if err := relay.Submit(context.Background(), first); err != nil { + t.Fatal(err) + } + + started := make(chan struct{}) + secondResult := make(chan error, 1) + go func() { + close(started) + secondResult <- relay.Submit(context.Background(), second) + }() + <-started + select { + case err := <-secondResult: + t.Fatalf("second Submit() completed while relay was full: %v", err) + default: + } + + var received []string + if err := relay.Drain(func(event Event) error { + received = append(received, event.RequestID) + return nil + }); err != nil { + t.Fatal(err) + } + if err := waitRelayResult(secondResult); err != nil { + t.Fatalf("second Submit() error = %v", err) + } + if err := relay.Drain(func(event Event) error { + received = append(received, event.RequestID) + return nil + }); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(received, []string{"first", "second"}) { + t.Fatalf("received order = %v", received) + } +} + +func TestEventRelayCloseAndContextUnblockFullSubmit(t *testing.T) { + tests := []struct { + name string + unblock func(*EventRelay, context.CancelFunc) + wantErr error + }{ + { + name: "close", + unblock: func(relay *EventRelay, _ context.CancelFunc) { + relay.Close() + }, + wantErr: ErrEventRelayClosed, + }, + { + name: "cancel", + unblock: func(_ *EventRelay, cancel context.CancelFunc) { + cancel() + }, + wantErr: context.Canceled, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + relay, err := NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + if err := relay.Submit( + context.Background(), + Event{Type: EventCatalogRefreshed}, + ); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + started := make(chan struct{}) + result := make(chan error, 1) + go func() { + close(started) + result <- relay.Submit(ctx, Event{Type: EventCatalogRejected}) + }() + <-started + test.unblock(relay, cancel) + if err := waitRelayResult(result); !errors.Is(err, test.wantErr) { + t.Fatalf("Submit() error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestPumpEventsInvalidatesBeforeUIDrainAndStops(t *testing.T) { + relay, err := NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + source := make(chan Event, 2) + invalidated := make(chan struct{}, 2) + ctx, cancel := context.WithCancel(context.Background()) + pumpResult := make(chan error, 1) + go func() { + pumpResult <- PumpEvents(ctx, source, relay, func() { + invalidated <- struct{}{} + }) + }() + + first := Event{Type: EventCatalogRefreshed, RequestID: "first"} + second := Event{Type: EventCatalogRejected, RequestID: "second"} + source <- first + source <- second + waitSignal(t, invalidated) + + var received []string + if len(received) != 0 { + t.Fatal("background pump applied an event before UI drain") + } + if err := relay.Drain(func(event Event) error { + received = append(received, event.RequestID) + return nil + }); err != nil { + t.Fatal(err) + } + waitSignal(t, invalidated) + if err := relay.Drain(func(event Event) error { + received = append(received, event.RequestID) + return nil + }); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(received, []string{"first", "second"}) { + t.Fatalf("received order = %v", received) + } + + cancel() + if err := waitRelayResult(pumpResult); !errors.Is(err, context.Canceled) { + t.Fatalf("PumpEvents() error = %v", err) + } +} + +func TestEventRelayDrainReportsErrorsAndContinues(t *testing.T) { + relay, err := NewEventRelay(2) + if err != nil { + t.Fatal(err) + } + for _, eventType := range []EventType{EventCatalogRefreshed, EventCatalogRejected} { + if err := relay.Submit(context.Background(), Event{Type: eventType}); err != nil { + t.Fatal(err) + } + } + wantErr := errors.New("apply failed") + applied := 0 + err = relay.Drain(func(Event) error { + applied++ + return wantErr + }) + if !errors.Is(err, wantErr) || applied != 2 { + t.Fatalf("Drain() = (%d applies, %v)", applied, err) + } +} + +func TestNewEventRelayRejectsInvalidCapacity(t *testing.T) { + if _, err := NewEventRelay(0); !errors.Is(err, ErrEventRelayInvalid) { + t.Fatalf("NewEventRelay(0) error = %v", err) + } + relay, err := NewEventRelay(1) + if err != nil { + t.Fatal(err) + } + relay.Close() + if err := relay.Submit( + context.Background(), + Event{Type: EventCatalogRefreshed}, + ); !errors.Is(err, ErrEventRelayClosed) { + t.Fatalf("Submit(after Close) error = %v", err) + } +} + +func waitRelayResult(result <-chan error) error { + select { + case err := <-result: + return err + case <-time.After(2 * time.Second): + return errors.New("timed out waiting for relay") + } +} + +func waitSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for signal") + } +} diff --git a/core/application/event_test.go b/core/application/event_test.go index db09e4e..afe7509 100644 --- a/core/application/event_test.go +++ b/core/application/event_test.go @@ -17,6 +17,8 @@ func TestEventTypeValid(t *testing.T) { EventAppStarted, EventAppExited, EventLicenseChanged, + EventIconReady, + EventIconFailed, } for _, eventType := range eventTypes { diff --git a/core/application/icon_event.go b/core/application/icon_event.go new file mode 100644 index 0000000..a3006f0 --- /dev/null +++ b/core/application/icon_event.go @@ -0,0 +1,270 @@ +package application + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + "reflect" + "strings" +) + +const ( + MinIconDPI = 48 + MaxIconDPI = 768 +) + +var ErrInvalidIconEvent = errors.New("invalid icon application event") + +// IconFailureCode is a stable, non-sensitive reason exposed to UI adapters. +type IconFailureCode string + +const ( + IconFailureUnavailable IconFailureCode = "unavailable" + IconFailureInvalid IconFailureCode = "invalid_content" + IconFailureUnsafe IconFailureCode = "unsafe_cache" +) + +// Valid reports whether code is part of the documented icon event contract. +func (code IconFailureCode) Valid() bool { + switch code { + case IconFailureUnavailable, IconFailureInvalid, IconFailureUnsafe: + return true + default: + return false + } +} + +// IconEventIdentity correlates one background request with one catalog icon. +type IconEventIdentity struct { + RequestID string + AppID string + Reference string + DPI int +} + +// NewIconEventIdentity validates and canonicalizes an icon event identity. +func NewIconEventIdentity( + requestID string, + appID string, + reference string, + dpi int, +) (IconEventIdentity, error) { + if strings.TrimSpace(requestID) == "" { + return IconEventIdentity{}, fmt.Errorf( + "%w: empty request ID", + ErrInvalidIconEvent, + ) + } + if strings.TrimSpace(appID) == "" { + return IconEventIdentity{}, fmt.Errorf( + "%w: empty app ID", + ErrInvalidIconEvent, + ) + } + canonicalReference, err := NormalizeIconReference(reference) + if err != nil { + return IconEventIdentity{}, err + } + if dpi < MinIconDPI || dpi > MaxIconDPI { + return IconEventIdentity{}, fmt.Errorf( + "%w: DPI %d outside %d..%d", + ErrInvalidIconEvent, + dpi, + MinIconDPI, + MaxIconDPI, + ) + } + return IconEventIdentity{ + RequestID: requestID, + AppID: appID, + Reference: canonicalReference, + DPI: dpi, + }, nil +} + +// NormalizeIconReference returns the canonical sha256: form. +func NormalizeIconReference(reference string) (string, error) { + const prefix = "sha256:" + if !strings.HasPrefix(reference, prefix) { + return "", fmt.Errorf( + "%w: icon reference must use sha256", + ErrInvalidIconEvent, + ) + } + digest := strings.ToLower(strings.TrimPrefix(reference, prefix)) + decoded, err := hex.DecodeString(digest) + if err != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 { + return "", fmt.Errorf( + "%w: malformed icon digest", + ErrInvalidIconEvent, + ) + } + return prefix + digest, nil +} + +// IconReadyPayload contains an image decoded outside the Gio UI goroutine. +type IconReadyPayload struct { + Reference string + DPI int + Image image.Image +} + +// IconFailedPayload contains a stable failure classification, never a raw URL. +type IconFailedPayload struct { + Reference string + DPI int + ErrorCode IconFailureCode +} + +// IconEvent is the validated representation consumed by UI adapters. +type IconEvent struct { + Type EventType + Identity IconEventIdentity + Image image.Image + ErrorCode IconFailureCode +} + +// NewIconReadyEvent builds a validated ready event. +func NewIconReadyEvent( + identity IconEventIdentity, + icon image.Image, +) (Event, error) { + validated, err := NewIconEventIdentity( + identity.RequestID, + identity.AppID, + identity.Reference, + identity.DPI, + ) + if err != nil { + return Event{}, err + } + if isNilImage(icon) { + return Event{}, fmt.Errorf("%w: nil ready image", ErrInvalidIconEvent) + } + return Event{ + Type: EventIconReady, + RequestID: validated.RequestID, + AppID: validated.AppID, + Payload: IconReadyPayload{ + Reference: validated.Reference, + DPI: validated.DPI, + Image: icon, + }, + }, nil +} + +// NewIconFailedEvent builds a validated failure event. +func NewIconFailedEvent( + identity IconEventIdentity, + code IconFailureCode, +) (Event, error) { + validated, err := NewIconEventIdentity( + identity.RequestID, + identity.AppID, + identity.Reference, + identity.DPI, + ) + if err != nil { + return Event{}, err + } + if !code.Valid() { + return Event{}, fmt.Errorf( + "%w: unknown failure code %q", + ErrInvalidIconEvent, + code, + ) + } + return Event{ + Type: EventIconFailed, + RequestID: validated.RequestID, + AppID: validated.AppID, + Payload: IconFailedPayload{ + Reference: validated.Reference, + DPI: validated.DPI, + ErrorCode: code, + }, + }, nil +} + +// ParseIconEvent validates an icon envelope. Non-icon events return handled=false. +func ParseIconEvent(event Event) (parsed IconEvent, handled bool, err error) { + switch event.Type { + case EventIconReady: + payload, ok := event.Payload.(IconReadyPayload) + if !ok { + return IconEvent{}, true, fmt.Errorf( + "%w: ready payload has type %T", + ErrInvalidIconEvent, + event.Payload, + ) + } + identity, identityErr := NewIconEventIdentity( + event.RequestID, + event.AppID, + payload.Reference, + payload.DPI, + ) + if identityErr != nil { + return IconEvent{}, true, identityErr + } + if isNilImage(payload.Image) { + return IconEvent{}, true, fmt.Errorf( + "%w: nil ready image", + ErrInvalidIconEvent, + ) + } + return IconEvent{ + Type: event.Type, + Identity: identity, + Image: payload.Image, + }, true, nil + case EventIconFailed: + payload, ok := event.Payload.(IconFailedPayload) + if !ok { + return IconEvent{}, true, fmt.Errorf( + "%w: failed payload has type %T", + ErrInvalidIconEvent, + event.Payload, + ) + } + identity, identityErr := NewIconEventIdentity( + event.RequestID, + event.AppID, + payload.Reference, + payload.DPI, + ) + if identityErr != nil { + return IconEvent{}, true, identityErr + } + if !payload.ErrorCode.Valid() { + return IconEvent{}, true, fmt.Errorf( + "%w: unknown failure code %q", + ErrInvalidIconEvent, + payload.ErrorCode, + ) + } + return IconEvent{ + Type: event.Type, + Identity: identity, + ErrorCode: payload.ErrorCode, + }, true, nil + default: + return IconEvent{}, false, nil + } +} + +func isNilImage(icon image.Image) bool { + if icon == nil { + return true + } + value := reflect.ValueOf(icon) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, + reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/core/application/icon_event_test.go b/core/application/icon_event_test.go new file mode 100644 index 0000000..6c67636 --- /dev/null +++ b/core/application/icon_event_test.go @@ -0,0 +1,126 @@ +package application + +import ( + "errors" + "image" + "strings" + "testing" +) + +func TestIconEventRoundTrip(t *testing.T) { + reference := "sha256:" + strings.Repeat("A1", 32) + identity, err := NewIconEventIdentity("request-1", "app-one", reference, 144) + if err != nil { + t.Fatalf("NewIconEventIdentity() error = %v", err) + } + if identity.Reference != strings.ToLower(reference) { + t.Fatalf("canonical reference = %q", identity.Reference) + } + + icon := image.NewNRGBA(image.Rect(0, 0, 24, 24)) + ready, err := NewIconReadyEvent(identity, icon) + if err != nil { + t.Fatalf("NewIconReadyEvent() error = %v", err) + } + parsed, handled, err := ParseIconEvent(ready) + if err != nil || !handled { + t.Fatalf("ParseIconEvent(ready) = (%+v, %t, %v)", parsed, handled, err) + } + if parsed.Type != EventIconReady || parsed.Identity != identity || parsed.Image != icon { + t.Fatalf("parsed ready event = %+v", parsed) + } + + failed, err := NewIconFailedEvent(identity, IconFailureUnsafe) + if err != nil { + t.Fatalf("NewIconFailedEvent() error = %v", err) + } + parsed, handled, err = ParseIconEvent(failed) + if err != nil || !handled { + t.Fatalf("ParseIconEvent(failed) = (%+v, %t, %v)", parsed, handled, err) + } + if parsed.Type != EventIconFailed || + parsed.Identity != identity || + parsed.ErrorCode != IconFailureUnsafe { + t.Fatalf("parsed failed event = %+v", parsed) + } + + parsed, handled, err = ParseIconEvent(Event{Type: EventCatalogRefreshed}) + if err != nil || handled { + t.Fatalf("ParseIconEvent(non-icon) = (%+v, %t, %v)", parsed, handled, err) + } +} + +func TestIconEventRejectsInvalidIdentityAndPayload(t *testing.T) { + validReference := "sha256:" + strings.Repeat("0a", 32) + tests := []struct { + name string + requestID string + appID string + reference string + dpi int + }{ + {name: "empty request", appID: "app", reference: validReference, dpi: 96}, + {name: "empty app", requestID: "request", reference: validReference, dpi: 96}, + {name: "bad reference", requestID: "request", appID: "app", reference: "md5:00", dpi: 96}, + {name: "low DPI", requestID: "request", appID: "app", reference: validReference, dpi: 47}, + {name: "high DPI", requestID: "request", appID: "app", reference: validReference, dpi: 769}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewIconEventIdentity( + test.requestID, + test.appID, + test.reference, + test.dpi, + ) + if !errors.Is(err, ErrInvalidIconEvent) { + t.Fatalf("NewIconEventIdentity() error = %v", err) + } + }) + } + + identity, err := NewIconEventIdentity("request", "app", validReference, 96) + if err != nil { + t.Fatal(err) + } + if _, err := NewIconReadyEvent(identity, nil); !errors.Is(err, ErrInvalidIconEvent) { + t.Fatalf("NewIconReadyEvent(nil) error = %v", err) + } + var typedNil *image.NRGBA + if _, err := NewIconReadyEvent(identity, typedNil); !errors.Is(err, ErrInvalidIconEvent) { + t.Fatalf("NewIconReadyEvent(typed nil) error = %v", err) + } + if _, err := NewIconFailedEvent(identity, IconFailureCode("raw-http-error")); !errors.Is(err, ErrInvalidIconEvent) { + t.Fatalf("NewIconFailedEvent(invalid code) error = %v", err) + } + + invalidPayloads := []Event{ + {Type: EventIconReady, RequestID: "request", AppID: "app", Payload: "wrong"}, + {Type: EventIconFailed, RequestID: "request", AppID: "app", Payload: "wrong"}, + { + Type: EventIconReady, + RequestID: "request", + AppID: "app", + Payload: IconReadyPayload{ + Reference: validReference, + DPI: 96, + }, + }, + { + Type: EventIconFailed, + RequestID: "request", + AppID: "app", + Payload: IconFailedPayload{ + Reference: validReference, + DPI: 96, + ErrorCode: IconFailureCode("raw"), + }, + }, + } + for _, event := range invalidPayloads { + _, handled, err := ParseIconEvent(event) + if !handled || !errors.Is(err, ErrInvalidIconEvent) { + t.Fatalf("ParseIconEvent(%+v) handled=%t error=%v", event, handled, err) + } + } +} diff --git a/core/catalog/icon_delivery.go b/core/catalog/icon_delivery.go new file mode 100644 index 0000000..3068f64 --- /dev/null +++ b/core/catalog/icon_delivery.go @@ -0,0 +1,179 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "reflect" + + "softbox.local/core/application" +) + +var ( + ErrIconDeliveryInvalid = errors.New("icon event delivery is invalid") + ErrIconEventPublish = errors.New("publish icon application event") +) + +// IconLoader is the narrow cache contract used by background delivery. +type IconLoader interface { + Load(context.Context, IconRequest) (IconResult, error) +} + +// IconLoaderFunc adapts a function to IconLoader. +type IconLoaderFunc func(context.Context, IconRequest) (IconResult, error) + +func (function IconLoaderFunc) Load( + ctx context.Context, + request IconRequest, +) (IconResult, error) { + return function(ctx, request) +} + +// IconEventPublisher queues application events for UI adapters. +type IconEventPublisher interface { + Publish(context.Context, application.Event) error +} + +// IconEventPublisherFunc adapts a function to IconEventPublisher. +type IconEventPublisherFunc func(context.Context, application.Event) error + +func (function IconEventPublisherFunc) Publish( + ctx context.Context, + event application.Event, +) error { + return function(ctx, event) +} + +// IconEventDelivery loads and decodes an icon in a caller-owned background task. +// It never creates goroutines and never imports or mutates Gio state. +type IconEventDelivery struct { + Loader IconLoader + Publisher IconEventPublisher +} + +// LoadAndPublish emits one ready or failed application event. +// Cancellation ends silently so an obsolete request cannot publish a stale failure. +func (delivery IconEventDelivery) LoadAndPublish( + ctx context.Context, + identity application.IconEventIdentity, +) error { + validated, err := application.NewIconEventIdentity( + identity.RequestID, + identity.AppID, + identity.Reference, + identity.DPI, + ) + if err != nil { + return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, err) + } + if isNilIconDeliveryDependency(delivery.Loader) || + isNilIconDeliveryDependency(delivery.Publisher) { + return fmt.Errorf( + "%w: loader and publisher are required", + ErrIconDeliveryInvalid, + ) + } + if err := ctx.Err(); err != nil { + return err + } + + result, loadErr := delivery.Loader.Load(ctx, IconRequest{ + Reference: validated.Reference, + DPI: validated.DPI, + }) + if loadErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + if isIconDeliveryCancellation(loadErr) { + return loadErr + } + return delivery.publishFailure(ctx, validated, loadErr) + } + if err := ctx.Err(); err != nil { + return err + } + + icon, decodeErr := DecodeIcon(result.Bytes) + if decodeErr != nil { + return delivery.publishFailure(ctx, validated, decodeErr) + } + if err := ctx.Err(); err != nil { + return err + } + ready, eventErr := application.NewIconReadyEvent(validated, icon) + if eventErr != nil { + return fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr) + } + if publishErr := delivery.Publisher.Publish(ctx, ready); publishErr != nil { + if isIconDeliveryCancellation(publishErr) { + return publishErr + } + return errors.Join(ErrIconEventPublish, publishErr) + } + return result.Warning +} + +func (delivery IconEventDelivery) publishFailure( + ctx context.Context, + identity application.IconEventIdentity, + cause error, +) error { + if err := ctx.Err(); err != nil { + return err + } + failed, eventErr := application.NewIconFailedEvent( + identity, + classifyIconFailure(cause), + ) + if eventErr != nil { + return errors.Join( + fmt.Errorf("load or decode icon: %w", cause), + fmt.Errorf("%w: %v", ErrIconDeliveryInvalid, eventErr), + ) + } + publishErr := delivery.Publisher.Publish(ctx, failed) + operationErr := fmt.Errorf("load or decode icon: %w", cause) + if publishErr != nil { + if isIconDeliveryCancellation(publishErr) { + return publishErr + } + return errors.Join(operationErr, ErrIconEventPublish, publishErr) + } + return operationErr +} + +func classifyIconFailure(err error) application.IconFailureCode { + switch { + case errors.Is(err, ErrIconCacheUnsafe): + return application.IconFailureUnsafe + case errors.Is(err, ErrIconReferenceInvalid), + errors.Is(err, ErrIconDPIInvalid), + errors.Is(err, ErrIconHashMismatch), + errors.Is(err, ErrIconTooLarge), + errors.Is(err, ErrIconImageInvalid), + errors.Is(err, ErrIconResponseInvalid): + return application.IconFailureInvalid + default: + return application.IconFailureUnavailable + } +} + +func isIconDeliveryCancellation(err error) bool { + return errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) +} + +func isNilIconDeliveryDependency(dependency any) bool { + if dependency == nil { + return true + } + value := reflect.ValueOf(dependency) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, + reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/core/catalog/icon_delivery_test.go b/core/catalog/icon_delivery_test.go new file mode 100644 index 0000000..e4aee27 --- /dev/null +++ b/core/catalog/icon_delivery_test.go @@ -0,0 +1,236 @@ +package catalog + +import ( + "context" + "errors" + "strings" + "testing" + + "softbox.local/core/application" +) + +func TestIconEventDeliveryPublishesDecodedReadyEvent(t *testing.T) { + document := testPNG(t, 24, 24) + identity := iconEventIdentity(t, document, "request-ready") + var gotRequest IconRequest + var events []application.Event + delivery := IconEventDelivery{ + Loader: IconLoaderFunc(func( + _ context.Context, + request IconRequest, + ) (IconResult, error) { + gotRequest = request + return IconResult{Bytes: document, Source: IconSourceMemory}, nil + }), + Publisher: IconEventPublisherFunc(func( + _ context.Context, + event application.Event, + ) error { + events = append(events, event) + return nil + }), + } + + if err := delivery.LoadAndPublish(context.Background(), identity); err != nil { + t.Fatalf("LoadAndPublish() error = %v", err) + } + if gotRequest.Reference != identity.Reference || gotRequest.DPI != identity.DPI { + t.Fatalf("loader request = %+v", gotRequest) + } + if len(events) != 1 { + t.Fatalf("published events = %d", len(events)) + } + parsed, handled, err := application.ParseIconEvent(events[0]) + if err != nil || !handled { + t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, err) + } + if parsed.Type != application.EventIconReady || parsed.Identity != identity { + t.Fatalf("ready event = %+v", parsed) + } + if bounds := parsed.Image.Bounds(); bounds.Dx() != 24 || bounds.Dy() != 24 { + t.Fatalf("decoded bounds = %v", bounds) + } +} + +func TestIconEventDeliveryClassifiesFailuresWithoutRawErrorPayload(t *testing.T) { + document := testPNG(t, 8, 8) + identity := iconEventIdentity(t, document, "request-failed") + tests := []struct { + name string + loadErr error + bytes []byte + wantCode application.IconFailureCode + }{ + {name: "unsafe", loadErr: ErrIconCacheUnsafe, wantCode: application.IconFailureUnsafe}, + {name: "invalid", loadErr: ErrIconHashMismatch, wantCode: application.IconFailureInvalid}, + { + name: "unavailable", + loadErr: errors.New("GET https://secret.invalid/icon?token=hidden failed"), + wantCode: application.IconFailureUnavailable, + }, + {name: "decode", bytes: []byte("not an image"), wantCode: application.IconFailureInvalid}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var published application.Event + delivery := IconEventDelivery{ + Loader: IconLoaderFunc(func( + context.Context, + IconRequest, + ) (IconResult, error) { + return IconResult{Bytes: test.bytes}, test.loadErr + }), + Publisher: IconEventPublisherFunc(func( + _ context.Context, + event application.Event, + ) error { + published = event + return nil + }), + } + + err := delivery.LoadAndPublish(context.Background(), identity) + if err == nil { + t.Fatal("LoadAndPublish() unexpectedly succeeded") + } + parsed, handled, parseErr := application.ParseIconEvent(published) + if parseErr != nil || !handled { + t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr) + } + if parsed.Type != application.EventIconFailed || parsed.ErrorCode != test.wantCode { + t.Fatalf("failed event = %+v", parsed) + } + payload := published.Payload.(application.IconFailedPayload) + if strings.Contains(string(payload.ErrorCode), "secret") || + strings.Contains(string(payload.ErrorCode), "token") { + t.Fatalf("failure payload leaked raw error: %+v", payload) + } + }) + } +} + +func TestIconEventDeliveryReportsPublishFailureAndCacheWarning(t *testing.T) { + document := testPNG(t, 12, 12) + identity := iconEventIdentity(t, document, "request-publish") + publishErr := errors.New("event queue closed") + delivery := IconEventDelivery{ + Loader: IconLoaderFunc(func( + context.Context, + IconRequest, + ) (IconResult, error) { + return IconResult{Bytes: document}, nil + }), + Publisher: IconEventPublisherFunc(func( + context.Context, + application.Event, + ) error { + return publishErr + }), + } + if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconEventPublish) || !errors.Is(err, publishErr) { + t.Fatalf("LoadAndPublish() error = %v", err) + } + + warning := errors.New("disk store warning") + delivery.Loader = IconLoaderFunc(func( + context.Context, + IconRequest, + ) (IconResult, error) { + return IconResult{Bytes: document, Warning: warning}, nil + }) + delivery.Publisher = IconEventPublisherFunc(func( + context.Context, + application.Event, + ) error { + return nil + }) + if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, warning) { + t.Fatalf("LoadAndPublish() warning = %v", err) + } +} + +func TestIconEventDeliveryCancellationPublishesNothing(t *testing.T) { + document := testPNG(t, 10, 10) + identity := iconEventIdentity(t, document, "request-canceled") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + loaderCalled := false + publisherCalled := false + delivery := IconEventDelivery{ + Loader: IconLoaderFunc(func( + context.Context, + IconRequest, + ) (IconResult, error) { + loaderCalled = true + return IconResult{}, nil + }), + Publisher: IconEventPublisherFunc(func( + context.Context, + application.Event, + ) error { + publisherCalled = true + return nil + }), + } + if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) { + t.Fatalf("LoadAndPublish() error = %v", err) + } + if loaderCalled || publisherCalled { + t.Fatalf("canceled delivery called loader=%t publisher=%t", loaderCalled, publisherCalled) + } + + ctx, cancel = context.WithCancel(context.Background()) + loaderCalled = false + publisherCalled = false + delivery.Loader = IconLoaderFunc(func( + context.Context, + IconRequest, + ) (IconResult, error) { + loaderCalled = true + cancel() + return IconResult{Bytes: document}, nil + }) + if err := delivery.LoadAndPublish(ctx, identity); !errors.Is(err, context.Canceled) { + t.Fatalf("LoadAndPublish(cancel during load) error = %v", err) + } + if !loaderCalled || publisherCalled { + t.Fatalf( + "cancel-during-load called loader=%t publisher=%t", + loaderCalled, + publisherCalled, + ) + } +} + +func TestIconEventDeliveryRejectsIncompleteDependencies(t *testing.T) { + document := testPNG(t, 4, 4) + identity := iconEventIdentity(t, document, "request-invalid") + tests := []IconEventDelivery{ + {}, + {Loader: IconLoaderFunc(nil), Publisher: IconEventPublisherFunc(nil)}, + } + for _, delivery := range tests { + if err := delivery.LoadAndPublish(context.Background(), identity); !errors.Is(err, ErrIconDeliveryInvalid) { + t.Fatalf("LoadAndPublish() error = %v", err) + } + } +} + +func iconEventIdentity( + t *testing.T, + document []byte, + requestID string, +) application.IconEventIdentity { + t.Helper() + request := iconRequest(document, 96) + identity, err := application.NewIconEventIdentity( + requestID, + "app-one", + request.Reference, + request.DPI, + ) + if err != nil { + t.Fatal(err) + } + return identity +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 13e29c5..75e81ac 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-606`。Windows 安全路径阻断项与 Phase 2 首个图标缓存资源整改已关闭;Phase 2 第二个整改 `T-607` 已落成待领取,下一步实现后台图标结果经 application event 回到 UI goroutine 的线程接线,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-607`。Windows 安全路径阻断项、图标缓存资源边界与后台结果回 UI 线程的事件接线均已关闭;下一步按 Phase 2 交叉审核顺序落成 modern/win7 适配器交互契约任务,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 优先路径: @@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 已完成 T-606:图标缓存按 key 去重、流式有界读取、memory LRU 与双 shell 图标剪枝;下一步实现 T-607 的 UI 线程事件接线,再串行处理其余整改与 T-302/T-303、Phase 4-6。 +5. 已完成 T-606/T-607:图标缓存按 key 去重、流式有界读取、memory LRU、双 shell 图标剪枝,以及 Load/Decode→application event→有界 relay/Invalidate→UI ApplyEvent 的线程接线;下一步落成双适配器交互契约任务,再串行处理其余整改与 T-302/T-303、Phase 4-6。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index fd0240e..af95081 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -61,7 +61,7 @@ UI 固定交互模式: T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。 -T-204/T-606 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → application event → UI ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;Catalog 快照删除 app 时两个 shell 剪枝对应 ImageOp。详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 +T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 ## 三、仓库目录结构 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index ec21918..ce658b6 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -25,7 +25,7 @@ - `core/` 与 `app-win7/` 只使用 **Go 1.20 可编译**的语法与依赖;新增依赖前检查其 go.mod 的 `go` 指令。泛型可用(1.18+),但 1.21+ 的标准库函数(如 `slices`、`maps`、`min/max` 内建)不得进入这两个模块。 - Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。 - 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。 -- Gio Layout 每帧禁止 IO(磁盘/网络/哈希);后台任务只发布 application.Event,不直接改控件;控件状态按软件 ID 保存。 +- Gio Layout 每帧禁止 IO(磁盘/网络/哈希/图片解码);后台任务只发布 application.Event,不得直接调用 `ApplyIcon` 或改控件/map。后台 event pump 只入有界 relay 并调用 `Window.Invalidate`;只有 Frame/UI goroutine可以 drain `ApplyEvent`。relay 满队列不得静默丢事件,关闭/取消必须解除背压等待。 - 图标 Fetcher 必须返回与 context 绑定的流,由 `IconCache` 在分配完整响应前执行声明长度拒绝与 `maxBytes+1` 有界读取;不得恢复为先读任意大 `[]byte` 再校验。缓存并发只允许按 key 去重,不得用横跨磁盘/网络的全局锁换取去重。 ## 3. 安全纪律(违反即安全事故) diff --git a/docs/api.md b/docs/api.md index 80bb660..970f207 100644 --- a/docs/api.md +++ b/docs/api.md @@ -90,7 +90,9 @@ Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 UR 5. 远端和磁盘字节都必须复核 SHA-256,并通过图片完整解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。 6. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。 7. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。 -8. 后台完成 `IconCache.Load` 与 `DecodeIcon` 后只发布事件;Gio UI goroutine 消费事件后调用 `ApplyIcon`/Invalidate。Layout 只复用内存 `paint.ImageOp`,Catalog 移除 app 时两个 shell 同步剪枝对应 ImageOp。 +8. `IconEventDelivery` 在调用方拥有的后台 context 中完成 `IconCache.Load` 与 `DecodeIcon`,成功发布 `IconReady`,失败只发布稳定分类的 `IconFailed`;取消直接结束且不发布迟到失败。事件身份为 request_id + app_id + icon_ref + DPI,不得把原始 URL/query 或 Gio 类型放进 payload。 +9. application event relay 是有界 FIFO,队列满时执行可取消的 lossless backpressure,不静默丢图标结果。后台 pump 成功入队后只调用并发安全的 `Window.Invalidate`;Gio Frame/UI goroutine 在 Layout 前 drain 并执行 `ApplyEvent`/`ApplyIcon`。 +10. shell 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef、DPI 变化或取消请求后丢弃迟到 ready/failed。同一 AppID 更换 IconRef 时先清除旧 ImageOp,Layout 始终只复用内存 `paint.ImageOp`。 ## 2. 标准软件包协议 v1(ZIP) @@ -279,8 +281,10 @@ phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollba | InstallRolledBack | 切换失败恢复 backup | app_id, error_code | 状态 → rollback 完成提示 | | AppStarted / AppExited | 进程启动/退出检测 | app_id, pid | 状态 → running / installed | | LicenseChanged | 许可证导入/撤销 | products | 授权视图刷新 | +| IconReady | 图标已完成可信加载与后台解码 | request_id, app_id, icon_ref, DPI, image.Image | 有界 relay 唤醒窗口;UI Frame 验证仍为最新请求后创建 ImageOp | +| IconFailed | 图标加载、校验或解码失败(取消不发布) | request_id, app_id, icon_ref, DPI, error_code | UI 记录诊断;仅保留同 icon_ref/DPI 的既有可信图标,否则继续占位 | -错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。 +错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。 ## 5. CLI 参数合约 diff --git a/docs/current-state.md b/docs/current-state.md index 275869f..c8c9715 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,26 +13,26 @@ ## 当前快照 - 日期:2026-07-17 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-606 已完成,T-607 已落成待领取,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-607 已完成,T-302 继续暂后置 - 技术栈:根 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 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存,以及默认并发 2 的持久可恢复下载队列;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与随 Catalog 剪枝的内存图标 -- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件/图标剪枝稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon +- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件/图标剪枝稳定性、详情、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`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:无;下一步领取 T-607,建立图标后台 Load/Decode 经 application event、有界 relay 与 Invalidate 回到 Gio UI goroutine 的接线;适配器契约、VisibleItems 快照、Phase 1 中央目录预扫描等继续串行,T-302 继续后置 +- 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成 modern/win7 适配器交互契约任务;VisibleItems 快照、Phase 1 中央目录预扫描等继续串行,T-302 继续后置 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-606 已完成;T-607 已落成待领取,其余审核整改尚未编号,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-607 已完成;其余审核整改尚未编号,T-302 暂后置 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | -| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、可恢复下载队列与 Phase 1 安装安全原型 | -| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和随 Catalog 剪枝的内存图标 | -| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和随 Catalog 剪枝的内存图标 | +| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | +| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | +| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | | `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` | | `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 | @@ -40,9 +40,9 @@ 任务状态以 `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-604`~`T-606`。 +- 已完成: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-604`~`T-607`。 - 正在进行:无。 -- 下一个可领取任务:`T-607`(建立图标后台结果的 UI 线程事件投递),依赖 `T-606` 已完成。 +- 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 modern/win7 适配器交互契约任务。 ## 当前可运行内容 diff --git a/docs/review/phase2-review.md b/docs/review/phase2-review.md index ca29265..14b3995 100644 --- a/docs/review/phase2-review.md +++ b/docs/review/phase2-review.md @@ -284,5 +284,5 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取 ## 任务落地追踪 - `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。 -- `T-607` 已按最终处理顺序第 2 项落成待领取:后台图标 Load/Decode 只发布 application event,有界 relay 请求重绘,由 Gio UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`,并拒绝过期结果回写。 -- 适配器交互契约、`VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;必须等待 T-607 完成并提交后再按顺序落成。 +- `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。 +- 适配器交互契约、`VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;下一任务从双适配器交互契约开始,继续按顺序串行落成。 diff --git a/docs/routes.md b/docs/routes.md index 1d1afcb..9d01f49 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -47,7 +47,7 @@ ## 交互规则(硬约束) -- 每帧先 drain 点击事件,再提交用例;后台只发布事件,UI 在 ApplyEvent 中更新 ViewModel 后 `Window.Invalidate`。 +- 每帧先 drain application relay 与点击事件,再提交用例;后台只把事件放入有界 relay 并调用并发安全的 `Window.Invalidate`,Frame/UI goroutine 在 `ApplyEvent` 中更新 ViewModel/ImageOp。 - Layout 中不得读磁盘、访问网络、计算哈希;图标走内存 + 磁盘缓存(按 DPI)。 - 数百个软件项滚动不卡顿是验收标准,不是优化项。 - 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。 @@ -65,7 +65,7 @@ T-204 已落地的详情/图标约束: - 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。 - modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。 -- 后台把已验证图标解码为 `image.Image` 后发布完成事件;UI goroutine 消费事件再调用 `ApplyIcon`/Invalidate。`ApplyIcon` 预建 `paint.ImageOp`,列表与详情 Layout 只绘制内存操作;`SetItems` 在 Catalog 移除 app 时剪枝对应 ImageOp。 +- T-607 由 `IconEventDelivery` 在后台完成可信加载/解码并发布强类型 `IconReady`/`IconFailed`;有界 FIFO relay 只请求 Invalidate,Frame/UI goroutine drain 后才调用 `ApplyEvent`/`ApplyIcon`。相同 app 只接受最新且 IconRef/DPI 匹配的 request_id;Catalog 删除、IconRef 变化或取消后丢弃迟到结果,IconRef 变化同时清除旧 ImageOp。 - 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。 ## 导航规则 diff --git a/docs/tasks/T-607.md b/docs/tasks/T-607.md index 9eff193..86265ed 100644 --- a/docs/tasks/T-607.md +++ b/docs/tasks/T-607.md @@ -3,12 +3,12 @@ id: T-607 title: 建立图标后台结果的 UI 线程事件投递 phase: 2 deps: [T-606] -status: TODO +status: DONE created: 2026-07-17 issue: null -context_ref: null +context_ref: fba672381e9be69d8423026245ddf46ee52b16f6 claim_branch: null -work_branch: null +work_branch: agent/codex/T-607 write_paths: - docs/tasks/T-607.md - core/application/ @@ -44,7 +44,7 @@ T-606 已关闭图标缓存的全局锁 convoy、远端响应无界读取和内 - 提供统一构造/发布入口并校验空 ID、非法 `sha256:` 引用、非法 DPI、nil image 和 payload 类型,避免生产者各自拼装 `any`。 2. 在 `core/catalog` 增加可注入的后台结果发布用例: - 接受已经完整确定的 `IconFetchRequest`、AppID/RequestID 和 application event publisher,在调用方提供的后台 context 中执行 `IconCache.Load` 与 `DecodeIcon`。 - - 成功发布 ready;加载、校验或解码失败发布 failed;事件发布失败显式返回/报告,不得改变 T-606 的缓存可信性语义。 + - 成功发布 ready;加载、校验或解码失败发布 failed;调用 context 取消/超时直接结束且不发布可能过期的 failed;事件发布失败显式返回/报告,不得改变 T-606 的缓存可信性语义。 - 用例本身不创建无限 goroutine或无界 worker 队列;并发/生命周期由上层调度者和 context 控制。 - 继续使用 T-606 的流式 Fetcher 合约,不新增图标 URL/CDN 规则。 3. 为 modern/win7 建立相同的 application event relay: @@ -66,7 +66,7 @@ T-606 已关闭图标缓存的全局锁 convoy、远端响应无界读取和内 ## 验收要点 - `EventIconReady`/`EventIconFailed` 是有效 application event,强类型 payload 不 import Gio,非法事件身份或 payload 被稳定拒绝。 -- 后台图标用例调用现有 `IconCache.Load` 与 `DecodeIcon`,成功/失败均发布带 RequestID、AppID、IconRef、DPI 的对应事件;发布失败可观察且 context 取消能及时结束。 +- 后台图标用例调用现有 `IconCache.Load` 与 `DecodeIcon`,成功/非取消失败发布带 RequestID、AppID、IconRef、DPI 的对应事件;发布失败可观察,context 取消及时结束且不发布迟到失败。 - modern/win7 后台 relay 收到事件时只入队并请求 invalidate,不会直接改变 `shell.icons`;Frame/UI goroutine drain 后才创建 `paint.ImageOp`。 - relay 使用有界 FIFO 队列和可取消的 lossless backpressure,满队列不静默丢事件;window/runtime 退出后无 goroutine 泄漏、死锁或 busy loop。 - 已删除 app、IconRef/DPI 已变化、RequestID 已被新请求取代的迟到 ready/failed 均被忽略;不会在 `SetItems` 剪枝后重新插入旧图标。相同 AppID 的 IconRef 变化会清除旧 ImageOp,直到新图标就绪前显示占位。 @@ -98,3 +98,11 @@ T-606 已关闭图标缓存的全局锁 convoy、远端响应无界读取和内 - 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第二优先级整改落成任务;现有全局最大任务为 T-606,因此取 T-607,依赖已完成的 T-606。 - 2026-07-17:任务冻结后台 Load/Decode→application event→有界 relay/Invalidate→Gio UI drain/ApplyEvent 的线程边界;真实 URL 映射、viewport 调度、适配器全量契约、VisibleItems 快照和文件拆分继续按审核顺序串行处理。 - 2026-07-17:用两个隔离 workspace 的本地 `go doc gioui.org/app.Window.Invalidate` 核对 Gio v0.10.1 与 v0.6.0,两者都明确 Invalidate 可并发调用;因此只允许后台 relay 调 Invalidate,所有 shell/ImageOp 更新仍限定在 UI goroutine。 +- 2026-07-17:在 `agent/codex/T-607` 分支领取任务,基线为 `fba672381e9be69d8423026245ddf46ee52b16f6`;保持单 Agent 串行执行。 +- 2026-07-17:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的测试和 Windows amd64 构建。 +- 2026-07-17:`core/application` 新增 `IconReady`/`IconFailed` 强类型事件、规范化 request/app/icon_ref/DPI 身份、三类非敏感失败码,以及带 FIFO lossless backpressure 的有界 `EventRelay`。relay 的 slot+mutex 关闭线性化保证 Close 后不再接收新事件,满队列等待可由 context/close 解除;`PumpEvents` 只入队并调用 Invalidate。 +- 2026-07-17:`core/catalog.IconEventDelivery` 在调用方拥有的后台 context 中调用现有 `IconCache.Load` 与 `DecodeIcon`,成功发布 ready,非取消失败发布分类后的 failed,原始网络错误不进入 payload;取消不发布迟到失败,发布错误和非致命磁盘 warning 都返回调用方观察。 +- 2026-07-17:modern/win7 主循环均接入 32-entry runtime/relay 与 Frame 前 drain;后台 pump 退出时由 context/runtime/relay 确定收尾。两个 AppShell 只在 UI goroutine `ApplyEvent`/`ApplyIcon`,按最新 RequestID + AppID + IconRef + DPI 拒绝迟到结果;删除 app、IconRef 变化、DPI 变化和取消会失效旧请求,IconRef 变化清理旧 ImageOp。 +- 2026-07-17:定向验证通过:Go 1.20.14 `go vet ./application ./catalog`、`go test -count=10 ./application ./catalog`;modern/win7 `go test -count=5 ./ui/gio`,并验证对应 cmd 包编译。用例覆盖事件/payload 拒绝、后台 submit 在 UI drain 前不改 shell、FIFO 背压、关闭/取消、失败分类、发布失败、加载中取消、最新请求、IconRef/DPI 变化、删除 app 与取消后的迟到结果。 +- 2026-07-17:尝试 Go 1.20.14 `go test -race -count=1 ./application ./catalog`;当前 Windows 环境缺少 GCC(`cgo: C compiler "gcc" not found`),race detector 不可用。按任务边界如实记录;确定性线程边界测试在 core 重复 20 次、双 UI 重复 5 次通过。 +- 2026-07-17:完整 `./scripts/verify_phase0.ps1` 通过,包含治理/上下文/边界/版本校验、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的 UI/平台测试及 Windows amd64 构建;协议、架构、路由、编码规则、审核追踪和当前状态已同步。