Files
soft_quay/core/application/icon_event.go
T

271 lines
6.1 KiB
Go
Raw Normal View History

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
}
}