package application import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "image" "reflect" "strings" ) const ( MinIconDPI = 48 MaxIconDPI = 768 ) var ErrInvalidIconEvent = errors.New("invalid icon application event") // IconFailureCode is a stable, non-sensitive reason exposed to UI adapters. type IconFailureCode string const ( IconFailureUnavailable IconFailureCode = "unavailable" IconFailureInvalid IconFailureCode = "invalid_content" IconFailureUnsafe IconFailureCode = "unsafe_cache" ) // Valid reports whether code is part of the documented icon event contract. func (code IconFailureCode) Valid() bool { switch code { case IconFailureUnavailable, IconFailureInvalid, IconFailureUnsafe: return true default: return false } } // IconEventIdentity correlates one background request with one catalog icon. type IconEventIdentity struct { RequestID string AppID string Reference string DPI int } // NewIconEventIdentity validates and canonicalizes an icon event identity. func NewIconEventIdentity( requestID string, appID string, reference string, dpi int, ) (IconEventIdentity, error) { if strings.TrimSpace(requestID) == "" { return IconEventIdentity{}, fmt.Errorf( "%w: empty request ID", ErrInvalidIconEvent, ) } if strings.TrimSpace(appID) == "" { return IconEventIdentity{}, fmt.Errorf( "%w: empty app ID", ErrInvalidIconEvent, ) } canonicalReference, err := NormalizeIconReference(reference) if err != nil { return IconEventIdentity{}, err } if dpi < MinIconDPI || dpi > MaxIconDPI { return IconEventIdentity{}, fmt.Errorf( "%w: DPI %d outside %d..%d", ErrInvalidIconEvent, dpi, MinIconDPI, MaxIconDPI, ) } return IconEventIdentity{ RequestID: requestID, AppID: appID, Reference: canonicalReference, DPI: dpi, }, nil } // NormalizeIconReference returns the canonical sha256: form. func NormalizeIconReference(reference string) (string, error) { const prefix = "sha256:" if !strings.HasPrefix(reference, prefix) { return "", fmt.Errorf( "%w: icon reference must use sha256", ErrInvalidIconEvent, ) } digest := strings.ToLower(strings.TrimPrefix(reference, prefix)) decoded, err := hex.DecodeString(digest) if err != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 { return "", fmt.Errorf( "%w: malformed icon digest", ErrInvalidIconEvent, ) } return prefix + digest, nil } // IconReadyPayload contains an image decoded outside the Gio UI goroutine. type IconReadyPayload struct { Reference string DPI int Image image.Image } // IconFailedPayload contains a stable failure classification, never a raw URL. type IconFailedPayload struct { Reference string DPI int ErrorCode IconFailureCode } // IconEvent is the validated representation consumed by UI adapters. type IconEvent struct { Type EventType Identity IconEventIdentity Image image.Image ErrorCode IconFailureCode } // NewIconReadyEvent builds a validated ready event. func NewIconReadyEvent( identity IconEventIdentity, icon image.Image, ) (Event, error) { validated, err := NewIconEventIdentity( identity.RequestID, identity.AppID, identity.Reference, identity.DPI, ) if err != nil { return Event{}, err } if isNilImage(icon) { return Event{}, fmt.Errorf("%w: nil ready image", ErrInvalidIconEvent) } return Event{ Type: EventIconReady, RequestID: validated.RequestID, AppID: validated.AppID, Payload: IconReadyPayload{ Reference: validated.Reference, DPI: validated.DPI, Image: icon, }, }, nil } // NewIconFailedEvent builds a validated failure event. func NewIconFailedEvent( identity IconEventIdentity, code IconFailureCode, ) (Event, error) { validated, err := NewIconEventIdentity( identity.RequestID, identity.AppID, identity.Reference, identity.DPI, ) if err != nil { return Event{}, err } if !code.Valid() { return Event{}, fmt.Errorf( "%w: unknown failure code %q", ErrInvalidIconEvent, code, ) } return Event{ Type: EventIconFailed, RequestID: validated.RequestID, AppID: validated.AppID, Payload: IconFailedPayload{ Reference: validated.Reference, DPI: validated.DPI, ErrorCode: code, }, }, nil } // ParseIconEvent validates an icon envelope. Non-icon events return handled=false. func ParseIconEvent(event Event) (parsed IconEvent, handled bool, err error) { switch event.Type { case EventIconReady: payload, ok := event.Payload.(IconReadyPayload) if !ok { return IconEvent{}, true, fmt.Errorf( "%w: ready payload has type %T", ErrInvalidIconEvent, event.Payload, ) } identity, identityErr := NewIconEventIdentity( event.RequestID, event.AppID, payload.Reference, payload.DPI, ) if identityErr != nil { return IconEvent{}, true, identityErr } if isNilImage(payload.Image) { return IconEvent{}, true, fmt.Errorf( "%w: nil ready image", ErrInvalidIconEvent, ) } return IconEvent{ Type: event.Type, Identity: identity, Image: payload.Image, }, true, nil case EventIconFailed: payload, ok := event.Payload.(IconFailedPayload) if !ok { return IconEvent{}, true, fmt.Errorf( "%w: failed payload has type %T", ErrInvalidIconEvent, event.Payload, ) } identity, identityErr := NewIconEventIdentity( event.RequestID, event.AppID, payload.Reference, payload.DPI, ) if identityErr != nil { return IconEvent{}, true, identityErr } if !payload.ErrorCode.Valid() { return IconEvent{}, true, fmt.Errorf( "%w: unknown failure code %q", ErrInvalidIconEvent, payload.ErrorCode, ) } return IconEvent{ Type: event.Type, Identity: identity, ErrorCode: payload.ErrorCode, }, true, nil default: return IconEvent{}, false, nil } } func isNilImage(icon image.Image) bool { if icon == nil { return true } value := reflect.ValueOf(icon) switch value.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: return value.IsNil() default: return false } }