271 lines
6.1 KiB
Go
271 lines
6.1 KiB
Go
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
|
||
|
|
}
|
||
|
|
}
|