Route icon results through UI events (T-607)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -11,8 +13,11 @@ import (
|
|||||||
"softbox.local/app-modern/platform/windows"
|
"softbox.local/app-modern/platform/windows"
|
||||||
softboxgio "softbox.local/app-modern/ui/gio"
|
softboxgio "softbox.local/app-modern/ui/gio"
|
||||||
"softbox.local/core"
|
"softbox.local/core"
|
||||||
|
"softbox.local/core/application"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const applicationEventCapacity = 32
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
go func() {
|
go func() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
@@ -33,6 +38,31 @@ func run() error {
|
|||||||
|
|
||||||
theme := softboxgio.NewTheme()
|
theme := softboxgio.NewTheme()
|
||||||
shell := softboxgio.NewAppShell(string(platform.Edition()))
|
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
|
var operations op.Ops
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -40,6 +70,9 @@ func run() error {
|
|||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
return event.Err
|
return event.Err
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
|
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||||
|
log.Printf("apply application event: %v", err)
|
||||||
|
}
|
||||||
context := app.NewContext(&operations, event)
|
context := app.NewContext(&operations, event)
|
||||||
shell.Layout(context, theme)
|
shell.Layout(context, theme)
|
||||||
event.Frame(context.Ops)
|
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
|
categoryControls map[string]*widget.Clickable
|
||||||
rows map[string]*rowControls
|
rows map[string]*rowControls
|
||||||
icons map[string]paint.ImageOp
|
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
|
lastRendered int
|
||||||
detailRendered bool
|
detailRendered bool
|
||||||
}
|
}
|
||||||
@@ -83,14 +87,21 @@ func NewAppShell(
|
|||||||
categoryControls: make(map[string]*widget.Clickable),
|
categoryControls: make(map[string]*widget.Clickable),
|
||||||
rows: make(map[string]*rowControls),
|
rows: make(map[string]*rowControls),
|
||||||
icons: make(map[string]paint.ImageOp),
|
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.search.SingleLine = true
|
||||||
shell.SetItems(items)
|
shell.SetItems(items)
|
||||||
return shell
|
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) {
|
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||||
|
delete(shell.iconApplied, appID)
|
||||||
|
delete(shell.iconFailures, appID)
|
||||||
if icon == nil {
|
if icon == nil {
|
||||||
delete(shell.icons, appID)
|
delete(shell.icons, appID)
|
||||||
return
|
return
|
||||||
@@ -104,18 +115,39 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
|||||||
|
|
||||||
nextRows := make(map[string]*rowControls, len(items))
|
nextRows := make(map[string]*rowControls, len(items))
|
||||||
nextIcons := make(map[string]paint.ImageOp, 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 {
|
for _, item := range items {
|
||||||
controls := shell.rows[item.ID]
|
controls := shell.rows[item.ID]
|
||||||
if controls == nil {
|
if controls == nil {
|
||||||
controls = new(rowControls)
|
controls = new(rowControls)
|
||||||
}
|
}
|
||||||
nextRows[item.ID] = controls
|
nextRows[item.ID] = controls
|
||||||
if icon, exists := shell.icons[item.ID]; exists {
|
reference := canonicalIconReference(item.IconRef)
|
||||||
nextIcons[item.ID] = icon
|
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.rows = nextRows
|
||||||
shell.icons = nextIcons
|
shell.icons = nextIcons
|
||||||
|
shell.iconReferences = nextReferences
|
||||||
|
shell.iconRequests = nextRequests
|
||||||
|
shell.iconApplied = nextApplied
|
||||||
|
shell.iconFailures = nextFailures
|
||||||
|
|
||||||
nextCategories := make(map[string]*widget.Clickable)
|
nextCategories := make(map[string]*widget.Clickable)
|
||||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -11,8 +13,11 @@ import (
|
|||||||
"softbox.local/app-win7/platform/windows"
|
"softbox.local/app-win7/platform/windows"
|
||||||
softboxgio "softbox.local/app-win7/ui/gio"
|
softboxgio "softbox.local/app-win7/ui/gio"
|
||||||
"softbox.local/core"
|
"softbox.local/core"
|
||||||
|
"softbox.local/core/application"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const applicationEventCapacity = 32
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
go func() {
|
go func() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
@@ -33,6 +38,31 @@ func run() error {
|
|||||||
|
|
||||||
theme := softboxgio.NewTheme()
|
theme := softboxgio.NewTheme()
|
||||||
shell := softboxgio.NewAppShell(string(platform.Edition()))
|
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
|
var operations op.Ops
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -40,6 +70,9 @@ func run() error {
|
|||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
return event.Err
|
return event.Err
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
|
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||||
|
log.Printf("apply application event: %v", err)
|
||||||
|
}
|
||||||
context := app.NewContext(&operations, event)
|
context := app.NewContext(&operations, event)
|
||||||
shell.Layout(context, theme)
|
shell.Layout(context, theme)
|
||||||
event.Frame(context.Ops)
|
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
|
categoryControls map[string]*widget.Clickable
|
||||||
rows map[string]*rowControls
|
rows map[string]*rowControls
|
||||||
icons map[string]paint.ImageOp
|
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
|
lastRendered int
|
||||||
detailRendered bool
|
detailRendered bool
|
||||||
}
|
}
|
||||||
@@ -83,14 +87,21 @@ func NewAppShell(
|
|||||||
categoryControls: make(map[string]*widget.Clickable),
|
categoryControls: make(map[string]*widget.Clickable),
|
||||||
rows: make(map[string]*rowControls),
|
rows: make(map[string]*rowControls),
|
||||||
icons: make(map[string]paint.ImageOp),
|
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.search.SingleLine = true
|
||||||
shell.SetItems(items)
|
shell.SetItems(items)
|
||||||
return shell
|
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) {
|
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||||
|
delete(shell.iconApplied, appID)
|
||||||
|
delete(shell.iconFailures, appID)
|
||||||
if icon == nil {
|
if icon == nil {
|
||||||
delete(shell.icons, appID)
|
delete(shell.icons, appID)
|
||||||
return
|
return
|
||||||
@@ -104,18 +115,39 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
|||||||
|
|
||||||
nextRows := make(map[string]*rowControls, len(items))
|
nextRows := make(map[string]*rowControls, len(items))
|
||||||
nextIcons := make(map[string]paint.ImageOp, 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 {
|
for _, item := range items {
|
||||||
controls := shell.rows[item.ID]
|
controls := shell.rows[item.ID]
|
||||||
if controls == nil {
|
if controls == nil {
|
||||||
controls = new(rowControls)
|
controls = new(rowControls)
|
||||||
}
|
}
|
||||||
nextRows[item.ID] = controls
|
nextRows[item.ID] = controls
|
||||||
if icon, exists := shell.icons[item.ID]; exists {
|
reference := canonicalIconReference(item.IconRef)
|
||||||
nextIcons[item.ID] = icon
|
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.rows = nextRows
|
||||||
shell.icons = nextIcons
|
shell.icons = nextIcons
|
||||||
|
shell.iconReferences = nextReferences
|
||||||
|
shell.iconRequests = nextRequests
|
||||||
|
shell.iconApplied = nextApplied
|
||||||
|
shell.iconFailures = nextFailures
|
||||||
|
|
||||||
nextCategories := make(map[string]*widget.Clickable)
|
nextCategories := make(map[string]*widget.Clickable)
|
||||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const (
|
|||||||
EventAppStarted EventType = "AppStarted"
|
EventAppStarted EventType = "AppStarted"
|
||||||
EventAppExited EventType = "AppExited"
|
EventAppExited EventType = "AppExited"
|
||||||
EventLicenseChanged EventType = "LicenseChanged"
|
EventLicenseChanged EventType = "LicenseChanged"
|
||||||
|
EventIconReady EventType = "IconReady"
|
||||||
|
EventIconFailed EventType = "IconFailed"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validEventTypes = map[EventType]struct{}{
|
var validEventTypes = map[EventType]struct{}{
|
||||||
@@ -33,6 +35,8 @@ var validEventTypes = map[EventType]struct{}{
|
|||||||
EventAppStarted: {},
|
EventAppStarted: {},
|
||||||
EventAppExited: {},
|
EventAppExited: {},
|
||||||
EventLicenseChanged: {},
|
EventLicenseChanged: {},
|
||||||
|
EventIconReady: {},
|
||||||
|
EventIconFailed: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid reports whether eventType is part of the documented event contract.
|
// 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,
|
EventAppStarted,
|
||||||
EventAppExited,
|
EventAppExited,
|
||||||
EventLicenseChanged,
|
EventLicenseChanged,
|
||||||
|
EventIconReady,
|
||||||
|
EventIconFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, eventType := range eventTypes {
|
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 安全解压、原子切换回滚原型。
|
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
|
||||||
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
|
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
|
||||||
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
|
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-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` 内建)不得进入这两个模块。
|
- `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 可跑。
|
- Gio 代码只出现在 `ui/gio/`;Windows 调用只出现在 `platform/windows/`,且必须有非 Windows stub,保证 `go test ./...` 在 Linux CI 可跑。
|
||||||
- 仅 Win10+ 存在的 Windows API 必须 LoadLibrary 动态加载、失败降级,不得成为 EXE 导入表强依赖。
|
- 仅 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 去重,不得用横跨磁盘/网络的全局锁换取去重。
|
- 图标 Fetcher 必须返回与 context 绑定的流,由 `IconCache` 在分配完整响应前执行声明长度拒绝与 `maxBytes+1` 有界读取;不得恢复为先读任意大 `[]byte` 再校验。缓存并发只允许按 key 去重,不得用横跨磁盘/网络的全局锁换取去重。
|
||||||
|
|
||||||
## 3. 安全纪律(违反即安全事故)
|
## 3. 安全纪律(违反即安全事故)
|
||||||
|
|||||||
+6
-2
@@ -90,7 +90,9 @@ Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 UR
|
|||||||
5. 远端和磁盘字节都必须复核 SHA-256,并通过图片完整解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。
|
5. 远端和磁盘字节都必须复核 SHA-256,并通过图片完整解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。
|
||||||
6. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。
|
6. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。
|
||||||
7. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。
|
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)
|
## 2. 标准软件包协议 v1(ZIP)
|
||||||
|
|
||||||
@@ -279,8 +281,10 @@ phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollba
|
|||||||
| InstallRolledBack | 切换失败恢复 backup | app_id, error_code | 状态 → rollback 完成提示 |
|
| InstallRolledBack | 切换失败恢复 backup | app_id, error_code | 状态 → rollback 完成提示 |
|
||||||
| AppStarted / AppExited | 进程启动/退出检测 | app_id, pid | 状态 → running / installed |
|
| AppStarted / AppExited | 进程启动/退出检测 | app_id, pid | 状态 → running / installed |
|
||||||
| LicenseChanged | 许可证导入/撤销 | products | 授权视图刷新 |
|
| 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 参数合约
|
## 5. CLI 参数合约
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -13,26 +13,26 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-17
|
- 日期: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 不交叉解析
|
- 技术栈:根 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/本地状态/存储、共享 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、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件/图标剪枝稳定性、详情/ApplyIcon 与平台 stub;安装恢复矩阵保持通过
|
- 测试: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/` 记录运行时生成的攻击/传输矩阵
|
- 数据:`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 闸门、打印双目标构建命令)
|
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
- 版本管理: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/` | 已有 | 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 双平台验证入口 |
|
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、可恢复下载队列与 Phase 1 安装安全原型 |
|
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
|
||||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和随 Catalog 剪枝的内存图标 |
|
| `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 已接入低成本列表、详情和随 Catalog 剪枝的内存图标 |
|
| `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` |
|
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` |
|
||||||
| `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 |
|
| `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 |
|
||||||
|
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
|
|
||||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
任务状态以 `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-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`,并拒绝过期结果回写。
|
- `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 诊断尚未编号;必须等待 T-607 完成并提交后再按顺序落成。
|
- 适配器交互契约、`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)。
|
- Layout 中不得读磁盘、访问网络、计算哈希;图标走内存 + 磁盘缓存(按 DPI)。
|
||||||
- 数百个软件项滚动不卡顿是验收标准,不是优化项。
|
- 数百个软件项滚动不卡顿是验收标准,不是优化项。
|
||||||
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
|
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
|
||||||
@@ -65,7 +65,7 @@ T-204 已落地的详情/图标约束:
|
|||||||
|
|
||||||
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
||||||
- modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。
|
- 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 的字母占位,不阻塞列表或详情。
|
- 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。
|
||||||
|
|
||||||
## 导航规则
|
## 导航规则
|
||||||
|
|||||||
+13
-5
@@ -3,12 +3,12 @@ id: T-607
|
|||||||
title: 建立图标后台结果的 UI 线程事件投递
|
title: 建立图标后台结果的 UI 线程事件投递
|
||||||
phase: 2
|
phase: 2
|
||||||
deps: [T-606]
|
deps: [T-606]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-17
|
created: 2026-07-17
|
||||||
issue: null
|
issue: null
|
||||||
context_ref: null
|
context_ref: fba672381e9be69d8423026245ddf46ee52b16f6
|
||||||
claim_branch: null
|
claim_branch: null
|
||||||
work_branch: null
|
work_branch: agent/codex/T-607
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-607.md
|
- docs/tasks/T-607.md
|
||||||
- core/application/
|
- core/application/
|
||||||
@@ -44,7 +44,7 @@ T-606 已关闭图标缓存的全局锁 convoy、远端响应无界读取和内
|
|||||||
- 提供统一构造/发布入口并校验空 ID、非法 `sha256:` 引用、非法 DPI、nil image 和 payload 类型,避免生产者各自拼装 `any`。
|
- 提供统一构造/发布入口并校验空 ID、非法 `sha256:` 引用、非法 DPI、nil image 和 payload 类型,避免生产者各自拼装 `any`。
|
||||||
2. 在 `core/catalog` 增加可注入的后台结果发布用例:
|
2. 在 `core/catalog` 增加可注入的后台结果发布用例:
|
||||||
- 接受已经完整确定的 `IconFetchRequest`、AppID/RequestID 和 application event publisher,在调用方提供的后台 context 中执行 `IconCache.Load` 与 `DecodeIcon`。
|
- 接受已经完整确定的 `IconFetchRequest`、AppID/RequestID 和 application event publisher,在调用方提供的后台 context 中执行 `IconCache.Load` 与 `DecodeIcon`。
|
||||||
- 成功发布 ready;加载、校验或解码失败发布 failed;事件发布失败显式返回/报告,不得改变 T-606 的缓存可信性语义。
|
- 成功发布 ready;加载、校验或解码失败发布 failed;调用 context 取消/超时直接结束且不发布可能过期的 failed;事件发布失败显式返回/报告,不得改变 T-606 的缓存可信性语义。
|
||||||
- 用例本身不创建无限 goroutine或无界 worker 队列;并发/生命周期由上层调度者和 context 控制。
|
- 用例本身不创建无限 goroutine或无界 worker 队列;并发/生命周期由上层调度者和 context 控制。
|
||||||
- 继续使用 T-606 的流式 Fetcher 合约,不新增图标 URL/CDN 规则。
|
- 继续使用 T-606 的流式 Fetcher 合约,不新增图标 URL/CDN 规则。
|
||||||
3. 为 modern/win7 建立相同的 application event relay:
|
3. 为 modern/win7 建立相同的 application event relay:
|
||||||
@@ -66,7 +66,7 @@ T-606 已关闭图标缓存的全局锁 convoy、远端响应无界读取和内
|
|||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- `EventIconReady`/`EventIconFailed` 是有效 application event,强类型 payload 不 import Gio,非法事件身份或 payload 被稳定拒绝。
|
- `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`。
|
- modern/win7 后台 relay 收到事件时只入队并请求 invalidate,不会直接改变 `shell.icons`;Frame/UI goroutine drain 后才创建 `paint.ImageOp`。
|
||||||
- relay 使用有界 FIFO 队列和可取消的 lossless backpressure,满队列不静默丢事件;window/runtime 退出后无 goroutine 泄漏、死锁或 busy loop。
|
- relay 使用有界 FIFO 队列和可取消的 lossless backpressure,满队列不静默丢事件;window/runtime 退出后无 goroutine 泄漏、死锁或 busy loop。
|
||||||
- 已删除 app、IconRef/DPI 已变化、RequestID 已被新请求取代的迟到 ready/failed 均被忽略;不会在 `SetItems` 剪枝后重新插入旧图标。相同 AppID 的 IconRef 变化会清除旧 ImageOp,直到新图标就绪前显示占位。
|
- 已删除 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:根据 `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:任务冻结后台 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:用两个隔离 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