Route icon results through UI events (T-607)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()...) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()...) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ func TestEventTypeValid(t *testing.T) {
|
||||
EventAppStarted,
|
||||
EventAppExited,
|
||||
EventLicenseChanged,
|
||||
EventIconReady,
|
||||
EventIconFailed,
|
||||
}
|
||||
|
||||
for _, eventType := range eventTypes {
|
||||
|
||||
@@ -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:<lower-hex> 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -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,关闭详情不清空筛选或列表位置。
|
||||
|
||||
## 三、仓库目录结构
|
||||
|
||||
|
||||
@@ -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. 安全纪律(违反即安全事故)
|
||||
|
||||
+6
-2
@@ -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 参数合约
|
||||
|
||||
|
||||
+10
-10
@@ -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 适配器交互契约任务。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -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 诊断尚未编号;下一任务从双适配器交互契约开始,继续按顺序串行落成。
|
||||
|
||||
+2
-2
@@ -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 的字母占位,不阻塞列表或详情。
|
||||
|
||||
## 导航规则
|
||||
|
||||
+13
-5
@@ -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 构建;协议、架构、路由、编码规则、审核追踪和当前状态已同步。
|
||||
|
||||
Reference in New Issue
Block a user