Add app details and icon cache (T-204)
This commit is contained in:
@@ -23,6 +23,9 @@ type CatalogListItem struct {
|
||||
Version string
|
||||
Category string
|
||||
Tags []string
|
||||
IconRef string
|
||||
Homepage string
|
||||
Tutorial string
|
||||
Status domain.AppStatus
|
||||
Installed bool
|
||||
Installable bool
|
||||
@@ -141,6 +144,16 @@ func (model *CatalogListModel) SelectedID() string {
|
||||
return model.selectedID
|
||||
}
|
||||
|
||||
// SelectedItem returns the selected source item without changing filters.
|
||||
func (model *CatalogListModel) SelectedItem() (CatalogListItem, bool) {
|
||||
for _, item := range model.items {
|
||||
if item.ID == model.selectedID {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return CatalogListItem{}, false
|
||||
}
|
||||
|
||||
// Valid reports whether view is supported by the MVP list.
|
||||
func (view CatalogView) Valid() bool {
|
||||
return view == CatalogViewAll ||
|
||||
|
||||
@@ -64,6 +64,10 @@ func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||
if model.SelectedID() != "app-a" {
|
||||
t.Fatalf("SelectedID = %q", model.SelectedID())
|
||||
}
|
||||
selected, ok := model.SelectedItem()
|
||||
if !ok || selected.ID != "app-a" {
|
||||
t.Fatalf("SelectedItem() = %#v, %t", selected, ok)
|
||||
}
|
||||
|
||||
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
|
||||
if model.SelectedID() != "" {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxIconBytes int64 = 2 << 20
|
||||
DefaultMaxIconDimension = 2048
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIconReferenceInvalid = errors.New("icon reference is invalid")
|
||||
ErrIconDPIInvalid = errors.New("icon DPI is invalid")
|
||||
ErrIconHashMismatch = errors.New("icon SHA-256 does not match reference")
|
||||
ErrIconTooLarge = errors.New("icon exceeds resource limits")
|
||||
ErrIconImageInvalid = errors.New("icon image is invalid")
|
||||
ErrIconCacheUnsafe = errors.New("icon cache layout is unsafe")
|
||||
ErrNoValidIcon = errors.New("no valid icon available")
|
||||
)
|
||||
|
||||
// IconRequest identifies one content-addressed icon at one UI DPI.
|
||||
type IconRequest struct {
|
||||
Reference string
|
||||
DPI int
|
||||
}
|
||||
|
||||
// IconFetcher obtains icon bytes outside Gio Layout.
|
||||
type IconFetcher interface {
|
||||
FetchIcon(context.Context, IconRequest) ([]byte, error)
|
||||
}
|
||||
|
||||
// IconFetchFunc adapts a function to IconFetcher.
|
||||
type IconFetchFunc func(context.Context, IconRequest) ([]byte, error)
|
||||
|
||||
func (function IconFetchFunc) FetchIcon(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) ([]byte, error) {
|
||||
return function(ctx, request)
|
||||
}
|
||||
|
||||
// IconSource identifies the cache tier that returned bytes.
|
||||
type IconSource string
|
||||
|
||||
const (
|
||||
IconSourceMemory IconSource = "memory"
|
||||
IconSourceDisk IconSource = "disk"
|
||||
IconSourceRemote IconSource = "remote"
|
||||
)
|
||||
|
||||
// IconResult contains verified bytes and a non-fatal disk-store warning.
|
||||
type IconResult struct {
|
||||
Bytes []byte
|
||||
Source IconSource
|
||||
Warning error
|
||||
}
|
||||
|
||||
// IconLoadError preserves disk and refresh failures.
|
||||
type IconLoadError struct {
|
||||
Disk error
|
||||
Fetch error
|
||||
}
|
||||
|
||||
func (loadError *IconLoadError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"%s: disk=%v; fetch=%v",
|
||||
ErrNoValidIcon,
|
||||
loadError.Disk,
|
||||
loadError.Fetch,
|
||||
)
|
||||
}
|
||||
|
||||
func (loadError *IconLoadError) Unwrap() []error {
|
||||
return []error{ErrNoValidIcon, loadError.Disk, loadError.Fetch}
|
||||
}
|
||||
|
||||
// IconCache is a content-verified memory + disk cache keyed by digest and DPI.
|
||||
type IconCache struct {
|
||||
root string
|
||||
fetcher IconFetcher
|
||||
maxBytes int64
|
||||
maxDimension int
|
||||
mu sync.Mutex
|
||||
memory map[string][]byte
|
||||
}
|
||||
|
||||
// NewIconCache creates a cache with safe default resource limits.
|
||||
func NewIconCache(root string, fetcher IconFetcher) *IconCache {
|
||||
return &IconCache{
|
||||
root: root,
|
||||
fetcher: fetcher,
|
||||
maxBytes: DefaultMaxIconBytes,
|
||||
maxDimension: DefaultMaxIconDimension,
|
||||
memory: make(map[string][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// Load resolves memory, verified disk, then verified remote bytes.
|
||||
func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
|
||||
digest, key, err := cacheKey(request)
|
||||
if err != nil {
|
||||
return IconResult{}, err
|
||||
}
|
||||
if document, exists := cache.memory[key]; exists {
|
||||
return IconResult{
|
||||
Bytes: append([]byte(nil), document...),
|
||||
Source: IconSourceMemory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
filePath, pathErr := cache.filePath(digest, request.DPI)
|
||||
if pathErr != nil {
|
||||
return IconResult{}, pathErr
|
||||
}
|
||||
document, diskErr := cache.loadDisk(filePath, digest)
|
||||
if diskErr == nil {
|
||||
cache.memory[key] = append([]byte(nil), document...)
|
||||
return IconResult{
|
||||
Bytes: append([]byte(nil), document...),
|
||||
Source: IconSourceDisk,
|
||||
}, nil
|
||||
}
|
||||
if !os.IsNotExist(diskErr) {
|
||||
if errors.Is(diskErr, ErrIconCacheUnsafe) {
|
||||
return IconResult{}, diskErr
|
||||
}
|
||||
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
return IconResult{}, fmt.Errorf(
|
||||
"%w: remove invalid cache entry: %v",
|
||||
ErrIconCacheUnsafe,
|
||||
removeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if cache.fetcher == nil {
|
||||
return IconResult{}, &IconLoadError{
|
||||
Disk: diskErr,
|
||||
Fetch: errors.New("icon fetcher is not configured"),
|
||||
}
|
||||
}
|
||||
document, fetchErr := cache.fetcher.FetchIcon(ctx, request)
|
||||
if fetchErr != nil {
|
||||
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr}
|
||||
}
|
||||
if err := cache.validate(document, digest); err != nil {
|
||||
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: err}
|
||||
}
|
||||
|
||||
storeErr := cache.storeDisk(filePath, document)
|
||||
cache.memory[key] = append([]byte(nil), document...)
|
||||
return IconResult{
|
||||
Bytes: append([]byte(nil), document...),
|
||||
Source: IconSourceRemote,
|
||||
Warning: storeErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeIcon decodes already verified bytes outside Layout for ApplyIcon.
|
||||
func DecodeIcon(document []byte) (image.Image, error) {
|
||||
decoded, _, err := image.Decode(bytes.NewReader(document))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrIconImageInvalid, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) validate(document []byte, digest string) error {
|
||||
maxBytes := cache.maxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxIconBytes
|
||||
}
|
||||
if int64(len(document)) > maxBytes {
|
||||
return fmt.Errorf(
|
||||
"%w: bytes=%d limit=%d",
|
||||
ErrIconTooLarge,
|
||||
len(document),
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
actual := sha256.Sum256(document)
|
||||
if hex.EncodeToString(actual[:]) != digest {
|
||||
return ErrIconHashMismatch
|
||||
}
|
||||
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(document))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: decode config: %v", ErrIconImageInvalid, err)
|
||||
}
|
||||
maxDimension := cache.maxDimension
|
||||
if maxDimension <= 0 {
|
||||
maxDimension = DefaultMaxIconDimension
|
||||
}
|
||||
if config.Width <= 0 ||
|
||||
config.Height <= 0 ||
|
||||
config.Width > maxDimension ||
|
||||
config.Height > maxDimension {
|
||||
return fmt.Errorf(
|
||||
"%w: dimensions=%dx%d limit=%d",
|
||||
ErrIconTooLarge,
|
||||
config.Width,
|
||||
config.Height,
|
||||
maxDimension,
|
||||
)
|
||||
}
|
||||
if _, _, err := image.Decode(bytes.NewReader(document)); err != nil {
|
||||
return fmt.Errorf("%w: decode: %v", ErrIconImageInvalid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) filePath(digest string, dpi int) (string, error) {
|
||||
if cache.root == "" {
|
||||
return "", fmt.Errorf("%w: empty root", ErrIconCacheUnsafe)
|
||||
}
|
||||
absoluteRoot, err := filepath.Abs(cache.root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrIconCacheUnsafe, err)
|
||||
}
|
||||
return filepath.Join(
|
||||
absoluteRoot,
|
||||
digest+"-"+strconv.Itoa(dpi)+".icon",
|
||||
), nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) loadDisk(filePath, digest string) ([]byte, error) {
|
||||
info, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%w: cache entry is not a regular file", ErrIconCacheUnsafe)
|
||||
}
|
||||
document, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read icon cache: %w", err)
|
||||
}
|
||||
if err := cache.validate(document, digest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) storeDisk(filePath string, document []byte) error {
|
||||
directory := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return fmt.Errorf("create icon cache directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect icon cache directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%w: root is not a real directory", ErrIconCacheUnsafe)
|
||||
}
|
||||
|
||||
temporary, err := os.CreateTemp(directory, ".icon-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create icon cache temp file: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("protect icon cache temp file: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(document); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("write icon cache temp file: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("sync icon cache temp file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close icon cache temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, filePath); err != nil {
|
||||
return fmt.Errorf("activate icon cache entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheKey(request IconRequest) (digest string, key string, err error) {
|
||||
const prefix = "sha256:"
|
||||
if !strings.HasPrefix(request.Reference, prefix) {
|
||||
return "", "", ErrIconReferenceInvalid
|
||||
}
|
||||
digest = strings.ToLower(strings.TrimPrefix(request.Reference, prefix))
|
||||
decoded, decodeErr := hex.DecodeString(digest)
|
||||
if decodeErr != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
|
||||
return "", "", ErrIconReferenceInvalid
|
||||
}
|
||||
if request.DPI < 48 || request.DPI > 768 {
|
||||
return "", "", ErrIconDPIInvalid
|
||||
}
|
||||
return digest, digest + "@" + strconv.Itoa(request.DPI), nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIconCacheUsesMemoryAndOfflineDisk(t *testing.T) {
|
||||
document := testPNG(t, 16, 16)
|
||||
request := iconRequest(document, 96)
|
||||
fetchCalls := 0
|
||||
root := t.TempDir()
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
fetchCalls++
|
||||
return document, nil
|
||||
}))
|
||||
|
||||
result, err := cache.Load(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(remote) error = %v", err)
|
||||
}
|
||||
if result.Source != IconSourceRemote || fetchCalls != 1 {
|
||||
t.Fatalf("remote result = %#v, fetchCalls=%d", result, fetchCalls)
|
||||
}
|
||||
result, err = cache.Load(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(memory) error = %v", err)
|
||||
}
|
||||
if result.Source != IconSourceMemory || fetchCalls != 1 {
|
||||
t.Fatalf("memory result = %#v, fetchCalls=%d", result, fetchCalls)
|
||||
}
|
||||
|
||||
offline := errors.New("offline")
|
||||
restarted := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
return nil, offline
|
||||
}))
|
||||
result, err = restarted.Load(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(disk) error = %v", err)
|
||||
}
|
||||
if result.Source != IconSourceDisk {
|
||||
t.Fatalf("disk source = %q, want %q", result.Source, IconSourceDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheSeparatesDPIKeys(t *testing.T) {
|
||||
document := testPNG(t, 16, 16)
|
||||
fetchCalls := 0
|
||||
root := t.TempDir()
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
fetchCalls++
|
||||
return document, nil
|
||||
}))
|
||||
|
||||
for _, dpi := range []int{96, 144} {
|
||||
if _, err := cache.Load(context.Background(), iconRequest(document, dpi)); err != nil {
|
||||
t.Fatalf("Load(%d DPI) error = %v", dpi, err)
|
||||
}
|
||||
}
|
||||
if fetchCalls != 2 {
|
||||
t.Fatalf("fetchCalls = %d, want 2", fetchCalls)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir() error = %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("disk entries = %d, want 2", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheRejectsUntrustedImages(t *testing.T) {
|
||||
validDocument := testPNG(t, 16, 16)
|
||||
tests := []struct {
|
||||
name string
|
||||
request IconRequest
|
||||
document []byte
|
||||
wantErr error
|
||||
configure func(*IconCache)
|
||||
}{
|
||||
{
|
||||
name: "hash mismatch",
|
||||
request: iconRequest([]byte("different"), 96),
|
||||
document: validDocument,
|
||||
wantErr: ErrIconHashMismatch,
|
||||
},
|
||||
{
|
||||
name: "invalid image",
|
||||
request: iconRequest([]byte("not an image"), 96),
|
||||
document: []byte("not an image"),
|
||||
wantErr: ErrIconImageInvalid,
|
||||
},
|
||||
{
|
||||
name: "byte limit",
|
||||
request: iconRequest(validDocument, 96),
|
||||
document: validDocument,
|
||||
wantErr: ErrIconTooLarge,
|
||||
configure: func(cache *IconCache) {
|
||||
cache.maxBytes = int64(len(validDocument) - 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dimension limit",
|
||||
request: iconRequest(validDocument, 96),
|
||||
document: validDocument,
|
||||
wantErr: ErrIconTooLarge,
|
||||
configure: func(cache *IconCache) {
|
||||
cache.maxDimension = 8
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
return test.document, nil
|
||||
}))
|
||||
if test.configure != nil {
|
||||
test.configure(cache)
|
||||
}
|
||||
_, err := cache.Load(context.Background(), test.request)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
entries, readErr := os.ReadDir(root)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadDir() error = %v", readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("invalid icon wrote %d disk entries", len(entries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) {
|
||||
document := testPNG(t, 16, 16)
|
||||
request := iconRequest(document, 120)
|
||||
root := t.TempDir()
|
||||
online := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
return document, nil
|
||||
}))
|
||||
if _, err := online.Load(context.Background(), request); err != nil {
|
||||
t.Fatalf("Load(seed) error = %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("ReadDir() = %v, %v", entries, err)
|
||||
}
|
||||
filePath := filepath.Join(root, entries[0].Name())
|
||||
if err := os.WriteFile(filePath, []byte("corrupt"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(corrupt) error = %v", err)
|
||||
}
|
||||
|
||||
repairs := 0
|
||||
repairing := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
repairs++
|
||||
return document, nil
|
||||
}))
|
||||
result, err := repairing.Load(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(repair) error = %v", err)
|
||||
}
|
||||
if result.Source != IconSourceRemote || repairs != 1 {
|
||||
t.Fatalf("repair result = %#v, repairs=%d", result, repairs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, []byte("corrupt again"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(corrupt again) error = %v", err)
|
||||
}
|
||||
offline := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) ([]byte, error) {
|
||||
return nil, errors.New("offline")
|
||||
}))
|
||||
_, err = offline.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrNoValidIcon) {
|
||||
t.Fatalf("Load(offline corrupt) error = %v, want %v", err, ErrNoValidIcon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIcon(t *testing.T) {
|
||||
document := testPNG(t, 8, 8)
|
||||
decoded, err := DecodeIcon(document)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeIcon() error = %v", err)
|
||||
}
|
||||
if decoded.Bounds().Dx() != 8 || decoded.Bounds().Dy() != 8 {
|
||||
t.Fatalf("Bounds = %v", decoded.Bounds())
|
||||
}
|
||||
}
|
||||
|
||||
func iconRequest(document []byte, dpi int) IconRequest {
|
||||
digest := sha256.Sum256(document)
|
||||
return IconRequest{
|
||||
Reference: "sha256:" + hex.EncodeToString(digest[:]),
|
||||
DPI: dpi,
|
||||
}
|
||||
}
|
||||
|
||||
func testPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
source := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8(x),
|
||||
G: uint8(y),
|
||||
B: 120,
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, source); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
Reference in New Issue
Block a user