Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72e7b04dc | ||
|
|
0c1b7662c6 | ||
|
|
9e5f3f4840 | ||
|
|
20596a7de4 | ||
|
|
84befee70f | ||
|
|
0f69fa330e | ||
|
|
65ff7a3f23 | ||
|
|
320b83d929 | ||
|
|
6455fec811 | ||
|
|
e9386d26e7 | ||
|
|
75b1803564 | ||
|
|
171572973b | ||
|
|
ed9ded2110 | ||
|
|
1b7f72e658 | ||
|
|
0705948d74 | ||
|
|
0945fe93dc | ||
|
|
fba672381e | ||
|
|
f1cc7308db | ||
|
|
8873a5261d | ||
|
|
6fd19d0f43 | ||
|
|
f7a803d944 | ||
|
|
7efcab5dfe | ||
|
|
e4a9295cbf | ||
|
|
a036ce9a8e | ||
|
|
90dc09d13d | ||
|
|
8dad40f934 | ||
|
|
2d302b731a | ||
|
|
a52acbf926 | ||
|
|
db8d93e843 | ||
|
|
819b1cdf88 | ||
|
|
2e21c9f327 | ||
|
|
9da72caa01 | ||
|
|
0ffeff63e1 | ||
|
|
0d6ed05d7e | ||
|
|
45c242ecec | ||
|
|
6f920eb457 | ||
|
|
0e20dd76b2 | ||
|
|
cf9fc01c68 |
@@ -0,0 +1,23 @@
|
||||
name: Phase 0 build gate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.0"
|
||||
cache: false
|
||||
- name: Run Phase 0 verification
|
||||
run: bash scripts/verify_phase0.sh
|
||||
@@ -30,3 +30,6 @@ gitea.env.*
|
||||
# Python 本地校验缓存
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# 本地编辑器 workspace 配置(个人,不入库)
|
||||
*.code-workspace
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
||||
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
||||
|
||||
## Agent 执行模式
|
||||
|
||||
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
|
||||
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
|
||||
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
|
||||
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
|
||||
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
|
||||
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
||||
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
||||
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
||||
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
|
||||
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
||||
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
|
||||
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/app-modern/platform/windows"
|
||||
softboxgio "softbox.local/app-modern/ui/gio"
|
||||
"softbox.local/core"
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
const applicationEventCapacity = 32
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("%s stopped: %v", core.ProductName, err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
app.Title(core.ProductName),
|
||||
app.Size(unit.Dp(1080), unit.Dp(720)),
|
||||
)
|
||||
|
||||
theme := softboxgio.NewTheme()
|
||||
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
|
||||
|
||||
for {
|
||||
switch event := window.Event().(type) {
|
||||
case app.DestroyEvent:
|
||||
return event.Err
|
||||
case app.FrameEvent:
|
||||
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||
log.Printf("apply application event: %v", err)
|
||||
}
|
||||
context := app.NewContext(&operations, event)
|
||||
shell.Layout(context, theme)
|
||||
event.Frame(context.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
module softbox.local/app-modern
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
gioui.org v0.10.1
|
||||
softbox.local/core v0.0.0
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package windows provides modern Windows platform adapters and non-Windows
|
||||
// stubs for package-level tests.
|
||||
package windows
|
||||
@@ -0,0 +1,17 @@
|
||||
package windows
|
||||
|
||||
// Edition identifies the application build channel shown by the UI.
|
||||
type Edition string
|
||||
|
||||
const EditionModern Edition = "Modern"
|
||||
|
||||
// Platform is the minimal boundary for target-specific capabilities.
|
||||
type Platform interface {
|
||||
OS() string
|
||||
Edition() Edition
|
||||
}
|
||||
|
||||
// New returns the platform implementation selected by build tags.
|
||||
func New() Platform {
|
||||
return newPlatform()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import "runtime"
|
||||
|
||||
type platformStub struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platformStub{}
|
||||
}
|
||||
|
||||
func (platformStub) OS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (platformStub) Edition() Edition {
|
||||
return EditionModern
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package windows
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlatformStubContract(t *testing.T) {
|
||||
platform := New()
|
||||
if platform.OS() == "" {
|
||||
t.Fatal("OS() should not be empty")
|
||||
}
|
||||
if platform.Edition() != EditionModern {
|
||||
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
type platform struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platform{}
|
||||
}
|
||||
|
||||
func (platform) OS() string {
|
||||
return "windows"
|
||||
}
|
||||
|
||||
func (platform) Edition() Edition {
|
||||
return EditionModern
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
adapterContractViewport = image.Pt(1080, 720)
|
||||
adapterContractCompactViewport = image.Pt(1080, 420)
|
||||
)
|
||||
|
||||
const adapterContractEdition = "Modern"
|
||||
|
||||
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
|
||||
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
|
||||
shell.search.SetText("APP-TWO")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.Query(); got != "app-two" {
|
||||
t.Fatalf("query after editor update = %q, want app-two", got)
|
||||
}
|
||||
visible := shell.model.VisibleItems()
|
||||
if len(visible) != 1 || visible[0].ID != "app-two" {
|
||||
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
|
||||
}
|
||||
|
||||
shell.search.SetText("")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.Category(); got != "图像" {
|
||||
t.Fatalf("category after click = %q, want 图像", got)
|
||||
}
|
||||
|
||||
shell.viewUpdates.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewUpdates {
|
||||
t.Fatalf("view after updates click = %q", got)
|
||||
}
|
||||
shell.viewInstalled.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewInstalled {
|
||||
t.Fatalf("view after installed click = %q", got)
|
||||
}
|
||||
shell.viewAll.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewAll {
|
||||
t.Fatalf("view after all click = %q", got)
|
||||
}
|
||||
|
||||
shell.search.SetText("missing-app")
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
|
||||
!adapterContractHasSemantic(nodes, "显示全部软件") {
|
||||
t.Fatal("filtered empty state did not expose its recovery action")
|
||||
}
|
||||
shell.resetFilters.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if shell.search.Text() != "" || shell.model.Query() != "" ||
|
||||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
|
||||
t.Fatalf(
|
||||
"reset state = editor %q, query %q, category %q, view %q",
|
||||
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
|
||||
)
|
||||
}
|
||||
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
|
||||
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
|
||||
items := adapterContractLargeCatalog(80)
|
||||
shell := NewAppShell(adapterContractEdition, items...)
|
||||
targetID := "app-037"
|
||||
targetControl := shell.rows[targetID]
|
||||
|
||||
reordered := append([]application.CatalogListItem(nil), items...)
|
||||
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
|
||||
reordered[left], reordered[right] = reordered[right], reordered[left]
|
||||
}
|
||||
shell.SetItems(reordered)
|
||||
if shell.rows[targetID] != targetControl {
|
||||
t.Fatal("row control was recreated after catalog reorder")
|
||||
}
|
||||
|
||||
shell.search.SetText("app-")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.viewInstalled.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.appList.ScrollTo(12)
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
|
||||
targetControl.open.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.SelectedID(); got != targetID {
|
||||
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
|
||||
}
|
||||
if !shell.detailRendered {
|
||||
t.Fatal("row click selected the model but did not render detail")
|
||||
}
|
||||
queryBefore := shell.model.Query()
|
||||
categoryBefore := shell.model.Category()
|
||||
viewBefore := shell.model.View()
|
||||
positionBefore := shell.appList.Position
|
||||
|
||||
shell.closeDetail.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.SelectedID(); got != "" {
|
||||
t.Fatalf("selected ID after close = %q, want empty", got)
|
||||
}
|
||||
if shell.detailRendered {
|
||||
t.Fatal("detail remained rendered after close click")
|
||||
}
|
||||
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
|
||||
shell.model.View() != viewBefore {
|
||||
t.Fatal("closing detail changed the active list filters")
|
||||
}
|
||||
positionAfter := shell.appList.Position
|
||||
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
|
||||
t.Fatalf(
|
||||
"list position after close = first %d offset %d, want first %d offset %d",
|
||||
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||
items := adapterContractLargeCatalog(500)
|
||||
shell := NewAppShell(adapterContractEdition, items...)
|
||||
retainedRow := shell.rows["app-001"]
|
||||
retainedCategory := shell.categoryControls["图像"]
|
||||
|
||||
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
|
||||
}
|
||||
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
|
||||
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
|
||||
}
|
||||
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
|
||||
t.Fatal("filtering recreated stable app or category controls")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
|
||||
if shell.rows["app-001"] != retainedRow {
|
||||
t.Fatal("retained app lost its row control after snapshot update")
|
||||
}
|
||||
if shell.categoryControls["图像"] != retainedCategory {
|
||||
t.Fatal("retained category lost its control after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-000"]; exists {
|
||||
t.Fatal("removed app retained its row control")
|
||||
}
|
||||
if _, exists := shell.categoryControls["工具"]; exists {
|
||||
t.Fatal("removed category retained its control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||
emptyShell := NewAppShell(adapterContractEdition)
|
||||
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
|
||||
t.Fatal("empty catalog did not render the catalog-unavailable state")
|
||||
}
|
||||
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||
t.Fatal("empty catalog rendered a filter recovery action")
|
||||
}
|
||||
|
||||
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
filteredShell.search.SetText("missing-app")
|
||||
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
|
||||
t.Fatal("filtered catalog did not render the no-matches state")
|
||||
}
|
||||
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
|
||||
t.Fatal("filtered catalog did not render its recovery action")
|
||||
}
|
||||
filteredShell.resetFilters.Click()
|
||||
adapterContractLayout(filteredShell, adapterContractViewport)
|
||||
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
|
||||
t.Fatal("filter recovery did not clear editor and model query")
|
||||
}
|
||||
if len(filteredShell.model.VisibleItems()) == 0 {
|
||||
t.Fatal("filter recovery did not restore catalog rows")
|
||||
}
|
||||
}
|
||||
|
||||
func adapterContractItems() []application.CatalogListItem {
|
||||
return []application.CatalogListItem{
|
||||
{
|
||||
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
|
||||
Status: domain.StatusNotInstalled, Installable: true,
|
||||
},
|
||||
{
|
||||
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
|
||||
Status: domain.StatusInstalled, Installed: true, Installable: true,
|
||||
},
|
||||
{
|
||||
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
|
||||
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
|
||||
items := make([]application.CatalogListItem, count)
|
||||
for index := range items {
|
||||
category := "工具"
|
||||
if index%2 == 1 {
|
||||
category = "图像"
|
||||
}
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("App %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: category,
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
|
||||
var operations op.Ops
|
||||
var router input.Router
|
||||
context := layout.Context{
|
||||
Ops: &operations,
|
||||
Source: router.Source(),
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
shell.Layout(context, NewTheme())
|
||||
router.Frame(&operations)
|
||||
return router.AppendSemantics(nil)
|
||||
}
|
||||
|
||||
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
|
||||
for _, node := range nodes {
|
||||
if node.Desc.Label == want || node.Desc.Description == want ||
|
||||
adapterContractHasSemantic(node.Children, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func adapterContractIDs(items []application.CatalogListItem) []string {
|
||||
ids := make([]string, len(items))
|
||||
for index, item := range items {
|
||||
ids[index] = item.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package gio contains the modern Gio UI adapter.
|
||||
//
|
||||
// Layout code is rendering-only: it must not read files, access the network,
|
||||
// calculate hashes, or directly mutate background application state.
|
||||
package gio
|
||||
@@ -0,0 +1,111 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
|
||||
|
||||
type iconFailureState struct {
|
||||
Identity application.IconEventIdentity
|
||||
Code application.IconFailureCode
|
||||
}
|
||||
|
||||
// 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)
|
||||
delete(shell.iconFailures, 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] = iconFailureState{
|
||||
Identity: identity,
|
||||
Code: 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.Code, 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,422 @@
|
||||
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 TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
|
||||
reference := testIconReference("55")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
|
||||
shell.model.Select(item.ID)
|
||||
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
failure := shell.iconFailures[item.ID]
|
||||
for _, want := range []string{
|
||||
"图标缓存安全警告",
|
||||
unsafeIconCacheMessage,
|
||||
unsafeIconCacheDiagnostic(failure),
|
||||
} {
|
||||
if !adapterContractHasSemantic(nodes, want) {
|
||||
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
|
||||
}
|
||||
}
|
||||
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
|
||||
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
|
||||
}
|
||||
|
||||
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(unavailable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
|
||||
nodes = adapterContractLayout(shell, adapterContractViewport)
|
||||
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
|
||||
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
|
||||
}
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnavailable {
|
||||
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
|
||||
reference := testIconReference("66")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstFailure := applyTestIconFailure(
|
||||
t,
|
||||
shell,
|
||||
first,
|
||||
application.IconFailureUnsafe,
|
||||
)
|
||||
if got := shell.iconFailures[item.ID].Identity; got != first {
|
||||
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{item})
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnsafe {
|
||||
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
|
||||
}
|
||||
|
||||
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("new request retained the previous unsafe diagnostic")
|
||||
}
|
||||
if err := shell.ApplyEvent(firstFailure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("late failure restored a stale diagnostic")
|
||||
}
|
||||
ready, err := application.NewIconReadyEvent(
|
||||
latest,
|
||||
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(ready); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("ready event retained an unsafe diagnostic")
|
||||
}
|
||||
|
||||
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(dpiRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
|
||||
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
|
||||
t.Fatalf("stored failure DPI = %d, want 144", got)
|
||||
}
|
||||
|
||||
newReference := testIconReference("77")
|
||||
changed := item
|
||||
changed.IconRef = newReference
|
||||
shell.SetItems([]application.CatalogListItem{changed})
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("IconRef change retained the unsafe diagnostic")
|
||||
}
|
||||
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(canceled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
|
||||
t.Fatal("CancelIconRequest() did not cancel the current request")
|
||||
}
|
||||
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("canceled failure created an unsafe diagnostic")
|
||||
}
|
||||
|
||||
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(final); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
|
||||
shell.SetItems(nil)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("removed app retained the unsafe diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func applyTestIconFailure(
|
||||
t *testing.T,
|
||||
shell *AppShell,
|
||||
identity application.IconEventIdentity,
|
||||
code application.IconFailureCode,
|
||||
) application.Event {
|
||||
t.Helper()
|
||||
event, err := application.NewIconFailedEvent(identity, code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
// AppShell is the modern software catalog window.
|
||||
type AppShell struct {
|
||||
edition string
|
||||
model *application.CatalogListModel
|
||||
|
||||
search widget.Editor
|
||||
appList layout.List
|
||||
categoryList layout.List
|
||||
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
closeDetail widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
func NewAppShell(
|
||||
edition string,
|
||||
items ...application.CatalogListItem,
|
||||
) *AppShell {
|
||||
shell := &AppShell{
|
||||
edition: edition,
|
||||
model: application.NewCatalogListModel(nil),
|
||||
appList: layout.List{Axis: layout.Vertical},
|
||||
categoryList: layout.List{Axis: layout.Horizontal},
|
||||
categoryControls: make(map[string]*widget.Clickable),
|
||||
rows: make(map[string]*rowControls),
|
||||
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]iconFailureState),
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
return shell
|
||||
}
|
||||
|
||||
// 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) {
|
||||
delete(shell.iconApplied, appID)
|
||||
delete(shell.iconFailures, appID)
|
||||
if icon == nil {
|
||||
delete(shell.icons, appID)
|
||||
return
|
||||
}
|
||||
shell.icons[appID] = paint.NewImageOp(icon)
|
||||
}
|
||||
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
|
||||
nextRows := make(map[string]*rowControls, 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]iconFailureState, len(items))
|
||||
for _, item := range items {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
controls = new(rowControls)
|
||||
}
|
||||
nextRows[item.ID] = controls
|
||||
reference := canonicalIconReference(item.IconRef)
|
||||
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 &&
|
||||
failure.Identity.Reference == reference {
|
||||
nextFailures[item.ID] = failure
|
||||
}
|
||||
}
|
||||
}
|
||||
shell.rows = nextRows
|
||||
shell.icons = nextIcons
|
||||
shell.iconReferences = nextReferences
|
||||
shell.iconRequests = nextRequests
|
||||
shell.iconApplied = nextApplied
|
||||
shell.iconFailures = nextFailures
|
||||
|
||||
nextCategories := make(map[string]*widget.Clickable)
|
||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||
control := shell.categoryControls[category]
|
||||
if control == nil {
|
||||
control = new(widget.Clickable)
|
||||
}
|
||||
nextCategories[category] = control
|
||||
}
|
||||
shell.categoryControls = nextCategories
|
||||
}
|
||||
|
||||
// Layout drains input first and performs no disk, network or hash IO.
|
||||
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
shell.drainInput(gtx)
|
||||
shell.lastRendered = 0
|
||||
shell.detailRendered = false
|
||||
paint.Fill(gtx.Ops, shellColors.background)
|
||||
|
||||
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutHeader(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutContent(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFooter(gtx, theme)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
for {
|
||||
if _, ok := shell.search.Update(gtx); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
}
|
||||
}
|
||||
for appID, controls := range shell.rows {
|
||||
for controls.open.Clicked(gtx) {
|
||||
shell.model.Select(appID)
|
||||
}
|
||||
}
|
||||
for shell.resetFilters.Clicked(gtx) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
for shell.closeDetail.Clicked(gtx) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
type rowControls struct {
|
||||
open widget.Clickable
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutContent(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(168))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "软件视图")
|
||||
label.Color = shellColors.secondary
|
||||
return layout.Inset{
|
||||
Left: unit.Dp(8), Bottom: unit.Dp(8),
|
||||
}.Layout(gtx, label.Layout)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewAll,
|
||||
"全部软件",
|
||||
application.CatalogViewAll,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewInstalled,
|
||||
"已安装",
|
||||
application.CatalogViewInstalled,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewUpdates,
|
||||
"可更新",
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutCatalog(gtx, theme)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !hasSelection {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !hasSelection {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
width := gtx.Dp(unit.Dp(320))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return shell.layoutDetail(gtx, theme, selected)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutCatalog(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
visible := shell.model.VisibleItems()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(
|
||||
theme,
|
||||
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(visible) == 0 {
|
||||
return shell.layoutEmptyState(gtx, theme)
|
||||
}
|
||||
return shell.appList.Layout(gtx, len(visible), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
shell.lastRendered++
|
||||
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppRow(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
|
||||
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp(fmt.Sprintf(
|
||||
"%s,版本 %s,状态 %s",
|
||||
item.Name,
|
||||
item.Version,
|
||||
statusLabel(item.Status),
|
||||
)).Add(gtx.Ops)
|
||||
|
||||
background := shellColors.muted
|
||||
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||
}
|
||||
if shell.model.SelectedID() == item.ID {
|
||||
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
background,
|
||||
unit.Dp(8),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutAppIcon(
|
||||
gtx,
|
||||
theme,
|
||||
item.ID,
|
||||
item.Name,
|
||||
unit.Dp(48),
|
||||
unit.Dp(8),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, item.Name).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(
|
||||
theme,
|
||||
fmt.Sprintf(
|
||||
"%s · %s · %s",
|
||||
item.ID,
|
||||
item.Version,
|
||||
item.Category,
|
||||
),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, statusLabel(item.Status))
|
||||
label.Color = statusColor(item.Status)
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, actionLabel(item))
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppIcon(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
appID string,
|
||||
name string,
|
||||
iconSize unit.Dp,
|
||||
radius unit.Dp,
|
||||
) layout.Dimensions {
|
||||
size := gtx.Dp(iconSize)
|
||||
gtx.Constraints.Min = image.Pt(size, size)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
if icon, exists := shell.icons[appID]; exists {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(2)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return widget.Image{
|
||||
Src: icon,
|
||||
Fit: widget.Contain,
|
||||
Position: layout.Center,
|
||||
}.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}
|
||||
letter := "S"
|
||||
for _, character := range name {
|
||||
letter = string(character)
|
||||
break
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.H6(theme, letter)
|
||||
label.Color = shellColors.onPrimary
|
||||
return label.Layout(gtx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutEmptyState(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
title := "没有匹配的软件"
|
||||
body := "尝试清除搜索词、分类或视图筛选。"
|
||||
showReset := shell.model.TotalCount() > 0
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, title).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, body)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showReset {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.resetFilters,
|
||||
"显示全部软件",
|
||||
true,
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
|
||||
|
||||
func (shell *AppShell) layoutDetail(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
shell.detailRendered = true
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, "软件详情").Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.closeDetail,
|
||||
"关闭",
|
||||
false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutAppIcon(
|
||||
gtx,
|
||||
theme,
|
||||
item.ID,
|
||||
item.Name,
|
||||
unit.Dp(72),
|
||||
unit.Dp(12),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, material.H6(theme, item.Name).Layout)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(
|
||||
theme,
|
||||
fmt.Sprintf("%s · %s", item.ID, item.Version),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return layout.Center.Layout(gtx, label.Layout)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(gtx, theme, "状态", statusLabel(item.Status))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(
|
||||
gtx,
|
||||
theme,
|
||||
"标签",
|
||||
fallbackText(strings.Join(item.Tags, " · "), "无"),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(
|
||||
gtx,
|
||||
theme,
|
||||
"简介",
|
||||
fallbackText(item.Description, "暂无简介"),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Reason == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Tutorial == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "教程", item.Tutorial)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Homepage == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "主页", item.Homepage)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, actionLabel(item)+";实际操作将在后续用例接入")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutUnsafeIconCacheFailure(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
appID string,
|
||||
) layout.Dimensions {
|
||||
failure, exists := shell.iconFailures[appID]
|
||||
if !exists || failure.Code != application.IconFailureUnsafe ||
|
||||
failure.Identity.AppID != appID {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
shellColors.destructive,
|
||||
shellColors.surface,
|
||||
unit.Dp(8),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
title := material.Body1(theme, "图标缓存安全警告")
|
||||
title.Color = shellColors.destructive
|
||||
return title.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
|
||||
diagnostic.Color = shellColors.secondary
|
||||
return diagnostic.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
|
||||
return fmt.Sprintf(
|
||||
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
|
||||
failure.Code,
|
||||
failure.Identity.AppID,
|
||||
unsafeIconCacheLocator(failure.Identity),
|
||||
)
|
||||
}
|
||||
|
||||
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
|
||||
digest := strings.TrimPrefix(identity.Reference, "sha256:")
|
||||
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
|
||||
}
|
||||
|
||||
func actionLabel(item application.CatalogListItem) string {
|
||||
if item.Reason != "" || item.Status == domain.StatusIncompatible {
|
||||
return "查看不可用原因"
|
||||
}
|
||||
switch item.Status {
|
||||
case domain.StatusInstalled:
|
||||
return "查看或启动"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "查看更新"
|
||||
case domain.StatusRunning:
|
||||
return "查看运行状态"
|
||||
case domain.StatusQueued,
|
||||
domain.StatusDownloading,
|
||||
domain.StatusVerifying,
|
||||
domain.StatusExtracting,
|
||||
domain.StatusInstalling:
|
||||
return "查看任务"
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return "查看恢复选项"
|
||||
default:
|
||||
if item.Installable {
|
||||
return "查看并安装"
|
||||
}
|
||||
return "查看详情"
|
||||
}
|
||||
}
|
||||
|
||||
func detailField(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
labelText string,
|
||||
value string,
|
||||
) layout.Dimensions {
|
||||
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, labelText)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||
layout.Rigid(material.Body2(theme, value).Layout),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func fallbackText(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reasonLabel(reason string) string {
|
||||
switch reason {
|
||||
case "deprecated":
|
||||
return "软件已停止发布,不能新装或更新"
|
||||
case "minimum_os":
|
||||
return "当前 Windows 版本低于最低要求"
|
||||
case "architecture":
|
||||
return "没有适用于当前系统架构的软件包"
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H4(theme, "SoftBox").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, "发现、安装并更新可信软件")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
border := shellColors.border
|
||||
if gtx.Focused(&shell.search) {
|
||||
border = shellColors.primary
|
||||
}
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
border,
|
||||
shellColors.surface,
|
||||
unit.Dp(8),
|
||||
layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
|
||||
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
|
||||
editor.TextSize = unit.Sp(15)
|
||||
return editor.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutCategories(gtx, theme)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutCategories(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
categories := append([]string{""}, shell.model.Categories()...)
|
||||
height := gtx.Dp(unit.Dp(44))
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
category := categories[index]
|
||||
label := category
|
||||
if label == "" {
|
||||
label = "全部分类"
|
||||
}
|
||||
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
shell.categoryControls[category],
|
||||
label,
|
||||
shell.model.Category() == category,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutViewButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
view application.CatalogView,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
clickable,
|
||||
label,
|
||||
shell.model.View() == view,
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFilterButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
active bool,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, clickable, label)
|
||||
button.CornerRadius = unit.Dp(8)
|
||||
button.Inset = layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||
}
|
||||
if active {
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
} else {
|
||||
button.Background = shellColors.muted
|
||||
button.Color = shellColors.foreground
|
||||
}
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFooter(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "目录状态:等待已验证 Catalog")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var shellColors = struct {
|
||||
background color.NRGBA
|
||||
surface color.NRGBA
|
||||
muted color.NRGBA
|
||||
foreground color.NRGBA
|
||||
secondary color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
border color.NRGBA
|
||||
success color.NRGBA
|
||||
warning color.NRGBA
|
||||
destructive color.NRGBA
|
||||
}{
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||
}
|
||||
|
||||
// NewTheme creates the accessible semantic palette shared by the modern shell.
|
||||
func NewTheme() *material.Theme {
|
||||
theme := material.NewTheme()
|
||||
theme.Palette = material.Palette{
|
||||
Bg: shellColors.background,
|
||||
Fg: shellColors.foreground,
|
||||
ContrastBg: shellColors.primary,
|
||||
ContrastFg: shellColors.onPrimary,
|
||||
}
|
||||
theme.FingerSize = unit.Dp(44)
|
||||
return theme
|
||||
}
|
||||
|
||||
func panel(
|
||||
gtx layout.Context,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return layout.Background{}.Layout(
|
||||
gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(
|
||||
gtx.Ops,
|
||||
background,
|
||||
clip.UniformRRect(
|
||||
image.Rectangle{Max: gtx.Constraints.Min},
|
||||
gtx.Dp(radius),
|
||||
).Op(gtx.Ops),
|
||||
)
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func outlinedPanel(
|
||||
gtx layout.Context,
|
||||
border color.NRGBA,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
border,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(1)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func viewTitle(view application.CatalogView) string {
|
||||
switch view {
|
||||
case application.CatalogViewInstalled:
|
||||
return "已安装软件"
|
||||
case application.CatalogViewUpdates:
|
||||
return "可更新软件"
|
||||
default:
|
||||
return "全部软件"
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(status domain.AppStatus) string {
|
||||
switch status {
|
||||
case domain.StatusQueued:
|
||||
return "排队中"
|
||||
case domain.StatusDownloading:
|
||||
return "下载中"
|
||||
case domain.StatusVerifying:
|
||||
return "校验中"
|
||||
case domain.StatusExtracting:
|
||||
return "解压中"
|
||||
case domain.StatusInstalling:
|
||||
return "安装中"
|
||||
case domain.StatusInstalled:
|
||||
return "已安装"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "可更新"
|
||||
case domain.StatusRunning:
|
||||
return "运行中"
|
||||
case domain.StatusFailed:
|
||||
return "失败"
|
||||
case domain.StatusRollbackPending:
|
||||
return "待恢复"
|
||||
case domain.StatusIncompatible:
|
||||
return "不兼容"
|
||||
default:
|
||||
return "未安装"
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||
switch status {
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return shellColors.destructive
|
||||
case domain.StatusUpdateAvailable:
|
||||
return shellColors.warning
|
||||
case domain.StatusInstalled, domain.StatusRunning:
|
||||
return shellColors.success
|
||||
case domain.StatusIncompatible:
|
||||
return shellColors.secondary
|
||||
default:
|
||||
return shellColors.primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestAppShellFillsWindow(t *testing.T) {
|
||||
var operations op.Ops
|
||||
size := image.Pt(1080, 720)
|
||||
context := layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
|
||||
dimensions := NewAppShell("Modern").Layout(context, NewTheme())
|
||||
if dimensions.Size != size {
|
||||
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||
items := make([]application.CatalogListItem, 500)
|
||||
for index := range items {
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("软件 %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: "工具",
|
||||
Tags: []string{"工具"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
shell := NewAppShell("Modern", items...)
|
||||
context := testContext(image.Pt(1080, 420))
|
||||
|
||||
shell.Layout(context, NewTheme())
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf(
|
||||
"lastRendered = %d, want visible subset of %d",
|
||||
shell.lastRendered,
|
||||
len(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
items := []application.CatalogListItem{
|
||||
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||
}
|
||||
shell := NewAppShell("Modern", items...)
|
||||
original := shell.rows["app-two"]
|
||||
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
|
||||
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
|
||||
|
||||
shell.model.SetCategory("图像")
|
||||
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after filtering")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{
|
||||
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||
})
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-one"]; exists {
|
||||
t.Fatal("removed app retained row controls")
|
||||
}
|
||||
if _, exists := shell.icons["app-one"]; exists {
|
||||
t.Fatal("removed app retained prepared icon")
|
||||
}
|
||||
icon, exists := shell.icons["app-two"]
|
||||
if !exists {
|
||||
t.Fatal("retained app lost its prepared icon")
|
||||
}
|
||||
if icon.Size() != image.Pt(24, 24) {
|
||||
t.Fatalf("retained app icon size = %v", icon.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
|
||||
item := application.CatalogListItem{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Description: "格式化并检查 JSON",
|
||||
Version: "1.2.0",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"JSON", "格式化"},
|
||||
Homepage: "https://example.invalid/json-parser",
|
||||
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
}
|
||||
shell := NewAppShell("Modern", item)
|
||||
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||
shell.ApplyIcon(item.ID, icon)
|
||||
shell.model.Select(item.ID)
|
||||
|
||||
shell.Layout(testContext(image.Pt(1280, 800)), NewTheme())
|
||||
if !shell.detailRendered {
|
||||
t.Fatal("selected app detail was not rendered")
|
||||
}
|
||||
if _, exists := shell.icons[item.ID]; !exists {
|
||||
t.Fatal("ApplyIcon did not retain the prepared image operation")
|
||||
}
|
||||
|
||||
shell.ApplyIcon(item.ID, nil)
|
||||
if _, exists := shell.icons[item.ID]; exists {
|
||||
t.Fatal("ApplyIcon(nil) did not remove the image")
|
||||
}
|
||||
}
|
||||
|
||||
func testContext(size image.Point) layout.Context {
|
||||
var operations op.Ops
|
||||
return layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/app-win7/platform/windows"
|
||||
softboxgio "softbox.local/app-win7/ui/gio"
|
||||
"softbox.local/core"
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
const applicationEventCapacity = 32
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("%s Legacy stopped: %v", core.ProductName, err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
platform := windows.New()
|
||||
window := new(app.Window)
|
||||
window.Option(
|
||||
app.Title(core.ProductName+" Legacy"),
|
||||
app.Size(unit.Dp(1024), unit.Dp(680)),
|
||||
)
|
||||
|
||||
theme := softboxgio.NewTheme()
|
||||
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
|
||||
|
||||
for {
|
||||
switch event := window.Event().(type) {
|
||||
case app.DestroyEvent:
|
||||
return event.Err
|
||||
case app.FrameEvent:
|
||||
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||
log.Printf("apply application event: %v", err)
|
||||
}
|
||||
context := app.NewContext(&operations, event)
|
||||
shell.Layout(context, theme)
|
||||
event.Frame(context.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
module softbox.local/app-win7
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
gioui.org v0.6.0
|
||||
softbox.local/core v0.0.0
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
go 1.20
|
||||
|
||||
use (
|
||||
.
|
||||
../core
|
||||
)
|
||||
|
||||
replace softbox.local/core v0.0.0 => ../core
|
||||
@@ -0,0 +1,10 @@
|
||||
gioui.org v0.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI=
|
||||
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
|
||||
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
|
||||
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
|
||||
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
|
||||
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package windows provides Win7-compatible platform adapters and non-Windows
|
||||
// stubs for package-level tests.
|
||||
package windows
|
||||
@@ -0,0 +1,17 @@
|
||||
package windows
|
||||
|
||||
// Edition identifies the application build channel shown by the UI.
|
||||
type Edition string
|
||||
|
||||
const EditionLegacy Edition = "Legacy"
|
||||
|
||||
// Platform is the minimal boundary for target-specific capabilities.
|
||||
type Platform interface {
|
||||
OS() string
|
||||
Edition() Edition
|
||||
}
|
||||
|
||||
// New returns the platform implementation selected by build tags.
|
||||
func New() Platform {
|
||||
return newPlatform()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import "runtime"
|
||||
|
||||
type platformStub struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platformStub{}
|
||||
}
|
||||
|
||||
func (platformStub) OS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (platformStub) Edition() Edition {
|
||||
return EditionLegacy
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package windows
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlatformStubContract(t *testing.T) {
|
||||
platform := New()
|
||||
if platform.OS() == "" {
|
||||
t.Fatal("OS() should not be empty")
|
||||
}
|
||||
if platform.Edition() != EditionLegacy {
|
||||
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
type platform struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platform{}
|
||||
}
|
||||
|
||||
func (platform) OS() string {
|
||||
return "windows"
|
||||
}
|
||||
|
||||
func (platform) Edition() Edition {
|
||||
return EditionLegacy
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/io/input"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
adapterContractViewport = image.Pt(1024, 680)
|
||||
adapterContractCompactViewport = image.Pt(1024, 380)
|
||||
)
|
||||
|
||||
const adapterContractEdition = "Legacy"
|
||||
|
||||
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
|
||||
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
|
||||
shell.search.SetText("APP-TWO")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.Query(); got != "app-two" {
|
||||
t.Fatalf("query after editor update = %q, want app-two", got)
|
||||
}
|
||||
visible := shell.model.VisibleItems()
|
||||
if len(visible) != 1 || visible[0].ID != "app-two" {
|
||||
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
|
||||
}
|
||||
|
||||
shell.search.SetText("")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.Category(); got != "图像" {
|
||||
t.Fatalf("category after click = %q, want 图像", got)
|
||||
}
|
||||
|
||||
shell.viewUpdates.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewUpdates {
|
||||
t.Fatalf("view after updates click = %q", got)
|
||||
}
|
||||
shell.viewInstalled.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewInstalled {
|
||||
t.Fatalf("view after installed click = %q", got)
|
||||
}
|
||||
shell.viewAll.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.View(); got != application.CatalogViewAll {
|
||||
t.Fatalf("view after all click = %q", got)
|
||||
}
|
||||
|
||||
shell.search.SetText("missing-app")
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
|
||||
!adapterContractHasSemantic(nodes, "显示全部软件") {
|
||||
t.Fatal("filtered empty state did not expose its recovery action")
|
||||
}
|
||||
shell.resetFilters.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if shell.search.Text() != "" || shell.model.Query() != "" ||
|
||||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
|
||||
t.Fatalf(
|
||||
"reset state = editor %q, query %q, category %q, view %q",
|
||||
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
|
||||
)
|
||||
}
|
||||
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
|
||||
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
|
||||
items := adapterContractLargeCatalog(80)
|
||||
shell := NewAppShell(adapterContractEdition, items...)
|
||||
targetID := "app-037"
|
||||
targetControl := shell.rows[targetID]
|
||||
|
||||
reordered := append([]application.CatalogListItem(nil), items...)
|
||||
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
|
||||
reordered[left], reordered[right] = reordered[right], reordered[left]
|
||||
}
|
||||
shell.SetItems(reordered)
|
||||
if shell.rows[targetID] != targetControl {
|
||||
t.Fatal("row control was recreated after catalog reorder")
|
||||
}
|
||||
|
||||
shell.search.SetText("app-")
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.viewInstalled.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
shell.appList.ScrollTo(12)
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
|
||||
targetControl.open.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.SelectedID(); got != targetID {
|
||||
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
|
||||
}
|
||||
if !shell.detailRendered {
|
||||
t.Fatal("row click selected the model but did not render detail")
|
||||
}
|
||||
queryBefore := shell.model.Query()
|
||||
categoryBefore := shell.model.Category()
|
||||
viewBefore := shell.model.View()
|
||||
positionBefore := shell.appList.Position
|
||||
|
||||
shell.closeDetail.Click()
|
||||
adapterContractLayout(shell, adapterContractViewport)
|
||||
if got := shell.model.SelectedID(); got != "" {
|
||||
t.Fatalf("selected ID after close = %q, want empty", got)
|
||||
}
|
||||
if shell.detailRendered {
|
||||
t.Fatal("detail remained rendered after close click")
|
||||
}
|
||||
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
|
||||
shell.model.View() != viewBefore {
|
||||
t.Fatal("closing detail changed the active list filters")
|
||||
}
|
||||
positionAfter := shell.appList.Position
|
||||
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
|
||||
t.Fatalf(
|
||||
"list position after close = first %d offset %d, want first %d offset %d",
|
||||
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||
items := adapterContractLargeCatalog(500)
|
||||
shell := NewAppShell(adapterContractEdition, items...)
|
||||
retainedRow := shell.rows["app-001"]
|
||||
retainedCategory := shell.categoryControls["图像"]
|
||||
|
||||
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
|
||||
}
|
||||
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
|
||||
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
|
||||
}
|
||||
|
||||
shell.categoryControls["图像"].Click()
|
||||
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
|
||||
t.Fatal("filtering recreated stable app or category controls")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
|
||||
if shell.rows["app-001"] != retainedRow {
|
||||
t.Fatal("retained app lost its row control after snapshot update")
|
||||
}
|
||||
if shell.categoryControls["图像"] != retainedCategory {
|
||||
t.Fatal("retained category lost its control after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-000"]; exists {
|
||||
t.Fatal("removed app retained its row control")
|
||||
}
|
||||
if _, exists := shell.categoryControls["工具"]; exists {
|
||||
t.Fatal("removed category retained its control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||
emptyShell := NewAppShell(adapterContractEdition)
|
||||
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
|
||||
t.Fatal("empty catalog did not render the catalog-unavailable state")
|
||||
}
|
||||
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||
t.Fatal("empty catalog rendered a filter recovery action")
|
||||
}
|
||||
|
||||
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||
filteredShell.search.SetText("missing-app")
|
||||
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
|
||||
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
|
||||
t.Fatal("filtered catalog did not render the no-matches state")
|
||||
}
|
||||
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
|
||||
t.Fatal("filtered catalog did not render its recovery action")
|
||||
}
|
||||
filteredShell.resetFilters.Click()
|
||||
adapterContractLayout(filteredShell, adapterContractViewport)
|
||||
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
|
||||
t.Fatal("filter recovery did not clear editor and model query")
|
||||
}
|
||||
if len(filteredShell.model.VisibleItems()) == 0 {
|
||||
t.Fatal("filter recovery did not restore catalog rows")
|
||||
}
|
||||
}
|
||||
|
||||
func adapterContractItems() []application.CatalogListItem {
|
||||
return []application.CatalogListItem{
|
||||
{
|
||||
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
|
||||
Status: domain.StatusNotInstalled, Installable: true,
|
||||
},
|
||||
{
|
||||
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
|
||||
Status: domain.StatusInstalled, Installed: true, Installable: true,
|
||||
},
|
||||
{
|
||||
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
|
||||
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
|
||||
items := make([]application.CatalogListItem, count)
|
||||
for index := range items {
|
||||
category := "工具"
|
||||
if index%2 == 1 {
|
||||
category = "图像"
|
||||
}
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("App %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: category,
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
|
||||
var operations op.Ops
|
||||
var router input.Router
|
||||
context := layout.Context{
|
||||
Ops: &operations,
|
||||
Source: router.Source(),
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
shell.Layout(context, NewTheme())
|
||||
router.Frame(&operations)
|
||||
return router.AppendSemantics(nil)
|
||||
}
|
||||
|
||||
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
|
||||
for _, node := range nodes {
|
||||
if node.Desc.Label == want || node.Desc.Description == want ||
|
||||
adapterContractHasSemantic(node.Children, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func adapterContractIDs(items []application.CatalogListItem) []string {
|
||||
ids := make([]string, len(items))
|
||||
for index, item := range items {
|
||||
ids[index] = item.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package gio contains the Win7-compatible Gio UI adapter.
|
||||
//
|
||||
// Layout code is rendering-only: it must not read files, access the network,
|
||||
// calculate hashes, or directly mutate background application state.
|
||||
package gio
|
||||
@@ -0,0 +1,111 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
|
||||
|
||||
type iconFailureState struct {
|
||||
Identity application.IconEventIdentity
|
||||
Code application.IconFailureCode
|
||||
}
|
||||
|
||||
// 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)
|
||||
delete(shell.iconFailures, 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] = iconFailureState{
|
||||
Identity: identity,
|
||||
Code: 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.Code, 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,422 @@
|
||||
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 TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
|
||||
reference := testIconReference("55")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
|
||||
shell.model.Select(item.ID)
|
||||
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
failure := shell.iconFailures[item.ID]
|
||||
for _, want := range []string{
|
||||
"图标缓存安全警告",
|
||||
unsafeIconCacheMessage,
|
||||
unsafeIconCacheDiagnostic(failure),
|
||||
} {
|
||||
if !adapterContractHasSemantic(nodes, want) {
|
||||
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
|
||||
}
|
||||
}
|
||||
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
|
||||
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
|
||||
}
|
||||
|
||||
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(unavailable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
|
||||
nodes = adapterContractLayout(shell, adapterContractViewport)
|
||||
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
|
||||
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
|
||||
}
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnavailable {
|
||||
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
|
||||
reference := testIconReference("66")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstFailure := applyTestIconFailure(
|
||||
t,
|
||||
shell,
|
||||
first,
|
||||
application.IconFailureUnsafe,
|
||||
)
|
||||
if got := shell.iconFailures[item.ID].Identity; got != first {
|
||||
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{item})
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnsafe {
|
||||
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
|
||||
}
|
||||
|
||||
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("new request retained the previous unsafe diagnostic")
|
||||
}
|
||||
if err := shell.ApplyEvent(firstFailure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("late failure restored a stale diagnostic")
|
||||
}
|
||||
ready, err := application.NewIconReadyEvent(
|
||||
latest,
|
||||
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(ready); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("ready event retained an unsafe diagnostic")
|
||||
}
|
||||
|
||||
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(dpiRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
|
||||
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
|
||||
t.Fatalf("stored failure DPI = %d, want 144", got)
|
||||
}
|
||||
|
||||
newReference := testIconReference("77")
|
||||
changed := item
|
||||
changed.IconRef = newReference
|
||||
shell.SetItems([]application.CatalogListItem{changed})
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("IconRef change retained the unsafe diagnostic")
|
||||
}
|
||||
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(canceled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
|
||||
t.Fatal("CancelIconRequest() did not cancel the current request")
|
||||
}
|
||||
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("canceled failure created an unsafe diagnostic")
|
||||
}
|
||||
|
||||
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(final); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
|
||||
shell.SetItems(nil)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("removed app retained the unsafe diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func applyTestIconFailure(
|
||||
t *testing.T,
|
||||
shell *AppShell,
|
||||
identity application.IconEventIdentity,
|
||||
code application.IconFailureCode,
|
||||
) application.Event {
|
||||
t.Helper()
|
||||
event, err := application.NewIconFailedEvent(identity, code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
// AppShell is the Legacy software catalog window.
|
||||
type AppShell struct {
|
||||
edition string
|
||||
model *application.CatalogListModel
|
||||
|
||||
search widget.Editor
|
||||
appList layout.List
|
||||
categoryList layout.List
|
||||
|
||||
viewAll widget.Clickable
|
||||
viewInstalled widget.Clickable
|
||||
viewUpdates widget.Clickable
|
||||
resetFilters widget.Clickable
|
||||
closeDetail widget.Clickable
|
||||
|
||||
categoryControls map[string]*widget.Clickable
|
||||
rows map[string]*rowControls
|
||||
icons map[string]paint.ImageOp
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]iconFailureState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
|
||||
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
|
||||
func NewAppShell(
|
||||
edition string,
|
||||
items ...application.CatalogListItem,
|
||||
) *AppShell {
|
||||
shell := &AppShell{
|
||||
edition: edition,
|
||||
model: application.NewCatalogListModel(nil),
|
||||
appList: layout.List{Axis: layout.Vertical},
|
||||
categoryList: layout.List{Axis: layout.Horizontal},
|
||||
categoryControls: make(map[string]*widget.Clickable),
|
||||
rows: make(map[string]*rowControls),
|
||||
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]iconFailureState),
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
return shell
|
||||
}
|
||||
|
||||
// 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) {
|
||||
delete(shell.iconApplied, appID)
|
||||
delete(shell.iconFailures, appID)
|
||||
if icon == nil {
|
||||
delete(shell.icons, appID)
|
||||
return
|
||||
}
|
||||
shell.icons[appID] = paint.NewImageOp(icon)
|
||||
}
|
||||
|
||||
// SetItems applies a prepared, IO-free catalog/status snapshot.
|
||||
func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
shell.model.SetItems(items)
|
||||
|
||||
nextRows := make(map[string]*rowControls, 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]iconFailureState, len(items))
|
||||
for _, item := range items {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
controls = new(rowControls)
|
||||
}
|
||||
nextRows[item.ID] = controls
|
||||
reference := canonicalIconReference(item.IconRef)
|
||||
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 &&
|
||||
failure.Identity.Reference == reference {
|
||||
nextFailures[item.ID] = failure
|
||||
}
|
||||
}
|
||||
}
|
||||
shell.rows = nextRows
|
||||
shell.icons = nextIcons
|
||||
shell.iconReferences = nextReferences
|
||||
shell.iconRequests = nextRequests
|
||||
shell.iconApplied = nextApplied
|
||||
shell.iconFailures = nextFailures
|
||||
|
||||
nextCategories := make(map[string]*widget.Clickable)
|
||||
for _, category := range append([]string{""}, shell.model.Categories()...) {
|
||||
control := shell.categoryControls[category]
|
||||
if control == nil {
|
||||
control = new(widget.Clickable)
|
||||
}
|
||||
nextCategories[category] = control
|
||||
}
|
||||
shell.categoryControls = nextCategories
|
||||
}
|
||||
|
||||
// Layout drains input first and performs no disk, network or hash IO.
|
||||
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||
shell.drainInput(gtx)
|
||||
shell.lastRendered = 0
|
||||
shell.detailRendered = false
|
||||
paint.Fill(gtx.Ops, shellColors.background)
|
||||
|
||||
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutHeader(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutContent(gtx, theme)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
shell.edition+" · Legacy · Windows 7 SP1 x64",
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
for {
|
||||
if _, ok := shell.search.Update(gtx); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
shell.model.SetQuery(shell.search.Text())
|
||||
for shell.viewAll.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewAll)
|
||||
}
|
||||
for shell.viewInstalled.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewInstalled)
|
||||
}
|
||||
for shell.viewUpdates.Clicked(gtx) {
|
||||
shell.model.SetView(application.CatalogViewUpdates)
|
||||
}
|
||||
for category, control := range shell.categoryControls {
|
||||
for control.Clicked(gtx) {
|
||||
shell.model.SetCategory(category)
|
||||
}
|
||||
}
|
||||
for appID, controls := range shell.rows {
|
||||
for controls.open.Clicked(gtx) {
|
||||
shell.model.Select(appID)
|
||||
}
|
||||
}
|
||||
for shell.resetFilters.Clicked(gtx) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
for shell.closeDetail.Clicked(gtx) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
type rowControls struct {
|
||||
open widget.Clickable
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutContent(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
width := gtx.Dp(unit.Dp(152))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(10)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewAll,
|
||||
"全部软件",
|
||||
application.CatalogViewAll,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewInstalled,
|
||||
"已安装",
|
||||
application.CatalogViewInstalled,
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutViewButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.viewUpdates,
|
||||
"可更新",
|
||||
application.CatalogViewUpdates,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
visible := shell.model.VisibleItems()
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
fmt.Sprintf(
|
||||
"%d / %d 项",
|
||||
len(visible),
|
||||
shell.model.TotalCount(),
|
||||
),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if len(visible) == 0 {
|
||||
return shell.layoutEmptyState(gtx, theme)
|
||||
}
|
||||
return shell.appList.Layout(gtx, len(visible), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
shell.lastRendered++
|
||||
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !hasSelection {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !hasSelection {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
width := gtx.Dp(unit.Dp(280))
|
||||
gtx.Constraints.Min.X = width
|
||||
gtx.Constraints.Max.X = width
|
||||
return shell.layoutDetail(gtx, theme, selected)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppRow(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
|
||||
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
semantic.DescriptionOp(fmt.Sprintf(
|
||||
"%s,版本 %s,状态 %s",
|
||||
item.Name,
|
||||
item.Version,
|
||||
statusLabel(item.Status),
|
||||
)).Add(gtx.Ops)
|
||||
background := shellColors.muted
|
||||
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||
}
|
||||
if shell.model.SelectedID() == item.ID {
|
||||
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
background,
|
||||
unit.Dp(5),
|
||||
layout.UniformInset(unit.Dp(10)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutAppIcon(
|
||||
gtx,
|
||||
theme,
|
||||
item.ID,
|
||||
item.Name,
|
||||
unit.Dp(40),
|
||||
unit.Dp(5),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.Body1(theme, item.Name).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body2(theme, statusLabel(item.Status))
|
||||
label.Color = statusColor(item.Status)
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutAppIcon(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
appID string,
|
||||
name string,
|
||||
iconSize unit.Dp,
|
||||
radius unit.Dp,
|
||||
) layout.Dimensions {
|
||||
size := gtx.Dp(iconSize)
|
||||
gtx.Constraints.Min = image.Pt(size, size)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
if icon, exists := shell.icons[appID]; exists {
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(2)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return widget.Image{
|
||||
Src: icon,
|
||||
Fit: widget.Contain,
|
||||
Position: layout.Center,
|
||||
}.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}
|
||||
letter := "S"
|
||||
for _, character := range name {
|
||||
letter = string(character)
|
||||
break
|
||||
}
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Body1(theme, letter)
|
||||
label.Color = shellColors.onPrimary
|
||||
return label.Layout(gtx)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutEmptyState(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
title := "没有匹配的软件"
|
||||
body := "清除搜索词、分类或视图筛选后重试。"
|
||||
showReset := shell.model.TotalCount() > 0
|
||||
if shell.model.TotalCount() == 0 {
|
||||
title = "软件目录尚未加载"
|
||||
body = "联网刷新或读取已验证缓存后会显示软件。"
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H6(theme, title).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, body)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showReset {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.resetFilters,
|
||||
"显示全部软件",
|
||||
true,
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
|
||||
|
||||
func (shell *AppShell) layoutDetail(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
item application.CatalogListItem,
|
||||
) layout.Dimensions {
|
||||
shell.detailRendered = true
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.muted,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.Body1(theme, "软件详情").Layout),
|
||||
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
&shell.closeDetail,
|
||||
"关闭",
|
||||
false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutAppIcon(
|
||||
gtx,
|
||||
theme,
|
||||
item.ID,
|
||||
item.Name,
|
||||
unit.Dp(64),
|
||||
unit.Dp(7),
|
||||
)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, material.Body1(theme, item.Name).Layout)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(
|
||||
theme,
|
||||
fmt.Sprintf("%s · %s", item.ID, item.Version),
|
||||
)
|
||||
label.Color = shellColors.secondary
|
||||
return layout.Center.Layout(gtx, label.Layout)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(gtx, theme, "状态", statusLabel(item.Status))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(
|
||||
gtx,
|
||||
theme,
|
||||
"标签",
|
||||
fallbackText(strings.Join(item.Tags, " · "), "无"),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return detailField(
|
||||
gtx,
|
||||
theme,
|
||||
"简介",
|
||||
fallbackText(item.Description, "暂无简介"),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Reason == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Tutorial == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "教程", item.Tutorial)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if item.Homepage == "" {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return detailField(gtx, theme, "主页", item.Homepage)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "实际安装/启动操作将在后续用例接入")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutUnsafeIconCacheFailure(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
appID string,
|
||||
) layout.Dimensions {
|
||||
failure, exists := shell.iconFailures[appID]
|
||||
if !exists || failure.Code != application.IconFailureUnsafe ||
|
||||
failure.Identity.AppID != appID {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
shellColors.destructive,
|
||||
shellColors.surface,
|
||||
unit.Dp(6),
|
||||
layout.UniformInset(unit.Dp(10)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
title := material.Body1(theme, "图标缓存安全警告")
|
||||
title.Color = shellColors.destructive
|
||||
return title.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
|
||||
diagnostic.Color = shellColors.secondary
|
||||
return diagnostic.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
|
||||
return fmt.Sprintf(
|
||||
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
|
||||
failure.Code,
|
||||
failure.Identity.AppID,
|
||||
unsafeIconCacheLocator(failure.Identity),
|
||||
)
|
||||
}
|
||||
|
||||
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
|
||||
digest := strings.TrimPrefix(identity.Reference, "sha256:")
|
||||
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
|
||||
}
|
||||
|
||||
func detailField(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
labelText string,
|
||||
value string,
|
||||
) layout.Dimensions {
|
||||
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, labelText)
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(material.Caption(theme, value).Layout),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func fallbackText(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reasonLabel(reason string) string {
|
||||
switch reason {
|
||||
case "deprecated":
|
||||
return "软件已停止发布"
|
||||
case "minimum_os":
|
||||
return "Windows 版本低于最低要求"
|
||||
case "architecture":
|
||||
return "没有当前架构的软件包"
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"gioui.org/layout"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
|
||||
label.Color = shellColors.secondary
|
||||
return label.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
border := shellColors.border
|
||||
if gtx.Focused(&shell.search) {
|
||||
border = shellColors.primary
|
||||
}
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
border,
|
||||
shellColors.surface,
|
||||
unit.Dp(6),
|
||||
layout.Inset{
|
||||
Top: unit.Dp(9), Bottom: unit.Dp(9),
|
||||
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
|
||||
editor.TextSize = unit.Sp(14)
|
||||
return editor.Layout(gtx)
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
categories := append([]string{""}, shell.model.Categories()...)
|
||||
height := gtx.Dp(unit.Dp(44))
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||
gtx layout.Context,
|
||||
index int,
|
||||
) layout.Dimensions {
|
||||
category := categories[index]
|
||||
label := category
|
||||
if label == "" {
|
||||
label = "全部分类"
|
||||
}
|
||||
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
shell.categoryControls[category],
|
||||
label,
|
||||
shell.model.Category() == category,
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutViewButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
view application.CatalogView,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return shell.layoutFilterButton(
|
||||
gtx,
|
||||
theme,
|
||||
clickable,
|
||||
label,
|
||||
shell.model.View() == view,
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutFilterButton(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
clickable *widget.Clickable,
|
||||
label string,
|
||||
active bool,
|
||||
) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||
button := material.Button(theme, clickable, label)
|
||||
button.CornerRadius = unit.Dp(5)
|
||||
button.Inset = layout.Inset{
|
||||
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||
}
|
||||
if active {
|
||||
button.Background = shellColors.primary
|
||||
button.Color = shellColors.onPrimary
|
||||
} else {
|
||||
button.Background = shellColors.muted
|
||||
button.Color = shellColors.foreground
|
||||
}
|
||||
return button.Layout(gtx)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var shellColors = struct {
|
||||
background color.NRGBA
|
||||
surface color.NRGBA
|
||||
muted color.NRGBA
|
||||
foreground color.NRGBA
|
||||
secondary color.NRGBA
|
||||
primary color.NRGBA
|
||||
onPrimary color.NRGBA
|
||||
border color.NRGBA
|
||||
success color.NRGBA
|
||||
warning color.NRGBA
|
||||
destructive color.NRGBA
|
||||
}{
|
||||
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||
}
|
||||
|
||||
// NewTheme creates the accessible palette shared by the Legacy shell.
|
||||
func NewTheme() *material.Theme {
|
||||
theme := material.NewTheme()
|
||||
theme.Palette = material.Palette{
|
||||
Bg: shellColors.background,
|
||||
Fg: shellColors.foreground,
|
||||
ContrastBg: shellColors.primary,
|
||||
ContrastFg: shellColors.onPrimary,
|
||||
}
|
||||
theme.FingerSize = unit.Dp(44)
|
||||
return theme
|
||||
}
|
||||
|
||||
func panel(
|
||||
gtx layout.Context,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return layout.Background{}.Layout(
|
||||
gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
paint.FillShape(
|
||||
gtx.Ops,
|
||||
background,
|
||||
clip.UniformRRect(
|
||||
image.Rectangle{Max: gtx.Constraints.Min},
|
||||
gtx.Dp(radius),
|
||||
).Op(gtx.Ops),
|
||||
)
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func outlinedPanel(
|
||||
gtx layout.Context,
|
||||
border color.NRGBA,
|
||||
background color.NRGBA,
|
||||
radius unit.Dp,
|
||||
inset layout.Inset,
|
||||
content layout.Widget,
|
||||
) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
border,
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(1)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func viewTitle(view application.CatalogView) string {
|
||||
switch view {
|
||||
case application.CatalogViewInstalled:
|
||||
return "已安装软件"
|
||||
case application.CatalogViewUpdates:
|
||||
return "可更新软件"
|
||||
default:
|
||||
return "全部软件"
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(status domain.AppStatus) string {
|
||||
switch status {
|
||||
case domain.StatusQueued:
|
||||
return "排队中"
|
||||
case domain.StatusDownloading:
|
||||
return "下载中"
|
||||
case domain.StatusVerifying:
|
||||
return "校验中"
|
||||
case domain.StatusExtracting:
|
||||
return "解压中"
|
||||
case domain.StatusInstalling:
|
||||
return "安装中"
|
||||
case domain.StatusInstalled:
|
||||
return "已安装"
|
||||
case domain.StatusUpdateAvailable:
|
||||
return "可更新"
|
||||
case domain.StatusRunning:
|
||||
return "运行中"
|
||||
case domain.StatusFailed:
|
||||
return "失败"
|
||||
case domain.StatusRollbackPending:
|
||||
return "待恢复"
|
||||
case domain.StatusIncompatible:
|
||||
return "不兼容"
|
||||
default:
|
||||
return "未安装"
|
||||
}
|
||||
}
|
||||
|
||||
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||
switch status {
|
||||
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||
return shellColors.destructive
|
||||
case domain.StatusUpdateAvailable:
|
||||
return shellColors.warning
|
||||
case domain.StatusInstalled, domain.StatusRunning:
|
||||
return shellColors.success
|
||||
case domain.StatusIncompatible:
|
||||
return shellColors.secondary
|
||||
default:
|
||||
return shellColors.primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
|
||||
"softbox.local/core/application"
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestAppShellFillsWindow(t *testing.T) {
|
||||
var operations op.Ops
|
||||
size := image.Pt(1024, 680)
|
||||
context := layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
|
||||
dimensions := NewAppShell("Legacy").Layout(context, NewTheme())
|
||||
if dimensions.Size != size {
|
||||
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||
items := make([]application.CatalogListItem, 500)
|
||||
for index := range items {
|
||||
items[index] = application.CatalogListItem{
|
||||
ID: fmt.Sprintf("app-%03d", index),
|
||||
Name: fmt.Sprintf("软件 %03d", index),
|
||||
Version: "1.0.0",
|
||||
Category: "工具",
|
||||
Tags: []string{"工具"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
shell := NewAppShell("Legacy", items...)
|
||||
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
|
||||
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||
t.Fatalf(
|
||||
"lastRendered = %d, want visible subset of %d",
|
||||
shell.lastRendered,
|
||||
len(items),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
items := []application.CatalogListItem{
|
||||
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||
}
|
||||
shell := NewAppShell("Legacy", items...)
|
||||
original := shell.rows["app-two"]
|
||||
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
|
||||
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
|
||||
|
||||
shell.model.SetCategory("图像")
|
||||
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after filtering")
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{
|
||||
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||
})
|
||||
if shell.rows["app-two"] != original {
|
||||
t.Fatal("row controls were recreated after snapshot update")
|
||||
}
|
||||
if _, exists := shell.rows["app-one"]; exists {
|
||||
t.Fatal("removed app retained row controls")
|
||||
}
|
||||
if _, exists := shell.icons["app-one"]; exists {
|
||||
t.Fatal("removed app retained prepared icon")
|
||||
}
|
||||
icon, exists := shell.icons["app-two"]
|
||||
if !exists {
|
||||
t.Fatal("retained app lost its prepared icon")
|
||||
}
|
||||
if icon.Size() != image.Pt(24, 24) {
|
||||
t.Fatalf("retained app icon size = %v", icon.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
|
||||
item := application.CatalogListItem{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Description: "格式化并检查 JSON",
|
||||
Version: "1.2.0",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"JSON", "格式化"},
|
||||
Homepage: "https://example.invalid/json-parser",
|
||||
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
}
|
||||
shell := NewAppShell("Legacy", item)
|
||||
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||
shell.ApplyIcon(item.ID, icon)
|
||||
shell.model.Select(item.ID)
|
||||
|
||||
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
|
||||
if !shell.detailRendered {
|
||||
t.Fatal("selected app detail was not rendered")
|
||||
}
|
||||
if _, exists := shell.icons[item.ID]; !exists {
|
||||
t.Fatal("ApplyIcon did not retain the prepared image operation")
|
||||
}
|
||||
|
||||
shell.ApplyIcon(item.ID, nil)
|
||||
if _, exists := shell.icons[item.ID]; exists {
|
||||
t.Fatal("ApplyIcon(nil) did not remove the image")
|
||||
}
|
||||
}
|
||||
|
||||
func testContext(size image.Point) layout.Context {
|
||||
var operations op.Ops
|
||||
return layout.Context{
|
||||
Ops: &operations,
|
||||
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||
Constraints: layout.Exact(size),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
// CatalogView is one primary software-list scope.
|
||||
type CatalogView string
|
||||
|
||||
const (
|
||||
CatalogViewAll CatalogView = "all"
|
||||
CatalogViewInstalled CatalogView = "installed"
|
||||
CatalogViewUpdates CatalogView = "updates"
|
||||
)
|
||||
|
||||
// CatalogListItem is the IO-free data consumed by Gio list adapters.
|
||||
type CatalogListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version string
|
||||
Category string
|
||||
Tags []string
|
||||
IconRef string
|
||||
Homepage string
|
||||
Tutorial string
|
||||
Status domain.AppStatus
|
||||
Installed bool
|
||||
Installable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CatalogListModel owns source items and composable list filters.
|
||||
type CatalogListModel struct {
|
||||
items []CatalogListItem
|
||||
visible []CatalogListItem
|
||||
categories []string
|
||||
query string
|
||||
category string
|
||||
view CatalogView
|
||||
selectedID string
|
||||
}
|
||||
|
||||
// NewCatalogListModel copies items and initializes the all view.
|
||||
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
|
||||
model := &CatalogListModel{view: CatalogViewAll}
|
||||
model.SetItems(items)
|
||||
return model
|
||||
}
|
||||
|
||||
// SetItems replaces the source snapshot and recomputes categories/visibility.
|
||||
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
|
||||
model.items = cloneCatalogItems(items)
|
||||
model.categories = collectCategories(model.items)
|
||||
if model.category != "" && !containsString(model.categories, model.category) {
|
||||
model.category = ""
|
||||
}
|
||||
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
|
||||
model.selectedID = ""
|
||||
}
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetQuery applies a case-insensitive name/ID/tag search.
|
||||
func (model *CatalogListModel) SetQuery(query string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
if model.query == normalized {
|
||||
return
|
||||
}
|
||||
model.query = normalized
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetCategory selects one exact category. Empty means every category.
|
||||
func (model *CatalogListModel) SetCategory(category string) {
|
||||
if category != "" && !containsString(model.categories, category) {
|
||||
return
|
||||
}
|
||||
if model.category == category {
|
||||
return
|
||||
}
|
||||
model.category = category
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// SetView selects the all, installed or updates scope.
|
||||
func (model *CatalogListModel) SetView(view CatalogView) {
|
||||
if !view.Valid() || model.view == view {
|
||||
return
|
||||
}
|
||||
model.view = view
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// Select records a stable app ID for row interaction state.
|
||||
func (model *CatalogListModel) Select(appID string) {
|
||||
if appID == "" || containsItemID(model.items, appID) {
|
||||
model.selectedID = appID
|
||||
}
|
||||
}
|
||||
|
||||
// ResetFilters restores the full list while retaining source data.
|
||||
func (model *CatalogListModel) ResetFilters() {
|
||||
model.query = ""
|
||||
model.category = ""
|
||||
model.view = CatalogViewAll
|
||||
model.refilter()
|
||||
}
|
||||
|
||||
// VisibleItems returns the current read-only snapshot generation without copying.
|
||||
// The snapshot remains stable after later model changes. Callers must not modify
|
||||
// its elements, nested Tags, or capacity; CatalogListModel is single-owner and
|
||||
// does not support concurrent reads and writes.
|
||||
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
|
||||
return model.visible
|
||||
}
|
||||
|
||||
// Categories returns the stable first-seen category order.
|
||||
func (model *CatalogListModel) Categories() []string {
|
||||
return model.categories
|
||||
}
|
||||
|
||||
// TotalCount returns the unfiltered source count.
|
||||
func (model *CatalogListModel) TotalCount() int {
|
||||
return len(model.items)
|
||||
}
|
||||
|
||||
// Query returns the normalized active query.
|
||||
func (model *CatalogListModel) Query() string {
|
||||
return model.query
|
||||
}
|
||||
|
||||
// Category returns the active exact category, or empty for all.
|
||||
func (model *CatalogListModel) Category() string {
|
||||
return model.category
|
||||
}
|
||||
|
||||
// View returns the active primary scope.
|
||||
func (model *CatalogListModel) View() CatalogView {
|
||||
return model.view
|
||||
}
|
||||
|
||||
// SelectedID returns the selected stable software ID.
|
||||
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 ||
|
||||
view == CatalogViewInstalled ||
|
||||
view == CatalogViewUpdates
|
||||
}
|
||||
|
||||
func (model *CatalogListModel) refilter() {
|
||||
visible := make([]CatalogListItem, 0, len(model.items))
|
||||
for _, item := range model.items {
|
||||
if model.category != "" && item.Category != model.category {
|
||||
continue
|
||||
}
|
||||
if !matchesView(item, model.view) {
|
||||
continue
|
||||
}
|
||||
if model.query != "" && !matchesQuery(item, model.query) {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, item)
|
||||
}
|
||||
model.visible = visible
|
||||
}
|
||||
|
||||
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||
switch view {
|
||||
case CatalogViewAll:
|
||||
return true
|
||||
case CatalogViewInstalled:
|
||||
return item.Installed
|
||||
case CatalogViewUpdates:
|
||||
return item.Status == domain.StatusUpdateAvailable
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func matchesQuery(item CatalogListItem, query string) bool {
|
||||
if strings.Contains(strings.ToLower(item.Name), query) ||
|
||||
strings.Contains(strings.ToLower(item.ID), query) {
|
||||
return true
|
||||
}
|
||||
for _, tag := range item.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectCategories(items []CatalogListItem) []string {
|
||||
seen := make(map[string]struct{})
|
||||
categories := make([]string, 0)
|
||||
for _, item := range items {
|
||||
if item.Category == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.Category]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.Category] = struct{}{}
|
||||
categories = append(categories, item.Category)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
|
||||
cloned := make([]CatalogListItem, len(items))
|
||||
for index, item := range items {
|
||||
cloned[index] = item
|
||||
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func containsString(values []string, value string) bool {
|
||||
for _, candidate := range values {
|
||||
if candidate == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsItemID(items []CatalogListItem, appID string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == appID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
var visibleSnapshotSink []CatalogListItem
|
||||
|
||||
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"JSON", "格式化"},
|
||||
Status: domain.StatusInstalled,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "image-tool",
|
||||
Name: "Image Tool",
|
||||
Category: "图像",
|
||||
Tags: []string{"PNG", "压缩"},
|
||||
Status: domain.StatusUpdateAvailable,
|
||||
Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "log-viewer",
|
||||
Name: "日志查看器",
|
||||
Category: "开发工具",
|
||||
Tags: []string{"LOG", "诊断"},
|
||||
Status: domain.StatusNotInstalled,
|
||||
},
|
||||
})
|
||||
|
||||
model.SetQuery(" png ")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("")
|
||||
model.SetCategory("开发工具")
|
||||
assertVisibleIDs(t, model, "json-parser", "log-viewer")
|
||||
|
||||
model.SetView(CatalogViewInstalled)
|
||||
assertVisibleIDs(t, model, "json-parser")
|
||||
|
||||
model.SetCategory("")
|
||||
model.SetView(CatalogViewUpdates)
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
|
||||
model.SetQuery("IMAGE-")
|
||||
assertVisibleIDs(t, model, "image-tool")
|
||||
}
|
||||
|
||||
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||
items := []CatalogListItem{
|
||||
{ID: "app-b", Name: "B", Category: "工具"},
|
||||
{ID: "app-a", Name: "A", Category: "工具"},
|
||||
}
|
||||
model := NewCatalogListModel(items)
|
||||
model.Select("app-a")
|
||||
model.SetQuery("app")
|
||||
|
||||
assertVisibleIDs(t, model, "app-b", "app-a")
|
||||
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() != "" {
|
||||
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
|
||||
model := NewCatalogListModel([]CatalogListItem{
|
||||
{ID: "one", Category: "开发"},
|
||||
{ID: "two", Category: "图像"},
|
||||
{ID: "three", Category: "开发"},
|
||||
})
|
||||
categories := model.Categories()
|
||||
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
|
||||
t.Fatalf("Categories = %#v", categories)
|
||||
}
|
||||
|
||||
model.SetQuery("missing")
|
||||
model.SetCategory("开发")
|
||||
model.SetView(CatalogViewInstalled)
|
||||
model.ResetFilters()
|
||||
if model.Query() != "" ||
|
||||
model.Category() != "" ||
|
||||
model.View() != CatalogViewAll ||
|
||||
len(model.VisibleItems()) != 3 {
|
||||
t.Fatalf("model was not reset: %#v", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(*CatalogListModel)
|
||||
mutate func(*CatalogListModel)
|
||||
}{
|
||||
{
|
||||
name: "query",
|
||||
mutate: func(model *CatalogListModel) { model.SetQuery("image") },
|
||||
},
|
||||
{
|
||||
name: "category",
|
||||
mutate: func(model *CatalogListModel) { model.SetCategory("图像") },
|
||||
},
|
||||
{
|
||||
name: "view",
|
||||
mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) },
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
prepare: func(model *CatalogListModel) {
|
||||
model.SetQuery("image")
|
||||
},
|
||||
mutate: func(model *CatalogListModel) { model.ResetFilters() },
|
||||
},
|
||||
{
|
||||
name: "items",
|
||||
mutate: func(model *CatalogListModel) {
|
||||
model.SetItems([]CatalogListItem{
|
||||
{ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||
if test.prepare != nil {
|
||||
test.prepare(model)
|
||||
}
|
||||
previous := model.VisibleItems()
|
||||
if len(previous) == 0 {
|
||||
t.Fatal("test setup produced an empty previous generation")
|
||||
}
|
||||
wantPrevious := cloneSnapshotForTest(previous)
|
||||
|
||||
test.mutate(model)
|
||||
|
||||
if !reflect.DeepEqual(previous, wantPrevious) {
|
||||
t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious)
|
||||
}
|
||||
current := model.VisibleItems()
|
||||
if len(current) == 0 {
|
||||
t.Fatal("test mutation produced an empty current generation")
|
||||
}
|
||||
if &previous[0] == ¤t[0] {
|
||||
t.Fatal("current generation reused the previous backing array")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) {
|
||||
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||
first := model.VisibleItems()
|
||||
second := model.VisibleItems()
|
||||
if len(first) == 0 || len(second) == 0 {
|
||||
t.Fatal("test setup produced an empty generation")
|
||||
}
|
||||
if &first[0] != &second[0] {
|
||||
t.Fatal("repeated VisibleItems calls copied the current generation")
|
||||
}
|
||||
|
||||
model.SetQuery(" ")
|
||||
unchanged := model.VisibleItems()
|
||||
if &first[0] != &unchanged[0] {
|
||||
t.Fatal("no-op model update published a new generation")
|
||||
}
|
||||
if allocations := testing.AllocsPerRun(100, func() {
|
||||
visibleSnapshotSink = model.VisibleItems()
|
||||
}); allocations != 0 {
|
||||
t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations)
|
||||
}
|
||||
|
||||
model.SetQuery("image")
|
||||
changed := model.VisibleItems()
|
||||
if len(changed) == 0 {
|
||||
t.Fatal("changed generation is empty")
|
||||
}
|
||||
if &first[0] == &changed[0] {
|
||||
t.Fatal("actual model update did not publish a new generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) {
|
||||
model := NewCatalogListModel(nil)
|
||||
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||
t.Fatalf("empty catalog visible items = %#v", visible)
|
||||
}
|
||||
|
||||
model.SetItems(catalogSnapshotFixture())
|
||||
full := model.VisibleItems()
|
||||
wantFull := cloneSnapshotForTest(full)
|
||||
model.SetQuery("missing-app")
|
||||
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||
t.Fatalf("no-match visible items = %#v", visible)
|
||||
}
|
||||
if !reflect.DeepEqual(full, wantFull) {
|
||||
t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull)
|
||||
}
|
||||
|
||||
model.ResetFilters()
|
||||
assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer")
|
||||
if !reflect.DeepEqual(full, wantFull) {
|
||||
t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull)
|
||||
}
|
||||
}
|
||||
|
||||
func catalogSnapshotFixture() []CatalogListItem {
|
||||
return []CatalogListItem{
|
||||
{
|
||||
ID: "json-parser", Name: "JSON Parser", Category: "开发",
|
||||
Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "image-tool", Name: "Image Tool", Category: "图像",
|
||||
Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true,
|
||||
},
|
||||
{
|
||||
ID: "log-viewer", Name: "Log Viewer", Category: "开发",
|
||||
Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSnapshotForTest(items []CatalogListItem) []CatalogListItem {
|
||||
cloned := make([]CatalogListItem, len(items))
|
||||
for index, item := range items {
|
||||
cloned[index] = item
|
||||
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||
t.Helper()
|
||||
visible := model.VisibleItems()
|
||||
if len(visible) != len(want) {
|
||||
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
|
||||
}
|
||||
for index, item := range visible {
|
||||
if item.ID != want[index] {
|
||||
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package application coordinates SoftBox use cases through injected ports.
|
||||
package application
|
||||
@@ -0,0 +1,45 @@
|
||||
package application
|
||||
|
||||
// DownloadStartedPayload begins one transfer attempt. A later attempt may
|
||||
// reset Done when a remote entity cannot be safely resumed.
|
||||
type DownloadStartedPayload struct {
|
||||
Attempt uint64
|
||||
Done int64
|
||||
TotalKnown bool
|
||||
Total int64
|
||||
}
|
||||
|
||||
// DownloadProgressPayload reports monotonic progress within one attempt.
|
||||
type DownloadProgressPayload struct {
|
||||
Attempt uint64
|
||||
Done int64
|
||||
TotalKnown bool
|
||||
Total int64
|
||||
SpeedBytesSec int64
|
||||
}
|
||||
|
||||
// DownloadPausedPayload maps to domain queued while preserving pause intent.
|
||||
type DownloadPausedPayload struct {
|
||||
Attempt uint64
|
||||
Done int64
|
||||
}
|
||||
|
||||
// DownloadCompletedPayload means bytes are durably downloaded but still
|
||||
// untrusted. T-302 must verify the signed Catalog identity, size and hashes.
|
||||
type DownloadCompletedPayload struct {
|
||||
Attempt uint64
|
||||
Done int64
|
||||
Path string
|
||||
}
|
||||
|
||||
// DownloadFailedPayload carries a stable low-level transfer/storage code.
|
||||
type DownloadFailedPayload struct {
|
||||
Attempt uint64
|
||||
Done int64
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
// DownloadCanceledPayload confirms cleanup and suppresses the old attempt.
|
||||
type DownloadCanceledPayload struct {
|
||||
Attempt uint64
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package application
|
||||
|
||||
// EventType identifies an application event consumed by UI adapters.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventCatalogRefreshed EventType = "CatalogRefreshed"
|
||||
EventCatalogRejected EventType = "CatalogRejected"
|
||||
EventDownloadStarted EventType = "DownloadStarted"
|
||||
EventDownloadProgress EventType = "DownloadProgress"
|
||||
EventDownloadPaused EventType = "DownloadPaused"
|
||||
EventDownloadCompleted EventType = "DownloadCompleted"
|
||||
EventDownloadFailed EventType = "DownloadFailed"
|
||||
EventDownloadCanceled EventType = "DownloadCanceled"
|
||||
EventInstallCompleted EventType = "InstallCompleted"
|
||||
EventInstallRolledBack EventType = "InstallRolledBack"
|
||||
EventAppStarted EventType = "AppStarted"
|
||||
EventAppExited EventType = "AppExited"
|
||||
EventLicenseChanged EventType = "LicenseChanged"
|
||||
EventIconReady EventType = "IconReady"
|
||||
EventIconFailed EventType = "IconFailed"
|
||||
)
|
||||
|
||||
var validEventTypes = map[EventType]struct{}{
|
||||
EventCatalogRefreshed: {},
|
||||
EventCatalogRejected: {},
|
||||
EventDownloadStarted: {},
|
||||
EventDownloadProgress: {},
|
||||
EventDownloadPaused: {},
|
||||
EventDownloadCompleted: {},
|
||||
EventDownloadFailed: {},
|
||||
EventDownloadCanceled: {},
|
||||
EventInstallCompleted: {},
|
||||
EventInstallRolledBack: {},
|
||||
EventAppStarted: {},
|
||||
EventAppExited: {},
|
||||
EventLicenseChanged: {},
|
||||
EventIconReady: {},
|
||||
EventIconFailed: {},
|
||||
}
|
||||
|
||||
// Valid reports whether eventType is part of the documented event contract.
|
||||
func (eventType EventType) Valid() bool {
|
||||
_, ok := validEventTypes[eventType]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Event is the common envelope delivered from background use cases to adapters.
|
||||
//
|
||||
// Payload is event-specific and will be replaced by concrete payload types as
|
||||
// the corresponding use cases are implemented.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
RequestID string
|
||||
AppID string
|
||||
Payload any
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package application
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypeValid(t *testing.T) {
|
||||
eventTypes := []EventType{
|
||||
EventCatalogRefreshed,
|
||||
EventCatalogRejected,
|
||||
EventDownloadStarted,
|
||||
EventDownloadProgress,
|
||||
EventDownloadPaused,
|
||||
EventDownloadCompleted,
|
||||
EventDownloadFailed,
|
||||
EventDownloadCanceled,
|
||||
EventInstallCompleted,
|
||||
EventInstallRolledBack,
|
||||
EventAppStarted,
|
||||
EventAppExited,
|
||||
EventLicenseChanged,
|
||||
EventIconReady,
|
||||
EventIconFailed,
|
||||
}
|
||||
|
||||
for _, eventType := range eventTypes {
|
||||
if !eventType.Valid() {
|
||||
t.Errorf("event type %q should be valid", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
if EventType("Unknown").Valid() {
|
||||
t.Fatal("unknown event type should be invalid")
|
||||
}
|
||||
}
|
||||
@@ -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,72 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidEvent indicates that an event is not part of the event contract.
|
||||
ErrInvalidEvent = errors.New("invalid application event")
|
||||
// ErrRuntimeClosed indicates that the runtime no longer accepts events.
|
||||
ErrRuntimeClosed = errors.New("application runtime closed")
|
||||
)
|
||||
|
||||
// Runtime is the minimal event bus shared by background use cases and adapters.
|
||||
type Runtime struct {
|
||||
events chan Event
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewRuntime creates an event runtime with the requested queue capacity.
|
||||
func NewRuntime(buffer int) *Runtime {
|
||||
if buffer < 0 {
|
||||
panic("application runtime buffer must not be negative")
|
||||
}
|
||||
return &Runtime{
|
||||
events: make(chan Event, buffer),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Publish queues an event or returns when the context/runtime is closed.
|
||||
func (runtime *Runtime) Publish(ctx context.Context, event Event) error {
|
||||
if !event.Type.Valid() {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtime.done:
|
||||
return ErrRuntimeClosed
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtime.done:
|
||||
return ErrRuntimeClosed
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case runtime.events <- event:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Events exposes the read-only event stream to adapters.
|
||||
func (runtime *Runtime) Events() <-chan Event {
|
||||
return runtime.events
|
||||
}
|
||||
|
||||
// Done is closed when the runtime stops accepting events.
|
||||
func (runtime *Runtime) Done() <-chan struct{} {
|
||||
return runtime.done
|
||||
}
|
||||
|
||||
// Close stops future publishes. It is safe to call more than once.
|
||||
func (runtime *Runtime) Close() {
|
||||
runtime.closeOnce.Do(func() {
|
||||
close(runtime.done)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimePublish(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
event := Event{
|
||||
Type: EventDownloadStarted,
|
||||
RequestID: "request-1",
|
||||
AppID: "json-parser",
|
||||
}
|
||||
|
||||
if err := runtime.Publish(context.Background(), event); err != nil {
|
||||
t.Fatalf("Publish() error = %v", err)
|
||||
}
|
||||
|
||||
got := <-runtime.Events()
|
||||
if got != event {
|
||||
t.Fatalf("Events() got %#v, want %#v", got, event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRejectsInvalidEvent(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
|
||||
err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")})
|
||||
if !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePublishHonorsContextCancellation(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil {
|
||||
t.Fatalf("first Publish() error = %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := runtime.Publish(ctx, Event{Type: EventDownloadProgress})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeClose(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
runtime.Close()
|
||||
runtime.Close()
|
||||
|
||||
select {
|
||||
case <-runtime.Done():
|
||||
default:
|
||||
t.Fatal("Done() should be closed")
|
||||
}
|
||||
|
||||
err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted})
|
||||
if !errors.Is(err, ErrRuntimeClosed) {
|
||||
t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var integerJSONNumber = regexp.MustCompile(`^(0|[1-9][0-9]*|-[1-9][0-9]*)$`)
|
||||
|
||||
func parseRestrictedJSON(data []byte) (any, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument)
|
||||
}
|
||||
if err := validateJSONStringSurrogates(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
|
||||
value, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: trailing data: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateJSONStringSurrogates(data []byte) error {
|
||||
for index := 0; index < len(data); index++ {
|
||||
if data[index] != '"' {
|
||||
continue
|
||||
}
|
||||
next, err := scanJSONStringSurrogates(data, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index = next - 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanJSONStringSurrogates(data []byte, start int) (int, error) {
|
||||
for index := start + 1; index < len(data); index++ {
|
||||
switch data[index] {
|
||||
case '"':
|
||||
return index + 1, nil
|
||||
case '\\':
|
||||
if index+1 >= len(data) {
|
||||
return 0, fmt.Errorf("%w: incomplete string escape", ErrInvalidDocument)
|
||||
}
|
||||
if data[index+1] != 'u' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
codeUnit, ok := decodeJSONHexCodeUnit(data, index+2)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: invalid unicode escape", ErrInvalidDocument)
|
||||
}
|
||||
switch {
|
||||
case codeUnit >= 0xd800 && codeUnit <= 0xdbff:
|
||||
if index+7 >= len(data) || data[index+6] != '\\' || data[index+7] != 'u' {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not paired", ErrInvalidDocument)
|
||||
}
|
||||
lowSurrogate, ok := decodeJSONHexCodeUnit(data, index+8)
|
||||
if !ok || lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not followed by a low surrogate", ErrInvalidDocument)
|
||||
}
|
||||
index += 11
|
||||
case codeUnit >= 0xdc00 && codeUnit <= 0xdfff:
|
||||
return 0, fmt.Errorf("%w: low surrogate has no high surrogate", ErrInvalidDocument)
|
||||
default:
|
||||
index += 5
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: unterminated string", ErrInvalidDocument)
|
||||
}
|
||||
|
||||
func decodeJSONHexCodeUnit(data []byte, start int) (uint16, bool) {
|
||||
if start+4 > len(data) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, digit := range data[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case digit >= '0' && digit <= '9':
|
||||
value |= uint16(digit - '0')
|
||||
case digit >= 'a' && digit <= 'f':
|
||||
value |= uint16(digit-'a') + 10
|
||||
case digit >= 'A' && digit <= 'F':
|
||||
value |= uint16(digit-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func decodeJSONValue(decoder *json.Decoder) (any, error) {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case json.Delim:
|
||||
switch value {
|
||||
case '{':
|
||||
object := make(map[string]any)
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: object key: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
|
||||
}
|
||||
if _, exists := object[key]; exists {
|
||||
return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key)
|
||||
}
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object[key] = child
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
|
||||
}
|
||||
return object, nil
|
||||
case '[':
|
||||
var array []any
|
||||
for decoder.More() {
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
array = append(array, child)
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
|
||||
}
|
||||
return array, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unexpected delimiter %q", ErrInvalidDocument, value)
|
||||
}
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||
}
|
||||
return value, nil
|
||||
case string, bool, nil:
|
||||
return value, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported token %T", ErrInvalidDocument, token)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
if err := appendCanonicalJSON(&buffer, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
buffer.WriteString("null")
|
||||
case bool:
|
||||
if value {
|
||||
buffer.WriteString("true")
|
||||
} else {
|
||||
buffer.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode string: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
buffer.Write(encoded)
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||
}
|
||||
buffer.WriteString(string(value))
|
||||
case []any:
|
||||
buffer.WriteByte('[')
|
||||
for index, child := range value {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
if err := appendCanonicalJSON(buffer, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
buffer.WriteByte('{')
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
encodedKey, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode key: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
buffer.Write(encodedKey)
|
||||
buffer.WriteByte(':')
|
||||
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported value %T", ErrInvalidDocument, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type canonicalVectorCorpus struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
PublicKeyBase64 string `json:"public_key_base64"`
|
||||
Vectors []canonicalVector `json:"vectors"`
|
||||
}
|
||||
|
||||
type canonicalVector struct {
|
||||
Name string `json:"name"`
|
||||
Document string `json:"document"`
|
||||
SignedPayloadBase64 string `json:"signed_payload_base64"`
|
||||
Signature string `json:"signature"`
|
||||
WantError string `json:"want_error"`
|
||||
}
|
||||
|
||||
func TestVerifierCanonicalVectors(t *testing.T) {
|
||||
corpus := readCanonicalVectorCorpus(t)
|
||||
publicKey, err := base64.StdEncoding.DecodeString(corpus.PublicKeyBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode corpus public key: %v", err)
|
||||
}
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
for _, vector := range corpus.Vectors {
|
||||
vector := vector
|
||||
t.Run(vector.Name, func(t *testing.T) {
|
||||
verified, err := verifier.Verify([]byte(vector.Document))
|
||||
if vector.WantError != "" {
|
||||
want := canonicalVectorError(t, vector.WantError)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
|
||||
expectedPayload, err := base64.StdEncoding.DecodeString(vector.SignedPayloadBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode static signed payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(verified.SignedPayload, expectedPayload) {
|
||||
t.Fatalf(
|
||||
"SignedPayload = %q, want static vector %q",
|
||||
verified.SignedPayload,
|
||||
expectedPayload,
|
||||
)
|
||||
}
|
||||
|
||||
signature, err := base64.StdEncoding.DecodeString(vector.Signature)
|
||||
if err != nil {
|
||||
t.Fatalf("decode static signature: %v", err)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(publicKey), expectedPayload, signature) {
|
||||
t.Fatal("static signature does not verify the static signed payload")
|
||||
}
|
||||
if got := vectorDocumentSignature(t, vector.Document); got != vector.Signature {
|
||||
t.Fatalf("document signature = %q, want static vector %q", got, vector.Signature)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserRejectsNonCanonicalSignatureVectorText(t *testing.T) {
|
||||
corpus := readCanonicalVectorCorpus(t)
|
||||
for _, vector := range corpus.Vectors {
|
||||
if vector.WantError != "signature_invalid" {
|
||||
continue
|
||||
}
|
||||
vector := vector
|
||||
t.Run(vector.Name, func(t *testing.T) {
|
||||
if err := validateSignature(vectorDocumentSignature(t, vector.Document)); err == nil {
|
||||
t.Fatal("validateSignature() accepted a non-canonical signature text")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserRejectsNonCanonicalPackageSignatureVectors(t *testing.T) {
|
||||
corpus := readCanonicalVectorCorpus(t)
|
||||
for _, vector := range corpus.Vectors {
|
||||
if vector.WantError != "signature_invalid" {
|
||||
continue
|
||||
}
|
||||
vector := vector
|
||||
t.Run(vector.Name, func(t *testing.T) {
|
||||
manifest := validManifestForTest()
|
||||
publishedPackage := manifest.Apps[0].Packages[ArchitectureAMD64]
|
||||
publishedPackage.Signature = vectorDocumentSignature(t, vector.Document)
|
||||
manifest.Apps[0].Packages[ArchitectureAMD64] = publishedPackage
|
||||
|
||||
_, err := parseSignedManifestForTest(t, manifest, ChannelModern)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readCanonicalVectorCorpus(t *testing.T) canonicalVectorCorpus {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "testdata", "catalog", "canonical-vectors.json")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open canonical vector corpus: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
decoder := json.NewDecoder(file)
|
||||
decoder.DisallowUnknownFields()
|
||||
var corpus canonicalVectorCorpus
|
||||
if err := decoder.Decode(&corpus); err != nil {
|
||||
t.Fatalf("decode canonical vector corpus: %v", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
t.Fatalf("canonical vector corpus has trailing data: %v", err)
|
||||
}
|
||||
if corpus.SchemaVersion != 1 {
|
||||
t.Fatalf("corpus schema_version = %d, want 1", corpus.SchemaVersion)
|
||||
}
|
||||
if len(corpus.Vectors) == 0 {
|
||||
t.Fatal("corpus has no vectors")
|
||||
}
|
||||
return corpus
|
||||
}
|
||||
|
||||
func canonicalVectorError(t *testing.T, value string) error {
|
||||
t.Helper()
|
||||
switch value {
|
||||
case "invalid_document":
|
||||
return ErrInvalidDocument
|
||||
case "unsupported_number":
|
||||
return ErrUnsupportedNumber
|
||||
case "signature_invalid":
|
||||
return ErrSignatureInvalid
|
||||
default:
|
||||
t.Fatalf("unsupported corpus want_error %q", value)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func vectorDocumentSignature(t *testing.T, document string) string {
|
||||
t.Helper()
|
||||
var root struct {
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(document), &root); err != nil {
|
||||
t.Fatalf("decode vector document signature: %v", err)
|
||||
}
|
||||
if root.Signature == "" {
|
||||
t.Fatal("vector document has no signature")
|
||||
}
|
||||
return root.Signature
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package catalog
|
||||
|
||||
import "context"
|
||||
|
||||
// Client composes signed loading, protocol validation and target filtering.
|
||||
type Client struct {
|
||||
loader *Loader
|
||||
parser Parser
|
||||
target Target
|
||||
}
|
||||
|
||||
// NewClient creates the formal Catalog loading path. Protocol validation is
|
||||
// installed on Loader so invalid remote data cannot replace the usable cache.
|
||||
func NewClient(
|
||||
verifier Verifier,
|
||||
fetcher Fetcher,
|
||||
cache Cache,
|
||||
target Target,
|
||||
) *Client {
|
||||
parser := Parser{ExpectedChannel: target.Channel}
|
||||
return &Client{
|
||||
loader: NewLoader(verifier, fetcher, cache, parser),
|
||||
parser: parser,
|
||||
target: target,
|
||||
}
|
||||
}
|
||||
|
||||
// Load returns a verified, validated and target-filtered Catalog.
|
||||
func (client *Client) Load(ctx context.Context) (Catalog, error) {
|
||||
result, err := client.loader.Load(ctx)
|
||||
if err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
manifest, err := client.parser.Parse(result.Document)
|
||||
if err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
entries, err := Filter(manifest, client.target)
|
||||
if err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
return Catalog{
|
||||
Manifest: manifest,
|
||||
Entries: entries,
|
||||
Source: result.Source,
|
||||
Warning: result.Warning,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientValidatesBeforeReplacingCache(t *testing.T) {
|
||||
publicKey, privateKey := catalogTestKey()
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||
validDocument, _ := signCatalogPayload(t, validPayload, privateKey)
|
||||
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||
|
||||
var invalidPayload map[string]any
|
||||
if err := json.Unmarshal(validPayload, &invalidPayload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
invalidPayload["channel"] = "win7"
|
||||
encodedInvalid, err := json.Marshal(invalidPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode invalid payload: %v", err)
|
||||
}
|
||||
invalidDocument, _ := signCatalogPayload(t, encodedInvalid, privateKey)
|
||||
|
||||
client := NewClient(
|
||||
verifier,
|
||||
FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return invalidDocument, nil
|
||||
}),
|
||||
cache,
|
||||
Target{
|
||||
Channel: ChannelModern,
|
||||
OS: Windows10,
|
||||
Architecture: ArchitectureAMD64,
|
||||
},
|
||||
)
|
||||
result, err := client.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Source != SourceCache {
|
||||
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||
}
|
||||
if !errors.Is(result.Warning, ErrChannelMismatch) {
|
||||
t.Fatalf("Warning = %v, want %v", result.Warning, ErrChannelMismatch)
|
||||
}
|
||||
if cache.storeCalls != 0 {
|
||||
t.Fatalf("Store() calls = %d, want 0", cache.storeCalls)
|
||||
}
|
||||
if len(result.Entries) != 1 || result.Entries[0].App.ID != "json-parser" {
|
||||
t.Fatalf("Entries = %#v", result.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrCachePathEmpty = errors.New("catalog cache path is empty")
|
||||
|
||||
// FileCache stores a signed Catalog document and a crash-recovery backup.
|
||||
type FileCache struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileCache(path string) *FileCache {
|
||||
return &FileCache{path: path}
|
||||
}
|
||||
|
||||
func (cache *FileCache) Load() ([]byte, error) {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
|
||||
if cache.path == "" {
|
||||
return nil, ErrCachePathEmpty
|
||||
}
|
||||
document, err := os.ReadFile(cache.path)
|
||||
if err == nil {
|
||||
return document, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(cache.backupPath())
|
||||
}
|
||||
|
||||
func (cache *FileCache) Store(document []byte) error {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
|
||||
if cache.path == "" {
|
||||
return ErrCachePathEmpty
|
||||
}
|
||||
directory := filepath.Dir(cache.path)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return fmt.Errorf("create catalog cache directory: %w", err)
|
||||
}
|
||||
|
||||
temporary, err := os.CreateTemp(directory, ".catalog-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create catalog 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 catalog cache temp file: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(document); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("write catalog cache temp file: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("sync catalog cache temp file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close catalog cache temp file: %w", err)
|
||||
}
|
||||
|
||||
backupPath := cache.backupPath()
|
||||
movedCurrent := false
|
||||
if _, err := os.Stat(cache.path); err == nil {
|
||||
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale catalog cache backup: %w", err)
|
||||
}
|
||||
if err := os.Rename(cache.path, backupPath); err != nil {
|
||||
return fmt.Errorf("backup current catalog cache: %w", err)
|
||||
}
|
||||
movedCurrent = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect current catalog cache: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(temporaryPath, cache.path); err != nil {
|
||||
if movedCurrent {
|
||||
_ = os.Rename(backupPath, cache.path)
|
||||
}
|
||||
return fmt.Errorf("activate catalog cache: %w", err)
|
||||
}
|
||||
if movedCurrent {
|
||||
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove catalog cache backup: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cache *FileCache) backupPath() string {
|
||||
return cache.path + ".backup"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileCacheStoreAndUpdate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache", "manifest.json")
|
||||
cache := NewFileCache(path)
|
||||
|
||||
if err := cache.Store([]byte("first")); err != nil {
|
||||
t.Fatalf("Store(first) error = %v", err)
|
||||
}
|
||||
if err := cache.Store([]byte("second")); err != nil {
|
||||
t.Fatalf("Store(second) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := cache.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if string(got) != "second" {
|
||||
t.Fatalf("Load() = %q, want %q", got, "second")
|
||||
}
|
||||
if _, err := os.Stat(path + ".backup"); !os.IsNotExist(err) {
|
||||
t.Fatalf("backup should be removed after successful update, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCacheLoadsBackupAfterInterruptedSwitch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
cache := NewFileCache(path)
|
||||
if err := cache.Store([]byte("verified")); err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
if err := os.Rename(path, path+".backup"); err != nil {
|
||||
t.Fatalf("simulate interrupted switch: %v", err)
|
||||
}
|
||||
|
||||
got, err := cache.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if string(got) != "verified" {
|
||||
t.Fatalf("Load() = %q, want %q", got, "verified")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package catalog
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Filter returns visible entries for target while retaining incompatible apps
|
||||
// with a stable reason and no package action.
|
||||
func Filter(manifest Manifest, target Target) ([]Entry, error) {
|
||||
if !target.Channel.valid() ||
|
||||
!target.OS.valid() ||
|
||||
!target.Architecture.valid() {
|
||||
return nil, fmt.Errorf("%w: %+v", ErrUnsupportedTarget, target)
|
||||
}
|
||||
if manifest.Channel != target.Channel {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
ErrChannelMismatch,
|
||||
manifest.Channel,
|
||||
target.Channel,
|
||||
)
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(manifest.Apps))
|
||||
for _, app := range manifest.Apps {
|
||||
if app.Status == CatalogStatusHidden {
|
||||
continue
|
||||
}
|
||||
entry := Entry{App: app}
|
||||
if app.Status == CatalogStatusDeprecated {
|
||||
entry.Reason = ReasonDeprecated
|
||||
entries = append(entries, entry)
|
||||
continue
|
||||
}
|
||||
if !supportsOS(target.OS, app.MinOS) {
|
||||
entry.Reason = ReasonMinimumOS
|
||||
entries = append(entries, entry)
|
||||
continue
|
||||
}
|
||||
publishedPackage, ok := app.Packages[target.Architecture]
|
||||
if !ok {
|
||||
entry.Reason = ReasonArchitecture
|
||||
entries = append(entries, entry)
|
||||
continue
|
||||
}
|
||||
entry.Package = &publishedPackage
|
||||
entry.Installable = true
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func supportsOS(target, minimum WindowsRelease) bool {
|
||||
ranks := map[WindowsRelease]int{
|
||||
Windows7SP1: 7,
|
||||
Windows10: 10,
|
||||
Windows11: 11,
|
||||
}
|
||||
return ranks[target] >= ranks[minimum]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterHandlesStatusOSAndArchitecture(t *testing.T) {
|
||||
manifest := validManifestForTest()
|
||||
active := manifest.Apps[0]
|
||||
|
||||
deprecated := active
|
||||
deprecated.ID = "deprecated-app"
|
||||
deprecated.Status = CatalogStatusDeprecated
|
||||
|
||||
hidden := active
|
||||
hidden.ID = "hidden-app"
|
||||
hidden.Status = CatalogStatusHidden
|
||||
|
||||
newOS := active
|
||||
newOS.ID = "windows-11-app"
|
||||
newOS.MinOS = Windows11
|
||||
|
||||
wrongArchitecture := active
|
||||
wrongArchitecture.ID = "x86-app"
|
||||
wrongArchitecture.Architectures = []Architecture{Architecture386}
|
||||
wrongArchitecture.Packages = map[Architecture]Package{
|
||||
Architecture386: active.Packages[ArchitectureAMD64],
|
||||
}
|
||||
manifest.Apps = []App{active, deprecated, hidden, newOS, wrongArchitecture}
|
||||
|
||||
entries, err := Filter(manifest, Target{
|
||||
Channel: ChannelModern,
|
||||
OS: Windows10,
|
||||
Architecture: ArchitectureAMD64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Filter() error = %v", err)
|
||||
}
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("len(entries) = %d, want 4", len(entries))
|
||||
}
|
||||
|
||||
assertEntry := func(index int, id string, installable bool, reason AvailabilityReason) {
|
||||
t.Helper()
|
||||
entry := entries[index]
|
||||
if entry.App.ID != id ||
|
||||
entry.Installable != installable ||
|
||||
entry.Reason != reason {
|
||||
t.Fatalf(
|
||||
"entries[%d] = {%q %t %q}, want {%q %t %q}",
|
||||
index,
|
||||
entry.App.ID,
|
||||
entry.Installable,
|
||||
entry.Reason,
|
||||
id,
|
||||
installable,
|
||||
reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
assertEntry(0, "json-parser", true, ReasonNone)
|
||||
assertEntry(1, "deprecated-app", false, ReasonDeprecated)
|
||||
assertEntry(2, "windows-11-app", false, ReasonMinimumOS)
|
||||
assertEntry(3, "x86-app", false, ReasonArchitecture)
|
||||
}
|
||||
|
||||
func TestFilterAllowsWin7CompatibleAppOnModernWindows(t *testing.T) {
|
||||
manifest := validManifestForTest()
|
||||
manifest.Apps[0].MinOS = Windows7SP1
|
||||
|
||||
entries, err := Filter(manifest, Target{
|
||||
Channel: ChannelModern,
|
||||
OS: Windows11,
|
||||
Architecture: ArchitectureAMD64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Filter() error = %v", err)
|
||||
}
|
||||
if len(entries) != 1 || !entries[0].Installable {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultMaxManifestBytes int64 = 8 << 20
|
||||
|
||||
var (
|
||||
ErrInsecureCatalogURL = errors.New("catalog URL must use HTTPS")
|
||||
ErrCatalogHTTPStatus = errors.New("catalog HTTP status is not successful")
|
||||
ErrCatalogTooLarge = errors.New("catalog response exceeds size limit")
|
||||
)
|
||||
|
||||
// HTTPFetcher obtains a Catalog document over HTTPS with a bounded response.
|
||||
type HTTPFetcher struct {
|
||||
URL string
|
||||
Client *http.Client
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
// Fetch implements Fetcher.
|
||||
func (fetcher HTTPFetcher) Fetch(ctx context.Context) ([]byte, error) {
|
||||
parsedURL, err := url.Parse(fetcher.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse catalog URL: %w", err)
|
||||
}
|
||||
if err := validateFetchURL(parsedURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create catalog request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := fetcher.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
clientCopy := *client
|
||||
previousRedirectCheck := client.CheckRedirect
|
||||
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if err := validateFetchURL(request.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if previousRedirectCheck != nil {
|
||||
return previousRedirectCheck(request, via)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
response, err := clientCopy.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch catalog: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("%w: %s", ErrCatalogHTTPStatus, response.Status)
|
||||
}
|
||||
maxBytes := fetcher.MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxManifestBytes
|
||||
}
|
||||
if response.ContentLength > maxBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: content-length %d, limit %d",
|
||||
ErrCatalogTooLarge,
|
||||
response.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
document, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read catalog response: %w", err)
|
||||
}
|
||||
if int64(len(document)) > maxBytes {
|
||||
return nil, fmt.Errorf("%w: limit %d", ErrCatalogTooLarge, maxBytes)
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func validateFetchURL(parsedURL *url.URL) error {
|
||||
if parsedURL == nil ||
|
||||
parsedURL.Scheme != "https" ||
|
||||
parsedURL.Host == "" ||
|
||||
parsedURL.User != nil {
|
||||
return ErrInsecureCatalogURL
|
||||
}
|
||||
if parsedURL.Fragment != "" {
|
||||
return fmt.Errorf("%w: fragments are not allowed", ErrInsecureCatalogURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPFetcherRequiresHTTPSAndBoundsResponse(t *testing.T) {
|
||||
t.Run("insecure URL", func(t *testing.T) {
|
||||
_, err := (HTTPFetcher{URL: "http://example.invalid/manifest.json"}).
|
||||
Fetch(context.Background())
|
||||
if !errors.Is(err, ErrInsecureCatalogURL) {
|
||||
t.Fatalf("Fetch() error = %v, want %v", err, ErrInsecureCatalogURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful HTTPS", func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
if request.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("Accept = %q", request.Header.Get("Accept"))
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"schema_version":1}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
document, err := (HTTPFetcher{
|
||||
URL: server.URL,
|
||||
Client: server.Client(),
|
||||
MaxBytes: 64,
|
||||
}).Fetch(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch() error = %v", err)
|
||||
}
|
||||
if string(document) != `{"schema_version":1}` {
|
||||
t.Fatalf("document = %q", document)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too large", func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Content-Length", "6")
|
||||
_, _ = writer.Write([]byte("123456"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (HTTPFetcher{
|
||||
URL: server.URL,
|
||||
Client: server.Client(),
|
||||
MaxBytes: 5,
|
||||
}).Fetch(context.Background())
|
||||
if !errors.Is(err, ErrCatalogTooLarge) {
|
||||
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogTooLarge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP error", func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (HTTPFetcher{
|
||||
URL: server.URL,
|
||||
Client: server.Client(),
|
||||
}).Fetch(context.Background())
|
||||
if !errors.Is(err, ErrCatalogHTTPStatus) {
|
||||
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogHTTPStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxIconBytes int64 = 2 << 20
|
||||
DefaultMaxIconDimension = 2048
|
||||
DefaultIconMemoryBytes int64 = 32 << 20
|
||||
DefaultIconMemoryEntries = 256
|
||||
UnknownIconContentLength int64 = -1
|
||||
)
|
||||
|
||||
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")
|
||||
ErrIconResponseInvalid = errors.New("icon fetch response 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
|
||||
}
|
||||
|
||||
// IconFetchResponse streams one icon while preserving its optional declared size.
|
||||
// Body ownership transfers to IconCache and reads must stop when the FetchIcon
|
||||
// context is canceled.
|
||||
type IconFetchResponse struct {
|
||||
Body io.ReadCloser
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// IconFetcher opens an icon response outside Gio Layout.
|
||||
type IconFetcher interface {
|
||||
FetchIcon(context.Context, IconRequest) (IconFetchResponse, error)
|
||||
}
|
||||
|
||||
// IconFetchFunc adapts a function to IconFetcher.
|
||||
type IconFetchFunc func(context.Context, IconRequest) (IconFetchResponse, error)
|
||||
|
||||
func (function IconFetchFunc) FetchIcon(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) (IconFetchResponse, 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 *iconMemoryCache
|
||||
inflight map[string]*iconFlight
|
||||
}
|
||||
|
||||
type iconFlight struct {
|
||||
done chan struct{}
|
||||
result IconResult
|
||||
err error
|
||||
}
|
||||
|
||||
// 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: newIconMemoryCache(
|
||||
DefaultIconMemoryBytes,
|
||||
DefaultIconMemoryEntries,
|
||||
),
|
||||
inflight: make(map[string]*iconFlight),
|
||||
}
|
||||
}
|
||||
|
||||
// Load resolves memory, verified disk, then verified remote bytes.
|
||||
func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) {
|
||||
digest, key, err := cacheKey(request)
|
||||
if err != nil {
|
||||
return IconResult{}, err
|
||||
}
|
||||
|
||||
cache.mu.Lock()
|
||||
if document, exists := cache.memory.get(key); exists {
|
||||
cache.mu.Unlock()
|
||||
return IconResult{
|
||||
Bytes: document,
|
||||
Source: IconSourceMemory,
|
||||
}, nil
|
||||
}
|
||||
if flight, exists := cache.inflight[key]; exists {
|
||||
cache.mu.Unlock()
|
||||
select {
|
||||
case <-flight.done:
|
||||
return cloneIconResult(flight.result), flight.err
|
||||
case <-ctx.Done():
|
||||
return IconResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
flight := &iconFlight{done: make(chan struct{})}
|
||||
cache.inflight[key] = flight
|
||||
cache.mu.Unlock()
|
||||
|
||||
result, loadErr := cache.loadUncached(ctx, request, digest)
|
||||
|
||||
cache.mu.Lock()
|
||||
if loadErr == nil {
|
||||
cache.memory.put(key, result.Bytes)
|
||||
}
|
||||
flight.result = cloneIconResult(result)
|
||||
flight.err = loadErr
|
||||
delete(cache.inflight, key)
|
||||
close(flight.done)
|
||||
cache.mu.Unlock()
|
||||
|
||||
return cloneIconResult(result), loadErr
|
||||
}
|
||||
|
||||
func (cache *IconCache) loadUncached(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
digest string,
|
||||
) (IconResult, error) {
|
||||
filePath, pathErr := cache.filePath(digest, request.DPI)
|
||||
if pathErr != nil {
|
||||
return IconResult{}, pathErr
|
||||
}
|
||||
document, diskErr := cache.loadDisk(filePath, digest)
|
||||
if diskErr == nil {
|
||||
return IconResult{
|
||||
Bytes: 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"),
|
||||
}
|
||||
}
|
||||
response, fetchErr := cache.fetcher.FetchIcon(ctx, request)
|
||||
if fetchErr != nil {
|
||||
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr}
|
||||
}
|
||||
document, fetchErr = cache.readFetchedIcon(ctx, response)
|
||||
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)
|
||||
return IconResult{
|
||||
Bytes: document,
|
||||
Source: IconSourceRemote,
|
||||
Warning: storeErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) readFetchedIcon(
|
||||
ctx context.Context,
|
||||
response IconFetchResponse,
|
||||
) (document []byte, resultErr error) {
|
||||
if response.Body == nil {
|
||||
return nil, fmt.Errorf("%w: nil body", ErrIconResponseInvalid)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := response.Body.Close(); resultErr == nil && closeErr != nil {
|
||||
resultErr = fmt.Errorf("close icon response: %w", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if response.ContentLength < UnknownIconContentLength {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: content length %d",
|
||||
ErrIconResponseInvalid,
|
||||
response.ContentLength,
|
||||
)
|
||||
}
|
||||
maxBytes := cache.iconByteLimit()
|
||||
if response.ContentLength > maxBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: declared bytes=%d limit=%d",
|
||||
ErrIconTooLarge,
|
||||
response.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
document, readErr := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read icon response: %w", readErr)
|
||||
}
|
||||
if int64(len(document)) > maxBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: bytes=%d limit=%d",
|
||||
ErrIconTooLarge,
|
||||
len(document),
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (cache *IconCache) iconByteLimit() int64 {
|
||||
if cache.maxBytes <= 0 {
|
||||
return DefaultMaxIconBytes
|
||||
}
|
||||
return cache.maxBytes
|
||||
}
|
||||
|
||||
func cloneIconResult(result IconResult) IconResult {
|
||||
result.Bytes = append([]byte(nil), result.Bytes...)
|
||||
return result
|
||||
}
|
||||
|
||||
// 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.iconByteLimit()
|
||||
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,411 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type iconLoadOutcome struct {
|
||||
result IconResult
|
||||
err error
|
||||
}
|
||||
|
||||
func TestIconCacheMemoryHitDoesNotWaitForDifferentKey(t *testing.T) {
|
||||
fastDocument := testPNG(t, 15, 15)
|
||||
slowDocument := testPNG(t, 16, 16)
|
||||
fastRequest := iconRequest(fastDocument, 96)
|
||||
slowRequest := iconRequest(slowDocument, 96)
|
||||
slowStarted := make(chan struct{})
|
||||
releaseSlow := make(chan struct{})
|
||||
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
if request.Reference == slowRequest.Reference {
|
||||
close(slowStarted)
|
||||
select {
|
||||
case <-releaseSlow:
|
||||
case <-ctx.Done():
|
||||
return IconFetchResponse{}, ctx.Err()
|
||||
}
|
||||
return iconResponse(slowDocument), nil
|
||||
}
|
||||
return iconResponse(fastDocument), nil
|
||||
}))
|
||||
|
||||
if _, err := cache.Load(context.Background(), fastRequest); err != nil {
|
||||
t.Fatalf("Load(seed memory) error = %v", err)
|
||||
}
|
||||
slowOutcome := loadIconAsync(cache, context.Background(), slowRequest)
|
||||
waitSignal(t, slowStarted, "slow fetch did not start")
|
||||
|
||||
fastOutcome := loadIconAsync(cache, context.Background(), fastRequest)
|
||||
select {
|
||||
case outcome := <-fastOutcome:
|
||||
if outcome.err != nil || outcome.result.Source != IconSourceMemory {
|
||||
t.Fatalf("memory outcome = %#v, %v", outcome.result, outcome.err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
close(releaseSlow)
|
||||
t.Fatal("memory hit waited for an unrelated slow fetch")
|
||||
}
|
||||
|
||||
close(releaseSlow)
|
||||
if outcome := waitOutcome(t, slowOutcome); outcome.err != nil {
|
||||
t.Fatalf("slow Load() error = %v", outcome.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheFetchesDifferentKeysConcurrently(t *testing.T) {
|
||||
documents := [][]byte{testPNG(t, 17, 16), testPNG(t, 18, 16)}
|
||||
requests := []IconRequest{
|
||||
iconRequest(documents[0], 96),
|
||||
iconRequest(documents[1], 96),
|
||||
}
|
||||
documentByReference := map[string][]byte{
|
||||
requests[0].Reference: documents[0],
|
||||
requests[1].Reference: documents[1],
|
||||
}
|
||||
started := make(chan string, len(requests))
|
||||
release := make(chan struct{})
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
started <- request.Reference
|
||||
select {
|
||||
case <-release:
|
||||
return iconResponse(documentByReference[request.Reference]), nil
|
||||
case <-ctx.Done():
|
||||
return IconFetchResponse{}, ctx.Err()
|
||||
}
|
||||
}))
|
||||
|
||||
first := loadIconAsync(cache, context.Background(), requests[0])
|
||||
waitSignal(t, started, "first fetch did not start")
|
||||
second := loadIconAsync(cache, context.Background(), requests[1])
|
||||
waitSignal(t, started, "second key did not fetch while first key was blocked")
|
||||
close(release)
|
||||
|
||||
for index, outcomeChannel := range []<-chan iconLoadOutcome{first, second} {
|
||||
outcome := waitOutcome(t, outcomeChannel)
|
||||
if outcome.err != nil || outcome.result.Source != IconSourceRemote {
|
||||
t.Fatalf("outcome %d = %#v, %v", index, outcome.result, outcome.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheCoalescesSameKeyAndReturnsIndependentBytes(t *testing.T) {
|
||||
document := testPNG(t, 19, 16)
|
||||
request := iconRequest(document, 96)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var fetchCalls int32
|
||||
root := t.TempDir()
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
ctx context.Context,
|
||||
_ IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
atomic.AddInt32(&fetchCalls, 1)
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
return iconResponse(document), nil
|
||||
case <-ctx.Done():
|
||||
return IconFetchResponse{}, ctx.Err()
|
||||
}
|
||||
}))
|
||||
|
||||
leader := loadIconAsync(cache, context.Background(), request)
|
||||
waitSignal(t, started, "leader fetch did not start")
|
||||
|
||||
cache.mu.Lock()
|
||||
follower := loadIconAsync(cache, context.Background(), request)
|
||||
close(release)
|
||||
cache.mu.Unlock()
|
||||
|
||||
leaderOutcome := waitOutcome(t, leader)
|
||||
followerOutcome := waitOutcome(t, follower)
|
||||
if leaderOutcome.err != nil || followerOutcome.err != nil {
|
||||
t.Fatalf("coalesced errors = %v, %v", leaderOutcome.err, followerOutcome.err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fetchCalls); got != 1 {
|
||||
t.Fatalf("fetch calls = %d, want 1", got)
|
||||
}
|
||||
if !bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) {
|
||||
t.Fatal("coalesced callers received different content")
|
||||
}
|
||||
leaderOutcome.result.Bytes[0] ^= 0xff
|
||||
if bytes.Equal(leaderOutcome.result.Bytes, followerOutcome.result.Bytes) {
|
||||
t.Fatal("coalesced callers shared a mutable backing array")
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("disk entries = %v, error=%v; want one", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheFollowerCancellationDoesNotCancelLeader(t *testing.T) {
|
||||
document := testPNG(t, 20, 16)
|
||||
request := iconRequest(document, 96)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var fetchCalls int32
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
ctx context.Context,
|
||||
_ IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
atomic.AddInt32(&fetchCalls, 1)
|
||||
close(started)
|
||||
select {
|
||||
case <-release:
|
||||
return iconResponse(document), nil
|
||||
case <-ctx.Done():
|
||||
return IconFetchResponse{}, ctx.Err()
|
||||
}
|
||||
}))
|
||||
|
||||
leader := loadIconAsync(cache, context.Background(), request)
|
||||
waitSignal(t, started, "leader fetch did not start")
|
||||
followerContext, cancelFollower := context.WithCancel(context.Background())
|
||||
cancelFollower()
|
||||
if _, err := cache.Load(followerContext, request); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("follower error = %v, want context canceled", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fetchCalls); got != 1 {
|
||||
t.Fatalf("fetch calls after follower cancel = %d, want 1", got)
|
||||
}
|
||||
|
||||
close(release)
|
||||
if outcome := waitOutcome(t, leader); outcome.err != nil {
|
||||
t.Fatalf("leader error = %v", outcome.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheLeaderCancellationClearsFlightForRetry(t *testing.T) {
|
||||
document := testPNG(t, 21, 16)
|
||||
request := iconRequest(document, 96)
|
||||
firstStarted := make(chan struct{})
|
||||
var fetchCalls int32
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
ctx context.Context,
|
||||
_ IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
call := atomic.AddInt32(&fetchCalls, 1)
|
||||
if call == 1 {
|
||||
close(firstStarted)
|
||||
<-ctx.Done()
|
||||
return IconFetchResponse{}, ctx.Err()
|
||||
}
|
||||
return iconResponse(document), nil
|
||||
}))
|
||||
|
||||
leaderContext, cancelLeader := context.WithCancel(context.Background())
|
||||
first := loadIconAsync(cache, leaderContext, request)
|
||||
waitSignal(t, firstStarted, "cancelable leader did not start")
|
||||
cancelLeader()
|
||||
if outcome := waitOutcome(t, first); !errors.Is(outcome.err, context.Canceled) {
|
||||
t.Fatalf("leader error = %v, want context canceled", outcome.err)
|
||||
}
|
||||
|
||||
cache.mu.Lock()
|
||||
flights := len(cache.inflight)
|
||||
cache.mu.Unlock()
|
||||
if flights != 0 {
|
||||
t.Fatalf("in-flight entries after failure = %d, want 0", flights)
|
||||
}
|
||||
result, err := cache.Load(context.Background(), request)
|
||||
if err != nil || result.Source != IconSourceRemote {
|
||||
t.Fatalf("retry result = %#v, error=%v", result, err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fetchCalls); got != 2 {
|
||||
t.Fatalf("fetch calls after retry = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheBoundsAndClosesFetchedBodies(t *testing.T) {
|
||||
document := bytes.Repeat([]byte{0x42}, 32)
|
||||
request := iconRequest(document, 96)
|
||||
tests := []struct {
|
||||
name string
|
||||
contentLength int64
|
||||
wantRead int64
|
||||
}{
|
||||
{name: "declared oversized", contentLength: 9, wantRead: 0},
|
||||
{name: "unknown oversized", contentLength: UnknownIconContentLength, wantRead: 9},
|
||||
{name: "underdeclared oversized", contentLength: 1, wantRead: 9},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := newTrackingIconBody(document)
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{
|
||||
Body: body,
|
||||
ContentLength: test.contentLength,
|
||||
}, nil
|
||||
}))
|
||||
cache.maxBytes = 8
|
||||
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconTooLarge) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, ErrIconTooLarge)
|
||||
}
|
||||
if got := atomic.LoadInt64(&body.bytesRead); got != test.wantRead {
|
||||
t.Fatalf("body bytes read = %d, want %d", got, test.wantRead)
|
||||
}
|
||||
if got := atomic.LoadInt32(&body.closed); got != 1 {
|
||||
t.Fatalf("body close calls = %d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheClosesFetchedBodyOnValidationFailure(t *testing.T) {
|
||||
document := testPNG(t, 25, 16)
|
||||
request := iconRequest(testPNG(t, 26, 16), 96)
|
||||
body := newTrackingIconBody(document)
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{
|
||||
Body: body,
|
||||
ContentLength: int64(len(document)),
|
||||
}, nil
|
||||
}))
|
||||
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconHashMismatch) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, ErrIconHashMismatch)
|
||||
}
|
||||
if got := atomic.LoadInt64(&body.bytesRead); got != int64(len(document)) {
|
||||
t.Fatalf("body bytes read = %d, want %d", got, len(document))
|
||||
}
|
||||
if got := atomic.LoadInt32(&body.closed); got != 1 {
|
||||
t.Fatalf("body close calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheCanceledContextClosesBodyWithoutReading(t *testing.T) {
|
||||
document := testPNG(t, 27, 16)
|
||||
request := iconRequest(document, 96)
|
||||
body := newTrackingIconBody(document)
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{
|
||||
Body: body,
|
||||
ContentLength: int64(len(document)),
|
||||
}, nil
|
||||
}))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := cache.Load(ctx, request)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Load() error = %v, want context canceled", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&body.bytesRead); got != 0 {
|
||||
t.Fatalf("body bytes read = %d, want 0", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&body.closed); got != 1 {
|
||||
t.Fatalf("body close calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheRejectsInvalidFetchResponses(t *testing.T) {
|
||||
document := testPNG(t, 28, 16)
|
||||
request := iconRequest(document, 96)
|
||||
t.Run("nil body", func(t *testing.T) {
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{ContentLength: int64(len(document))}, nil
|
||||
}))
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconResponseInvalid) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid)
|
||||
}
|
||||
})
|
||||
t.Run("invalid content length", func(t *testing.T) {
|
||||
body := newTrackingIconBody(document)
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{Body: body, ContentLength: -2}, nil
|
||||
}))
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconResponseInvalid) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, ErrIconResponseInvalid)
|
||||
}
|
||||
if got := atomic.LoadInt64(&body.bytesRead); got != 0 {
|
||||
t.Fatalf("body bytes read = %d, want 0", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&body.closed); got != 1 {
|
||||
t.Fatalf("body close calls = %d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type trackingIconBody struct {
|
||||
reader *bytes.Reader
|
||||
bytesRead int64
|
||||
closed int32
|
||||
}
|
||||
|
||||
func newTrackingIconBody(document []byte) *trackingIconBody {
|
||||
return &trackingIconBody{reader: bytes.NewReader(document)}
|
||||
}
|
||||
|
||||
func (body *trackingIconBody) Read(buffer []byte) (int, error) {
|
||||
read, err := body.reader.Read(buffer)
|
||||
atomic.AddInt64(&body.bytesRead, int64(read))
|
||||
return read, err
|
||||
}
|
||||
|
||||
func (body *trackingIconBody) Close() error {
|
||||
atomic.AddInt32(&body.closed, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadIconAsync(
|
||||
cache *IconCache,
|
||||
ctx context.Context,
|
||||
request IconRequest,
|
||||
) <-chan iconLoadOutcome {
|
||||
outcome := make(chan iconLoadOutcome, 1)
|
||||
go func() {
|
||||
result, err := cache.Load(ctx, request)
|
||||
outcome <- iconLoadOutcome{result: result, err: err}
|
||||
}()
|
||||
return outcome
|
||||
}
|
||||
|
||||
func waitSignal[T any](t *testing.T, signal <-chan T, failure string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-signal:
|
||||
return value
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal(failure)
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
func waitOutcome(t *testing.T, outcome <-chan iconLoadOutcome) iconLoadOutcome {
|
||||
t.Helper()
|
||||
return waitSignal(t, outcome, "icon load did not finish")
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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,
|
||||
) (IconFetchResponse, error) {
|
||||
fetchCalls++
|
||||
return iconResponse(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,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{}, 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,
|
||||
) (IconFetchResponse, error) {
|
||||
fetchCalls++
|
||||
return iconResponse(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,
|
||||
) (IconFetchResponse, error) {
|
||||
return iconResponse(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,
|
||||
) (IconFetchResponse, error) {
|
||||
return iconResponse(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,
|
||||
) (IconFetchResponse, error) {
|
||||
repairs++
|
||||
return iconResponse(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,
|
||||
) (IconFetchResponse, error) {
|
||||
return IconFetchResponse{}, 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 TestIconCacheRejectsUnsafeDiskEntriesWithoutFallback(t *testing.T) {
|
||||
document := testPNG(t, 16, 16)
|
||||
request := iconRequest(document, 120)
|
||||
|
||||
t.Run("directory", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
entryPath := testIconCacheEntryPath(root, request)
|
||||
if err := os.Mkdir(entryPath, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir(cache entry) error = %v", err)
|
||||
}
|
||||
markerPath := filepath.Join(entryPath, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(marker) error = %v", err)
|
||||
}
|
||||
fetchCalls := 0
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
fetchCalls++
|
||||
return iconResponse(document), nil
|
||||
}))
|
||||
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconCacheUnsafe) {
|
||||
t.Fatalf("Load(directory) error = %v, want %v", err, ErrIconCacheUnsafe)
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("unsafe directory triggered %d fetches", fetchCalls)
|
||||
}
|
||||
marker, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil || string(marker) != "keep" {
|
||||
t.Fatalf("unsafe directory marker = %q, %v", marker, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
entryPath := testIconCacheEntryPath(root, request)
|
||||
targetPath := filepath.Join(t.TempDir(), "external-target.icon")
|
||||
target := []byte("external target must remain untouched")
|
||||
if err := os.WriteFile(targetPath, target, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
if err := os.Symlink(targetPath, entryPath); err != nil {
|
||||
t.Skipf("symlink creation is unavailable: %v", err)
|
||||
}
|
||||
fetchCalls := 0
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
fetchCalls++
|
||||
return iconResponse(document), nil
|
||||
}))
|
||||
|
||||
_, err := cache.Load(context.Background(), request)
|
||||
if !errors.Is(err, ErrIconCacheUnsafe) {
|
||||
t.Fatalf("Load(symlink) error = %v, want %v", err, ErrIconCacheUnsafe)
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("unsafe symlink triggered %d fetches", fetchCalls)
|
||||
}
|
||||
gotTarget, readErr := os.ReadFile(targetPath)
|
||||
if readErr != nil || !bytes.Equal(gotTarget, target) {
|
||||
t.Fatalf("external target changed: %q, %v", gotTarget, readErr)
|
||||
}
|
||||
info, statErr := os.Lstat(entryPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("Lstat(unsafe symlink) error = %v", statErr)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("unsafe symlink was replaced: mode=%v", info.Mode())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 iconResponse(document []byte) IconFetchResponse {
|
||||
return IconFetchResponse{
|
||||
Body: io.NopCloser(bytes.NewReader(document)),
|
||||
ContentLength: int64(len(document)),
|
||||
}
|
||||
}
|
||||
|
||||
func testIconCacheEntryPath(root string, request IconRequest) string {
|
||||
digest := strings.TrimPrefix(request.Reference, "sha256:")
|
||||
return filepath.Join(root, fmt.Sprintf("%s-%d.icon", digest, request.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()
|
||||
}
|
||||
@@ -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,295 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"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 TestIconEventDeliveryPublishesUnsafeFailureFromRealCache(t *testing.T) {
|
||||
document := testPNG(t, 8, 8)
|
||||
request := iconRequest(document, 96)
|
||||
identity := iconEventIdentity(t, document, "request-unsafe-cache")
|
||||
root := t.TempDir()
|
||||
entryPath := testIconCacheEntryPath(root, request)
|
||||
if err := os.Mkdir(entryPath, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir(cache entry) error = %v", err)
|
||||
}
|
||||
fetchCalls := 0
|
||||
cache := NewIconCache(root, IconFetchFunc(func(
|
||||
context.Context,
|
||||
IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
fetchCalls++
|
||||
return iconResponse(document), nil
|
||||
}))
|
||||
var events []application.Event
|
||||
delivery := IconEventDelivery{
|
||||
Loader: cache,
|
||||
Publisher: IconEventPublisherFunc(func(
|
||||
_ context.Context,
|
||||
event application.Event,
|
||||
) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := delivery.LoadAndPublish(context.Background(), identity)
|
||||
if !errors.Is(err, ErrIconCacheUnsafe) {
|
||||
t.Fatalf("LoadAndPublish() error = %v, want %v", err, ErrIconCacheUnsafe)
|
||||
}
|
||||
if fetchCalls != 0 {
|
||||
t.Fatalf("unsafe cache delivery triggered %d fetches", fetchCalls)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(events))
|
||||
}
|
||||
parsed, handled, parseErr := application.ParseIconEvent(events[0])
|
||||
if parseErr != nil || !handled {
|
||||
t.Fatalf("ParseIconEvent() = (%+v, %t, %v)", parsed, handled, parseErr)
|
||||
}
|
||||
if parsed.Type != application.EventIconFailed || parsed.Identity != identity ||
|
||||
parsed.ErrorCode != application.IconFailureUnsafe {
|
||||
t.Fatalf("unsafe failure event = %+v", parsed)
|
||||
}
|
||||
encoded, marshalErr := json.Marshal(events[0])
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("json.Marshal(event) error = %v", marshalErr)
|
||||
}
|
||||
if strings.Contains(string(encoded), root) ||
|
||||
strings.Contains(string(encoded), "cache entry is not a regular file") {
|
||||
t.Fatalf("unsafe failure event leaked cache details: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package catalog
|
||||
|
||||
import "container/list"
|
||||
|
||||
type iconMemoryEntry struct {
|
||||
key string
|
||||
document []byte
|
||||
}
|
||||
|
||||
type iconMemoryCache struct {
|
||||
maxBytes int64
|
||||
maxEntries int
|
||||
usedBytes int64
|
||||
entries map[string]*list.Element
|
||||
order list.List
|
||||
}
|
||||
|
||||
func newIconMemoryCache(maxBytes int64, maxEntries int) *iconMemoryCache {
|
||||
return &iconMemoryCache{
|
||||
maxBytes: maxBytes,
|
||||
maxEntries: maxEntries,
|
||||
entries: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
func (memory *iconMemoryCache) get(key string) ([]byte, bool) {
|
||||
element, exists := memory.entries[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
memory.order.MoveToFront(element)
|
||||
entry := element.Value.(*iconMemoryEntry)
|
||||
return append([]byte(nil), entry.document...), true
|
||||
}
|
||||
|
||||
func (memory *iconMemoryCache) put(key string, document []byte) {
|
||||
if memory.maxBytes <= 0 || memory.maxEntries <= 0 {
|
||||
return
|
||||
}
|
||||
stored := append([]byte(nil), document...)
|
||||
if element, exists := memory.entries[key]; exists {
|
||||
entry := element.Value.(*iconMemoryEntry)
|
||||
memory.usedBytes -= int64(len(entry.document))
|
||||
entry.document = stored
|
||||
memory.usedBytes += int64(len(stored))
|
||||
memory.order.MoveToFront(element)
|
||||
} else {
|
||||
entry := &iconMemoryEntry{key: key, document: stored}
|
||||
element := memory.order.PushFront(entry)
|
||||
memory.entries[key] = element
|
||||
memory.usedBytes += int64(len(stored))
|
||||
}
|
||||
|
||||
for memory.overLimit() {
|
||||
memory.removeOldest()
|
||||
}
|
||||
}
|
||||
|
||||
func (memory *iconMemoryCache) overLimit() bool {
|
||||
return memory.usedBytes > memory.maxBytes ||
|
||||
len(memory.entries) > memory.maxEntries
|
||||
}
|
||||
|
||||
func (memory *iconMemoryCache) removeOldest() {
|
||||
element := memory.order.Back()
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
entry := element.Value.(*iconMemoryEntry)
|
||||
delete(memory.entries, entry.key)
|
||||
memory.usedBytes -= int64(len(entry.document))
|
||||
memory.order.Remove(element)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIconMemoryCacheEvictsOldestEntryAndPromotesHits(t *testing.T) {
|
||||
memory := newIconMemoryCache(64, 2)
|
||||
memory.put("a", []byte{1, 2})
|
||||
memory.put("b", []byte{3, 4})
|
||||
if _, exists := memory.get("a"); !exists {
|
||||
t.Fatal("expected a memory hit")
|
||||
}
|
||||
memory.put("c", []byte{5, 6})
|
||||
|
||||
if _, exists := memory.get("b"); exists {
|
||||
t.Fatal("least-recently-used entry b was retained")
|
||||
}
|
||||
if _, exists := memory.get("a"); !exists {
|
||||
t.Fatal("promoted entry a was evicted")
|
||||
}
|
||||
if _, exists := memory.get("c"); !exists {
|
||||
t.Fatal("new entry c was evicted")
|
||||
}
|
||||
if memory.usedBytes != 4 || len(memory.entries) != 2 || memory.order.Len() != 2 {
|
||||
t.Fatalf(
|
||||
"memory state = bytes:%d entries:%d order:%d",
|
||||
memory.usedBytes,
|
||||
len(memory.entries),
|
||||
memory.order.Len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconMemoryCacheTracksReplacementAndByteLimit(t *testing.T) {
|
||||
memory := newIconMemoryCache(5, 10)
|
||||
memory.put("a", []byte{1, 2, 3})
|
||||
memory.put("b", []byte{4, 5})
|
||||
memory.put("a", []byte{6, 7, 8, 9})
|
||||
|
||||
if _, exists := memory.get("b"); exists {
|
||||
t.Fatal("byte limit did not evict the oldest entry")
|
||||
}
|
||||
document, exists := memory.get("a")
|
||||
if !exists || len(document) != 4 {
|
||||
t.Fatalf("replacement = %v, exists=%v", document, exists)
|
||||
}
|
||||
document[0] = 0xff
|
||||
again, _ := memory.get("a")
|
||||
if again[0] == 0xff {
|
||||
t.Fatal("memory hit exposed the stored backing array")
|
||||
}
|
||||
if memory.usedBytes != 4 || len(memory.entries) != 1 {
|
||||
t.Fatalf("memory state = bytes:%d entries:%d", memory.usedBytes, len(memory.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconCacheLRUEvictionReloadsVerifiedDisk(t *testing.T) {
|
||||
documents := [][]byte{
|
||||
testPNG(t, 22, 16),
|
||||
testPNG(t, 23, 16),
|
||||
testPNG(t, 24, 16),
|
||||
}
|
||||
requests := make([]IconRequest, len(documents))
|
||||
documentByReference := make(map[string][]byte, len(documents))
|
||||
for index, document := range documents {
|
||||
requests[index] = iconRequest(document, 96)
|
||||
documentByReference[requests[index].Reference] = document
|
||||
}
|
||||
var fetchCalls int32
|
||||
cache := NewIconCache(t.TempDir(), IconFetchFunc(func(
|
||||
_ context.Context,
|
||||
request IconRequest,
|
||||
) (IconFetchResponse, error) {
|
||||
atomic.AddInt32(&fetchCalls, 1)
|
||||
return iconResponse(documentByReference[request.Reference]), nil
|
||||
}))
|
||||
cache.memory.maxEntries = 2
|
||||
|
||||
for _, request := range requests[:2] {
|
||||
if _, err := cache.Load(context.Background(), request); err != nil {
|
||||
t.Fatalf("Load(seed) error = %v", err)
|
||||
}
|
||||
}
|
||||
if result, err := cache.Load(context.Background(), requests[0]); err != nil || result.Source != IconSourceMemory {
|
||||
t.Fatalf("promote result = %#v, error=%v", result, err)
|
||||
}
|
||||
if _, err := cache.Load(context.Background(), requests[2]); err != nil {
|
||||
t.Fatalf("Load(evict) error = %v", err)
|
||||
}
|
||||
result, err := cache.Load(context.Background(), requests[1])
|
||||
if err != nil || result.Source != IconSourceDisk {
|
||||
t.Fatalf("reload result = %#v, error=%v", result, err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fetchCalls); got != 3 {
|
||||
t.Fatalf("fetch calls = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIconCacheUsesBoundedMemoryDefaults(t *testing.T) {
|
||||
cache := NewIconCache(t.TempDir(), nil)
|
||||
if cache.memory.maxBytes != DefaultIconMemoryBytes ||
|
||||
cache.memory.maxEntries != DefaultIconMemoryEntries {
|
||||
t.Fatalf(
|
||||
"memory limits = %d bytes/%d entries",
|
||||
cache.memory.maxBytes,
|
||||
cache.memory.maxEntries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconMemoryCacheCanBeDisabledWithNonPositiveLimits(t *testing.T) {
|
||||
for _, memory := range []*iconMemoryCache{
|
||||
newIconMemoryCache(0, 1),
|
||||
newIconMemoryCache(1, 0),
|
||||
} {
|
||||
memory.put("disabled", []byte{1})
|
||||
if len(memory.entries) != 0 || memory.usedBytes != 0 {
|
||||
t.Fatalf("disabled memory retained state: %#v", memory)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrNoValidCatalog = errors.New("no valid catalog available")
|
||||
|
||||
// Fetcher obtains a signed Catalog document from a remote or test source.
|
||||
type Fetcher interface {
|
||||
Fetch(context.Context) ([]byte, error)
|
||||
}
|
||||
|
||||
// FetchFunc adapts a function to Fetcher.
|
||||
type FetchFunc func(context.Context) ([]byte, error)
|
||||
|
||||
func (function FetchFunc) Fetch(ctx context.Context) ([]byte, error) {
|
||||
return function(ctx)
|
||||
}
|
||||
|
||||
// Cache stores the last verified signed document.
|
||||
type Cache interface {
|
||||
Load() ([]byte, error)
|
||||
Store([]byte) error
|
||||
}
|
||||
|
||||
// DocumentValidator rejects signed documents that this client cannot consume.
|
||||
// Validators run before a remote document can replace the last valid cache.
|
||||
type DocumentValidator interface {
|
||||
Validate(VerifiedDocument) error
|
||||
}
|
||||
|
||||
// DocumentValidatorFunc adapts a function to DocumentValidator.
|
||||
type DocumentValidatorFunc func(VerifiedDocument) error
|
||||
|
||||
func (function DocumentValidatorFunc) Validate(document VerifiedDocument) error {
|
||||
return function(document)
|
||||
}
|
||||
|
||||
// LoadSource describes where a verified result came from.
|
||||
type LoadSource string
|
||||
|
||||
const (
|
||||
SourceRemote LoadSource = "remote"
|
||||
SourceCache LoadSource = "cache"
|
||||
)
|
||||
|
||||
// LoadResult returns verified bytes and a non-fatal refresh/cache warning.
|
||||
type LoadResult struct {
|
||||
Document VerifiedDocument
|
||||
Source LoadSource
|
||||
Warning error
|
||||
}
|
||||
|
||||
// LoadError preserves both the refresh and cache failure.
|
||||
type LoadError struct {
|
||||
Refresh error
|
||||
Cache error
|
||||
}
|
||||
|
||||
func (err *LoadError) Error() string {
|
||||
return fmt.Sprintf("%s: refresh=%v; cache=%v", ErrNoValidCatalog, err.Refresh, err.Cache)
|
||||
}
|
||||
|
||||
func (err *LoadError) Unwrap() error {
|
||||
return ErrNoValidCatalog
|
||||
}
|
||||
|
||||
// Loader verifies remote data before storing it and re-verifies cache fallback.
|
||||
type Loader struct {
|
||||
verifier Verifier
|
||||
fetcher Fetcher
|
||||
cache Cache
|
||||
validators []DocumentValidator
|
||||
}
|
||||
|
||||
func NewLoader(
|
||||
verifier Verifier,
|
||||
fetcher Fetcher,
|
||||
cache Cache,
|
||||
validators ...DocumentValidator,
|
||||
) *Loader {
|
||||
return &Loader{
|
||||
verifier: verifier,
|
||||
fetcher: fetcher,
|
||||
cache: cache,
|
||||
validators: append([]DocumentValidator(nil), validators...),
|
||||
}
|
||||
}
|
||||
|
||||
// Load prefers a verified remote document and falls back to verified cache.
|
||||
func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
|
||||
remoteBytes, refreshErr := loader.fetcher.Fetch(ctx)
|
||||
if refreshErr == nil {
|
||||
verified, verifyErr := loader.verifier.Verify(remoteBytes)
|
||||
if verifyErr == nil {
|
||||
verifyErr = loader.validate(verified)
|
||||
if verifyErr == nil {
|
||||
storeErr := loader.cache.Store(verified.Bytes)
|
||||
return LoadResult{
|
||||
Document: verified,
|
||||
Source: SourceRemote,
|
||||
Warning: storeErr,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
refreshErr = verifyErr
|
||||
}
|
||||
|
||||
cachedBytes, cacheErr := loader.cache.Load()
|
||||
if cacheErr == nil {
|
||||
var verified VerifiedDocument
|
||||
verified, cacheErr = loader.verifier.Verify(cachedBytes)
|
||||
if cacheErr == nil {
|
||||
cacheErr = loader.validate(verified)
|
||||
if cacheErr == nil {
|
||||
return LoadResult{
|
||||
Document: verified,
|
||||
Source: SourceCache,
|
||||
Warning: refreshErr,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LoadResult{}, &LoadError{
|
||||
Refresh: refreshErr,
|
||||
Cache: cacheErr,
|
||||
}
|
||||
}
|
||||
|
||||
func (loader *Loader) validate(document VerifiedDocument) error {
|
||||
for _, validator := range loader.validators {
|
||||
if validator == nil {
|
||||
continue
|
||||
}
|
||||
if err := validator.Validate(document); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoaderUsesVerifiedRemoteAndStoresCache(t *testing.T) {
|
||||
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||
cache := &memoryCache{}
|
||||
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return validDocument, nil
|
||||
}), cache)
|
||||
|
||||
result, err := loader.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Source != SourceRemote {
|
||||
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
|
||||
}
|
||||
if string(cache.document) != string(validDocument) {
|
||||
t.Fatal("verified remote document was not stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderFallsBackToVerifiedCache(t *testing.T) {
|
||||
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||
offline := errors.New("offline")
|
||||
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return nil, offline
|
||||
}), cache)
|
||||
|
||||
result, err := loader.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Source != SourceCache {
|
||||
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||
}
|
||||
if !errors.Is(result.Warning, offline) {
|
||||
t.Fatalf("Warning = %v, want %v", result.Warning, offline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderRejectsRemoteWithoutOverwritingCache(t *testing.T) {
|
||||
verifier, validDocument, tamperedDocument := loaderTestDocuments(t)
|
||||
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return tamperedDocument, nil
|
||||
}), cache)
|
||||
|
||||
result, err := loader.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Source != SourceCache {
|
||||
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||
}
|
||||
if !errors.Is(result.Warning, ErrSignatureInvalid) {
|
||||
t.Fatalf("Warning = %v, want %v", result.Warning, ErrSignatureInvalid)
|
||||
}
|
||||
if cache.storeCalls != 0 {
|
||||
t.Fatalf("Store() called %d times for invalid remote", cache.storeCalls)
|
||||
}
|
||||
if string(cache.document) != string(validDocument) {
|
||||
t.Fatal("invalid remote changed cached document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderRejectsInvalidCache(t *testing.T) {
|
||||
verifier, _, tamperedDocument := loaderTestDocuments(t)
|
||||
offline := errors.New("offline")
|
||||
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return nil, offline
|
||||
}), &memoryCache{document: tamperedDocument})
|
||||
|
||||
_, err := loader.Load(context.Background())
|
||||
if !errors.Is(err, ErrNoValidCatalog) {
|
||||
t.Fatalf("Load() error = %v, want %v", err, ErrNoValidCatalog)
|
||||
}
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("Load() error type = %T, want *LoadError", err)
|
||||
}
|
||||
if !errors.Is(loadErr.Cache, ErrSignatureInvalid) {
|
||||
t.Fatalf("cache error = %v, want %v", loadErr.Cache, ErrSignatureInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoaderReturnsCacheStoreWarning(t *testing.T) {
|
||||
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||
storeFailure := errors.New("disk full")
|
||||
cache := &memoryCache{storeErr: storeFailure}
|
||||
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||
return validDocument, nil
|
||||
}), cache)
|
||||
|
||||
result, err := loader.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if result.Source != SourceRemote {
|
||||
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
|
||||
}
|
||||
if !errors.Is(result.Warning, storeFailure) {
|
||||
t.Fatalf("Warning = %v, want %v", result.Warning, storeFailure)
|
||||
}
|
||||
}
|
||||
|
||||
type memoryCache struct {
|
||||
document []byte
|
||||
loadErr error
|
||||
storeErr error
|
||||
storeCalls int
|
||||
}
|
||||
|
||||
func (cache *memoryCache) Load() ([]byte, error) {
|
||||
if cache.loadErr != nil {
|
||||
return nil, cache.loadErr
|
||||
}
|
||||
return append([]byte(nil), cache.document...), nil
|
||||
}
|
||||
|
||||
func (cache *memoryCache) Store(document []byte) error {
|
||||
cache.storeCalls++
|
||||
if cache.storeErr != nil {
|
||||
return cache.storeErr
|
||||
}
|
||||
cache.document = append([]byte(nil), document...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func loaderTestDocuments(t *testing.T) (Verifier, []byte, []byte) {
|
||||
t.Helper()
|
||||
publicKey, privateKey := catalogTestKey()
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||
validDocument, signature := signCatalogPayload(t, validPayload, privateKey)
|
||||
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
|
||||
tamperedDocument := attachCatalogSignature(t, tamperedPayload, signature)
|
||||
return verifier, validDocument, tamperedDocument
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package catalog
|
||||
|
||||
// ManifestChannel separates modern and Win7 delivery tracks.
|
||||
type ManifestChannel string
|
||||
|
||||
const (
|
||||
ChannelModern ManifestChannel = "modern"
|
||||
ChannelWin7 ManifestChannel = "win7"
|
||||
)
|
||||
|
||||
// ReleaseChannel is the app release track supported by the MVP.
|
||||
type ReleaseChannel string
|
||||
|
||||
const (
|
||||
ReleaseStable ReleaseChannel = "stable"
|
||||
)
|
||||
|
||||
// AppCatalogStatus controls catalog visibility and installability.
|
||||
type AppCatalogStatus string
|
||||
|
||||
const (
|
||||
CatalogStatusActive AppCatalogStatus = "active"
|
||||
CatalogStatusDeprecated AppCatalogStatus = "deprecated"
|
||||
CatalogStatusHidden AppCatalogStatus = "hidden"
|
||||
)
|
||||
|
||||
// Architecture identifies a Windows package architecture.
|
||||
type Architecture string
|
||||
|
||||
const (
|
||||
Architecture386 Architecture = "386"
|
||||
ArchitectureAMD64 Architecture = "amd64"
|
||||
)
|
||||
|
||||
// WindowsRelease is an ordered minimum Windows release identifier.
|
||||
type WindowsRelease string
|
||||
|
||||
const (
|
||||
Windows7SP1 WindowsRelease = "windows-7-sp1"
|
||||
Windows10 WindowsRelease = "windows-10"
|
||||
Windows11 WindowsRelease = "windows-11"
|
||||
)
|
||||
|
||||
// Manifest is the signed Catalog protocol v1 document.
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Channel ManifestChannel `json:"channel"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
MinBoxVersion string `json:"min_box_version"`
|
||||
Apps []App `json:"apps"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// App is one published product in a Manifest.
|
||||
type App struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Channel ReleaseChannel `json:"channel"`
|
||||
Status AppCatalogStatus `json:"status"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Tutorial string `json:"tutorial,omitempty"`
|
||||
MinOS WindowsRelease `json:"min_os"`
|
||||
Architectures []Architecture `json:"architectures"`
|
||||
EntryEXE string `json:"entry_exe"`
|
||||
RequiresAdmin bool `json:"requires_admin"`
|
||||
Packages map[Architecture]Package `json:"packages"`
|
||||
}
|
||||
|
||||
// Package describes one downloadable ZIP artifact.
|
||||
type Package struct {
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// Target describes the client build and operating system consuming a Catalog.
|
||||
type Target struct {
|
||||
Channel ManifestChannel
|
||||
OS WindowsRelease
|
||||
Architecture Architecture
|
||||
}
|
||||
|
||||
// AvailabilityReason is stable data for UI localization and action gating.
|
||||
type AvailabilityReason string
|
||||
|
||||
const (
|
||||
ReasonNone AvailabilityReason = ""
|
||||
ReasonDeprecated AvailabilityReason = "deprecated"
|
||||
ReasonMinimumOS AvailabilityReason = "minimum_os"
|
||||
ReasonArchitecture AvailabilityReason = "architecture"
|
||||
)
|
||||
|
||||
// Entry is a visible app after target filtering.
|
||||
type Entry struct {
|
||||
App App
|
||||
Package *Package
|
||||
Installable bool
|
||||
Reason AvailabilityReason
|
||||
}
|
||||
|
||||
// Catalog is a verified, validated and target-filtered Manifest.
|
||||
type Catalog struct {
|
||||
Manifest Manifest
|
||||
Entries []Entry
|
||||
Source LoadSource
|
||||
Warning error
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
"softbox.local/core/internal/safepath"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidManifest = errors.New("invalid catalog manifest")
|
||||
ErrChannelMismatch = errors.New("catalog channel mismatch")
|
||||
ErrUnsupportedTarget = errors.New("unsupported catalog target")
|
||||
)
|
||||
|
||||
var (
|
||||
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
|
||||
iconRefPattern = regexp.MustCompile(`^sha256:[0-9A-Fa-f]{64}$`)
|
||||
)
|
||||
|
||||
// Parser validates the protocol shape for one delivery channel.
|
||||
type Parser struct {
|
||||
ExpectedChannel ManifestChannel
|
||||
}
|
||||
|
||||
// Validate implements DocumentValidator.
|
||||
func (parser Parser) Validate(document VerifiedDocument) error {
|
||||
_, err := parser.Parse(document)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse strictly decodes a previously verified Manifest.
|
||||
func (parser Parser) Parse(document VerifiedDocument) (Manifest, error) {
|
||||
if !parser.ExpectedChannel.valid() {
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%w: channel %q",
|
||||
ErrUnsupportedTarget,
|
||||
parser.ExpectedChannel,
|
||||
)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(document.Bytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
decoder.UseNumber()
|
||||
|
||||
var manifest Manifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("%w: decode: %v", ErrInvalidManifest, err)
|
||||
}
|
||||
if err := consumeEOF(decoder); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
if err := validateManifest(manifest, parser.ExpectedChannel); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func consumeEOF(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%w: trailing JSON value", ErrInvalidManifest)
|
||||
}
|
||||
return fmt.Errorf("%w: trailing data: %v", ErrInvalidManifest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManifest(manifest Manifest, expectedChannel ManifestChannel) error {
|
||||
if manifest.SchemaVersion != 1 {
|
||||
return invalidField("schema_version", "must be 1")
|
||||
}
|
||||
if !manifest.Channel.valid() {
|
||||
return invalidField("channel", "unsupported value %q", manifest.Channel)
|
||||
}
|
||||
if manifest.Channel != expectedChannel {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
ErrChannelMismatch,
|
||||
manifest.Channel,
|
||||
expectedChannel,
|
||||
)
|
||||
}
|
||||
generatedAt, err := time.Parse(time.RFC3339, manifest.GeneratedAt)
|
||||
_, offset := generatedAt.Zone()
|
||||
if err != nil || offset != 0 {
|
||||
return invalidField("generated_at", "must be an RFC3339 UTC timestamp")
|
||||
}
|
||||
if _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
|
||||
return invalidField("min_box_version", "must be SemVer")
|
||||
}
|
||||
if err := validateSignature(manifest.Signature); err != nil {
|
||||
return invalidField("signature", "%v", err)
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]struct{}, len(manifest.Apps))
|
||||
for index, app := range manifest.Apps {
|
||||
if err := validateApp(app); err != nil {
|
||||
return fmt.Errorf("%w: apps[%d]: %v", ErrInvalidManifest, index, err)
|
||||
}
|
||||
if _, exists := seenIDs[app.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate app id %q", ErrInvalidManifest, app.ID)
|
||||
}
|
||||
seenIDs[app.ID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateApp(app App) error {
|
||||
if !appIDPattern.MatchString(app.ID) {
|
||||
return invalidField("id", "must match ^[a-z0-9-]+$")
|
||||
}
|
||||
if strings.TrimSpace(app.Name) == "" {
|
||||
return invalidField("name", "must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(app.Description) == "" {
|
||||
return invalidField("description", "must not be empty")
|
||||
}
|
||||
if _, err := domain.ParseSemVer(app.Version); err != nil {
|
||||
return invalidField("version", "must be SemVer")
|
||||
}
|
||||
if app.Channel != ReleaseStable {
|
||||
return invalidField("channel", "unsupported value %q", app.Channel)
|
||||
}
|
||||
if !app.Status.valid() {
|
||||
return invalidField("status", "unsupported value %q", app.Status)
|
||||
}
|
||||
if strings.TrimSpace(app.Category) == "" {
|
||||
return invalidField("category", "must not be empty")
|
||||
}
|
||||
if len(app.Tags) == 0 {
|
||||
return invalidField("tags", "must contain at least one tag")
|
||||
}
|
||||
for _, tag := range app.Tags {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return invalidField("tags", "must not contain empty values")
|
||||
}
|
||||
}
|
||||
if app.Icon != "" && !iconRefPattern.MatchString(app.Icon) {
|
||||
return invalidField("icon", "must be sha256:<64 hexadecimal characters>")
|
||||
}
|
||||
if err := validateOptionalHTTPSURL("homepage", app.Homepage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalHTTPSURL("tutorial", app.Tutorial); err != nil {
|
||||
return err
|
||||
}
|
||||
if !app.MinOS.valid() {
|
||||
return invalidField("min_os", "unsupported value %q", app.MinOS)
|
||||
}
|
||||
if len(app.Architectures) == 0 {
|
||||
return invalidField("architectures", "must not be empty")
|
||||
}
|
||||
if err := safepath.ValidateRelative(app.EntryEXE); err != nil {
|
||||
return invalidField(
|
||||
"entry_exe",
|
||||
"must be a safe Windows relative path: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
if len(app.Packages) == 0 {
|
||||
return invalidField("packages", "must not be empty")
|
||||
}
|
||||
|
||||
architectures := make(map[Architecture]struct{}, len(app.Architectures))
|
||||
for _, architecture := range app.Architectures {
|
||||
if !architecture.valid() {
|
||||
return invalidField("architectures", "unsupported value %q", architecture)
|
||||
}
|
||||
if _, exists := architectures[architecture]; exists {
|
||||
return invalidField("architectures", "duplicate value %q", architecture)
|
||||
}
|
||||
architectures[architecture] = struct{}{}
|
||||
}
|
||||
for architecture := range architectures {
|
||||
publishedPackage, exists := app.Packages[architecture]
|
||||
if !exists {
|
||||
return invalidField("packages", "missing %q package", architecture)
|
||||
}
|
||||
if err := validatePackage(architecture, publishedPackage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for architecture := range app.Packages {
|
||||
if _, exists := architectures[architecture]; !exists {
|
||||
return invalidField(
|
||||
"packages",
|
||||
"package %q is absent from architectures",
|
||||
architecture,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePackage(architecture Architecture, publishedPackage Package) error {
|
||||
if !architecture.valid() {
|
||||
return invalidField("packages", "unsupported key %q", architecture)
|
||||
}
|
||||
if err := validateHTTPSURL(publishedPackage.URL); err != nil {
|
||||
return invalidField("packages."+string(architecture)+".url", "%v", err)
|
||||
}
|
||||
if publishedPackage.Size <= 0 {
|
||||
return invalidField("packages."+string(architecture)+".size", "must be positive")
|
||||
}
|
||||
if !sha256Pattern.MatchString(publishedPackage.SHA256) {
|
||||
return invalidField(
|
||||
"packages."+string(architecture)+".sha256",
|
||||
"must contain 64 hexadecimal characters",
|
||||
)
|
||||
}
|
||||
if _, err := hex.DecodeString(publishedPackage.SHA256); err != nil {
|
||||
return invalidField("packages."+string(architecture)+".sha256", "%v", err)
|
||||
}
|
||||
if err := validateSignature(publishedPackage.Signature); err != nil {
|
||||
return invalidField("packages."+string(architecture)+".signature", "%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSignature(value string) error {
|
||||
_, err := decodeCanonicalSignature(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be canonical padded Base64 for 64 bytes: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalHTTPSURL(fieldName, value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateHTTPSURL(value); err != nil {
|
||||
return invalidField(fieldName, "%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTTPSURL(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return errors.New("must be an absolute HTTPS URL without user information")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return errors.New("must not contain a fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidField(field, format string, values ...any) error {
|
||||
return fmt.Errorf(
|
||||
"%w: %s: %s",
|
||||
ErrInvalidManifest,
|
||||
field,
|
||||
fmt.Sprintf(format, values...),
|
||||
)
|
||||
}
|
||||
|
||||
func (channel ManifestChannel) valid() bool {
|
||||
return channel == ChannelModern || channel == ChannelWin7
|
||||
}
|
||||
|
||||
func (status AppCatalogStatus) valid() bool {
|
||||
return status == CatalogStatusActive ||
|
||||
status == CatalogStatusDeprecated ||
|
||||
status == CatalogStatusHidden
|
||||
}
|
||||
|
||||
func (architecture Architecture) valid() bool {
|
||||
return architecture == Architecture386 || architecture == ArchitectureAMD64
|
||||
}
|
||||
|
||||
func (release WindowsRelease) valid() bool {
|
||||
return release == Windows7SP1 || release == Windows10 || release == Windows11
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParserAcceptsValidSignedManifest(t *testing.T) {
|
||||
verifier, document := signedFixture(t, "manifest-valid-payload.json")
|
||||
verified, err := verifier.Verify(document)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
|
||||
manifest, err := (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if len(manifest.Apps) != 1 || manifest.Apps[0].ID != "json-parser" {
|
||||
t.Fatalf("Apps = %#v", manifest.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserRejectsWrongChannelAndUnknownFields(t *testing.T) {
|
||||
verifier, document := signedFixture(t, "manifest-valid-payload.json")
|
||||
verified, err := verifier.Verify(document)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
_, err = (Parser{ExpectedChannel: ChannelWin7}).Parse(verified)
|
||||
if !errors.Is(err, ErrChannelMismatch) {
|
||||
t.Fatalf("Parse() error = %v, want %v", err, ErrChannelMismatch)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(readCatalogFixture(t, "manifest-valid-payload.json"), &payload); err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
payload["unexpected"] = true
|
||||
mutatedPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode fixture: %v", err)
|
||||
}
|
||||
_, privateKey := catalogTestKey()
|
||||
mutatedDocument, _ := signCatalogPayload(t, mutatedPayload, privateKey)
|
||||
verified, err = verifier.Verify(mutatedDocument)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify(mutated) error = %v", err)
|
||||
}
|
||||
_, err = (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("Parse(mutated) error = %v, want %v", err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserRejectsDuplicateAppIDAndPackageMismatch(t *testing.T) {
|
||||
base := validManifestForTest()
|
||||
base.Apps = append(base.Apps, base.Apps[0])
|
||||
_, err := parseSignedManifestForTest(t, base, ChannelModern)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("duplicate Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||
}
|
||||
|
||||
base = validManifestForTest()
|
||||
delete(base.Apps[0].Packages, ArchitectureAMD64)
|
||||
_, err = parseSignedManifestForTest(t, base, ChannelModern)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("package Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserRejectsUnsafeWindowsEntrypoints(t *testing.T) {
|
||||
for _, entrypoint := range []string{
|
||||
".. /escape.exe",
|
||||
"bin./App.exe",
|
||||
"App.exe.",
|
||||
"App.exe ",
|
||||
" App.exe",
|
||||
"NUL",
|
||||
"con.txt",
|
||||
"bad?.exe",
|
||||
`bin\App.exe`,
|
||||
} {
|
||||
t.Run(entrypoint, func(t *testing.T) {
|
||||
manifest := validManifestForTest()
|
||||
manifest.Apps[0].EntryEXE = entrypoint
|
||||
_, err := parseSignedManifestForTest(t, manifest, ChannelModern)
|
||||
if !errors.Is(err, ErrInvalidManifest) {
|
||||
t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserAcceptsUnicodeNestedEntrypoint(t *testing.T) {
|
||||
manifest := validManifestForTest()
|
||||
manifest.Apps[0].EntryEXE = "工具/解析器.exe"
|
||||
parsed, err := parseSignedManifestForTest(t, manifest, ChannelModern)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if parsed.Apps[0].EntryEXE != manifest.Apps[0].EntryEXE {
|
||||
t.Fatalf(
|
||||
"EntryEXE = %q, want %q",
|
||||
parsed.Apps[0].EntryEXE,
|
||||
manifest.Apps[0].EntryEXE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func signedFixture(t *testing.T, name string) (Verifier, []byte) {
|
||||
t.Helper()
|
||||
publicKey, privateKey := catalogTestKey()
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
document, _ := signCatalogPayload(t, readCatalogFixture(t, name), privateKey)
|
||||
return verifier, document
|
||||
}
|
||||
|
||||
func validManifestForTest() Manifest {
|
||||
return Manifest{
|
||||
SchemaVersion: 1,
|
||||
Channel: ChannelModern,
|
||||
GeneratedAt: "2026-07-16T00:00:00Z",
|
||||
MinBoxVersion: "1.0.0",
|
||||
Apps: []App{
|
||||
{
|
||||
ID: "json-parser",
|
||||
Name: "JSON解析工具",
|
||||
Description: "测试目录项",
|
||||
Version: "1.2.0",
|
||||
Channel: ReleaseStable,
|
||||
Status: CatalogStatusActive,
|
||||
Category: "开发工具",
|
||||
Tags: []string{"工具", "JSON"},
|
||||
Icon: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Homepage: "https://example.invalid/json-parser",
|
||||
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||
MinOS: Windows10,
|
||||
Architectures: []Architecture{ArchitectureAMD64},
|
||||
EntryEXE: "JsonParser.exe",
|
||||
Packages: map[Architecture]Package{
|
||||
ArchitectureAMD64: {
|
||||
URL: "https://download.invalid/json-parser.zip",
|
||||
Size: 42,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseSignedManifestForTest(
|
||||
t *testing.T,
|
||||
manifest Manifest,
|
||||
expectedChannel ManifestChannel,
|
||||
) (Manifest, error) {
|
||||
t.Helper()
|
||||
manifest.Signature = ""
|
||||
payloadValue := map[string]any{}
|
||||
encodedManifest, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("encode manifest: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(encodedManifest, &payloadValue); err != nil {
|
||||
t.Fatalf("decode manifest: %v", err)
|
||||
}
|
||||
delete(payloadValue, "signature")
|
||||
payload, err := json.Marshal(payloadValue)
|
||||
if err != nil {
|
||||
t.Fatalf("encode payload: %v", err)
|
||||
}
|
||||
|
||||
publicKey, privateKey := catalogTestKey()
|
||||
document, _ := signCatalogPayload(t, payload, privateKey)
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
verified, err := verifier.Verify(document)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
return (Parser{ExpectedChannel: expectedChannel}).Parse(verified)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"manifest.schema.json",
|
||||
"app.schema.json",
|
||||
"installed-app.schema.json",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read schema: %v", err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(document, &schema); err != nil {
|
||||
t.Fatalf("decode schema: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
|
||||
t.Fatalf("$schema = %v", schema["$schema"])
|
||||
}
|
||||
if schema["type"] != "object" {
|
||||
t.Fatalf("type = %v", schema["type"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidDocument = errors.New("invalid catalog document")
|
||||
ErrDuplicateField = errors.New("duplicate catalog field")
|
||||
ErrUnsupportedNumber = errors.New("unsupported catalog number")
|
||||
ErrSignatureMissing = errors.New("catalog signature missing")
|
||||
ErrSignatureInvalid = errors.New("catalog signature invalid")
|
||||
ErrPublicKeyInvalid = errors.New("catalog public key invalid")
|
||||
)
|
||||
|
||||
// VerifiedDocument contains the original signed document and its signing bytes.
|
||||
type VerifiedDocument struct {
|
||||
Bytes []byte
|
||||
SignedPayload []byte
|
||||
}
|
||||
|
||||
// Verifier validates signed Catalog JSON documents with one Ed25519 public key.
|
||||
type Verifier struct {
|
||||
publicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewVerifier copies and validates the public key.
|
||||
func NewVerifier(publicKey []byte) (Verifier, error) {
|
||||
if len(publicKey) != ed25519.PublicKeySize {
|
||||
return Verifier{}, fmt.Errorf(
|
||||
"%w: got %d bytes, want %d",
|
||||
ErrPublicKeyInvalid,
|
||||
len(publicKey),
|
||||
ed25519.PublicKeySize,
|
||||
)
|
||||
}
|
||||
keyCopy := append(ed25519.PublicKey(nil), publicKey...)
|
||||
return Verifier{publicKey: keyCopy}, nil
|
||||
}
|
||||
|
||||
// Verify rejects ambiguous JSON and validates the top-level signature.
|
||||
func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
|
||||
rootValue, err := parseRestrictedJSON(document)
|
||||
if err != nil {
|
||||
return VerifiedDocument{}, err
|
||||
}
|
||||
root, ok := rootValue.(map[string]any)
|
||||
if !ok {
|
||||
return VerifiedDocument{}, fmt.Errorf("%w: root must be an object", ErrInvalidDocument)
|
||||
}
|
||||
|
||||
signatureValue, exists := root["signature"]
|
||||
if !exists {
|
||||
return VerifiedDocument{}, ErrSignatureMissing
|
||||
}
|
||||
signatureText, ok := signatureValue.(string)
|
||||
if !ok {
|
||||
return VerifiedDocument{}, fmt.Errorf("%w: signature must be a string", ErrSignatureInvalid)
|
||||
}
|
||||
delete(root, "signature")
|
||||
|
||||
signedPayload, err := canonicalJSON(root)
|
||||
if err != nil {
|
||||
return VerifiedDocument{}, err
|
||||
}
|
||||
signature, err := decodeCanonicalSignature(signatureText)
|
||||
if err != nil {
|
||||
return VerifiedDocument{}, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
|
||||
}
|
||||
if !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
|
||||
return VerifiedDocument{}, ErrSignatureInvalid
|
||||
}
|
||||
|
||||
return VerifiedDocument{
|
||||
Bytes: append([]byte(nil), document...),
|
||||
SignedPayload: append([]byte(nil), signedPayload...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeCanonicalSignature(value string) ([]byte, error) {
|
||||
signature, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid standard Base64: %w", err)
|
||||
}
|
||||
if base64.StdEncoding.EncodeToString(signature) != value {
|
||||
return nil, errors.New("signature must use canonical padded Base64")
|
||||
}
|
||||
if len(signature) != ed25519.SignatureSize {
|
||||
return nil, fmt.Errorf(
|
||||
"got %d signature bytes, want %d",
|
||||
len(signature),
|
||||
ed25519.SignatureSize,
|
||||
)
|
||||
}
|
||||
return signature, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifierFixtures(t *testing.T) {
|
||||
publicKey, privateKey := catalogTestKey()
|
||||
verifier, err := NewVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||
validDocument, validSignature := signCatalogPayload(t, validPayload, privateKey)
|
||||
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
|
||||
tamperedDocument := attachCatalogSignature(t, tamperedPayload, validSignature)
|
||||
forgedDocument := readCatalogFixture(t, "manifest-forged.json")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
document []byte
|
||||
wantErr error
|
||||
}{
|
||||
{name: "valid", document: validDocument},
|
||||
{name: "tampered", document: tamperedDocument, wantErr: ErrSignatureInvalid},
|
||||
{name: "forged", document: forgedDocument, wantErr: ErrSignatureInvalid},
|
||||
{
|
||||
name: "duplicate field",
|
||||
document: []byte(`{"channel":"modern","channel":"win7","signature":"x"}`),
|
||||
wantErr: ErrDuplicateField,
|
||||
},
|
||||
{
|
||||
name: "fractional number",
|
||||
document: []byte(`{"schema_version":1.5,"signature":"x"}`),
|
||||
wantErr: ErrUnsupportedNumber,
|
||||
},
|
||||
{
|
||||
name: "exponent number",
|
||||
document: []byte(`{"schema_version":1e2,"signature":"x"}`),
|
||||
wantErr: ErrUnsupportedNumber,
|
||||
},
|
||||
{
|
||||
name: "missing signature",
|
||||
document: validPayload,
|
||||
wantErr: ErrSignatureMissing,
|
||||
},
|
||||
{
|
||||
name: "trailing value",
|
||||
document: append(append([]byte(nil), validDocument...), []byte(` {}`)...),
|
||||
wantErr: ErrInvalidDocument,
|
||||
},
|
||||
{
|
||||
name: "invalid UTF-8",
|
||||
document: []byte{0xff, 0xfe},
|
||||
wantErr: ErrInvalidDocument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
verified, err := verifier.Verify(test.document)
|
||||
if test.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
if len(verified.SignedPayload) == 0 {
|
||||
t.Fatal("Verify() returned empty signed payload")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func catalogTestKey() (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
seed := sha256.Sum256([]byte("SoftBox catalog verifier test key - never use in production"))
|
||||
privateKey := ed25519.NewKeyFromSeed(seed[:])
|
||||
return privateKey.Public().(ed25519.PublicKey), privateKey
|
||||
}
|
||||
|
||||
func readCatalogFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "testdata", "catalog", name)
|
||||
document, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", name, err)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
func signCatalogPayload(t *testing.T, payload []byte, privateKey ed25519.PrivateKey) ([]byte, string) {
|
||||
t.Helper()
|
||||
value, err := parseRestrictedJSON(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parse payload: %v", err)
|
||||
}
|
||||
root, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("payload root is not an object")
|
||||
}
|
||||
canonical, err := canonicalJSON(root)
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalize payload: %v", err)
|
||||
}
|
||||
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
|
||||
return attachCatalogSignature(t, payload, signature), signature
|
||||
}
|
||||
|
||||
func attachCatalogSignature(t *testing.T, payload []byte, signature string) []byte {
|
||||
t.Helper()
|
||||
value, err := parseRestrictedJSON(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parse payload: %v", err)
|
||||
}
|
||||
root, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("payload root is not an object")
|
||||
}
|
||||
root["signature"] = signature
|
||||
document, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode signed document: %v", err)
|
||||
}
|
||||
return document
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package domain contains SoftBox business models and rules.
|
||||
package domain
|
||||
@@ -0,0 +1,212 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrInvalidSemVer = errors.New("invalid semantic version")
|
||||
|
||||
// SemVersion is a parsed Semantic Version 2.0.0 value.
|
||||
type SemVersion struct {
|
||||
original string
|
||||
major string
|
||||
minor string
|
||||
patch string
|
||||
prerelease []string
|
||||
build []string
|
||||
}
|
||||
|
||||
// ParseSemVer parses a complete Semantic Version 2.0.0 string.
|
||||
func ParseSemVer(value string) (SemVersion, error) {
|
||||
if value == "" {
|
||||
return SemVersion{}, fmt.Errorf("%w: empty value", ErrInvalidSemVer)
|
||||
}
|
||||
|
||||
coreAndPrerelease := value
|
||||
var build []string
|
||||
if separator := strings.IndexByte(value, '+'); separator >= 0 {
|
||||
if strings.IndexByte(value[separator+1:], '+') >= 0 {
|
||||
return SemVersion{}, fmt.Errorf("%w: multiple build separators", ErrInvalidSemVer)
|
||||
}
|
||||
coreAndPrerelease = value[:separator]
|
||||
var err error
|
||||
build, err = parseIdentifiers(value[separator+1:], false)
|
||||
if err != nil {
|
||||
return SemVersion{}, err
|
||||
}
|
||||
}
|
||||
|
||||
core := coreAndPrerelease
|
||||
var prerelease []string
|
||||
if separator := strings.IndexByte(coreAndPrerelease, '-'); separator >= 0 {
|
||||
core = coreAndPrerelease[:separator]
|
||||
var err error
|
||||
prerelease, err = parseIdentifiers(coreAndPrerelease[separator+1:], true)
|
||||
if err != nil {
|
||||
return SemVersion{}, err
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(core, ".")
|
||||
if len(parts) != 3 {
|
||||
return SemVersion{}, fmt.Errorf("%w: core must contain major.minor.patch", ErrInvalidSemVer)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !validNumericIdentifier(part, false) {
|
||||
return SemVersion{}, fmt.Errorf("%w: invalid core identifier %q", ErrInvalidSemVer, part)
|
||||
}
|
||||
}
|
||||
|
||||
return SemVersion{
|
||||
original: value,
|
||||
major: parts[0],
|
||||
minor: parts[1],
|
||||
patch: parts[2],
|
||||
prerelease: prerelease,
|
||||
build: build,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// String returns the original normalized-by-validation input.
|
||||
func (version SemVersion) String() string {
|
||||
return version.original
|
||||
}
|
||||
|
||||
// Compare applies SemVer precedence. Build metadata does not affect ordering.
|
||||
func (version SemVersion) Compare(other SemVersion) int {
|
||||
if comparison := compareNumericText(version.major, other.major); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
if comparison := compareNumericText(version.minor, other.minor); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
if comparison := compareNumericText(version.patch, other.patch); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
|
||||
if len(version.prerelease) == 0 && len(other.prerelease) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(version.prerelease) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(other.prerelease) == 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
count := len(version.prerelease)
|
||||
if len(other.prerelease) < count {
|
||||
count = len(other.prerelease)
|
||||
}
|
||||
for index := 0; index < count; index++ {
|
||||
left := version.prerelease[index]
|
||||
right := other.prerelease[index]
|
||||
leftNumeric := allDigits(left)
|
||||
rightNumeric := allDigits(right)
|
||||
switch {
|
||||
case leftNumeric && rightNumeric:
|
||||
if comparison := compareNumericText(left, right); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
case leftNumeric:
|
||||
return -1
|
||||
case rightNumeric:
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
case left > right:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(version.prerelease) < len(other.prerelease):
|
||||
return -1
|
||||
case len(version.prerelease) > len(other.prerelease):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// CompareSemVer parses and compares two version strings.
|
||||
func CompareSemVer(left, right string) (int, error) {
|
||||
leftVersion, err := ParseSemVer(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rightVersion, err := ParseSemVer(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return leftVersion.Compare(rightVersion), nil
|
||||
}
|
||||
|
||||
func parseIdentifiers(value string, rejectNumericLeadingZero bool) ([]string, error) {
|
||||
identifiers := strings.Split(value, ".")
|
||||
if value == "" || len(identifiers) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty identifier list", ErrInvalidSemVer)
|
||||
}
|
||||
for _, identifier := range identifiers {
|
||||
if identifier == "" {
|
||||
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidSemVer)
|
||||
}
|
||||
for _, character := range identifier {
|
||||
if (character < '0' || character > '9') &&
|
||||
(character < 'A' || character > 'Z') &&
|
||||
(character < 'a' || character > 'z') &&
|
||||
character != '-' {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: invalid identifier %q",
|
||||
ErrInvalidSemVer,
|
||||
identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
if rejectNumericLeadingZero &&
|
||||
allDigits(identifier) &&
|
||||
!validNumericIdentifier(identifier, false) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: numeric prerelease identifier %q has a leading zero",
|
||||
ErrInvalidSemVer,
|
||||
identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
func validNumericIdentifier(value string, allowLeadingZero bool) bool {
|
||||
if value == "" || !allDigits(value) {
|
||||
return false
|
||||
}
|
||||
return allowLeadingZero || len(value) == 1 || value[0] != '0'
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compareNumericText(left, right string) int {
|
||||
switch {
|
||||
case len(left) < len(right):
|
||||
return -1
|
||||
case len(left) > len(right):
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
case left > right:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSemVerPrecedence(t *testing.T) {
|
||||
ordered := []string{
|
||||
"1.0.0-alpha",
|
||||
"1.0.0-alpha.1",
|
||||
"1.0.0-alpha.beta",
|
||||
"1.0.0-beta",
|
||||
"1.0.0-beta.2",
|
||||
"1.0.0-beta.11",
|
||||
"1.0.0-rc.1",
|
||||
"1.0.0",
|
||||
"1.0.1",
|
||||
"1.1.0",
|
||||
"2.0.0",
|
||||
}
|
||||
for index := 0; index < len(ordered)-1; index++ {
|
||||
comparison, err := CompareSemVer(ordered[index], ordered[index+1])
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison >= 0 {
|
||||
t.Fatalf("%q should precede %q", ordered[index], ordered[index+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerIgnoresBuildMetadata(t *testing.T) {
|
||||
comparison, err := CompareSemVer("1.2.3+build.1", "1.2.3+build.99")
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison != 0 {
|
||||
t.Fatalf("comparison = %d, want 0", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerSupportsLargeNumericIdentifiers(t *testing.T) {
|
||||
comparison, err := CompareSemVer(
|
||||
"999999999999999999999999999.0.0",
|
||||
"1000000000000000000000000000.0.0",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison >= 0 {
|
||||
t.Fatalf("comparison = %d, want negative", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerRejectsInvalidValues(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"1",
|
||||
"1.2",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"1.2.3-",
|
||||
"1.2.3-alpha..1",
|
||||
"1.2.3-01",
|
||||
"1.2.3+",
|
||||
"1.2.3+build..1",
|
||||
"v1.2.3",
|
||||
"1.2.3 alpha",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
_, err := ParseSemVer(value)
|
||||
if !errors.Is(err, ErrInvalidSemVer) {
|
||||
t.Fatalf("ParseSemVer(%q) error = %v, want %v", value, err, ErrInvalidSemVer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AppStatus describes the user-visible lifecycle state of one catalog app.
|
||||
type AppStatus string
|
||||
|
||||
const (
|
||||
StatusNotInstalled AppStatus = "not_installed"
|
||||
StatusQueued AppStatus = "queued"
|
||||
StatusDownloading AppStatus = "downloading"
|
||||
StatusVerifying AppStatus = "verifying"
|
||||
StatusExtracting AppStatus = "extracting"
|
||||
StatusInstalling AppStatus = "installing"
|
||||
StatusInstalled AppStatus = "installed"
|
||||
StatusUpdateAvailable AppStatus = "update_available"
|
||||
StatusRunning AppStatus = "running"
|
||||
StatusFailed AppStatus = "failed"
|
||||
StatusRollbackPending AppStatus = "rollback_pending"
|
||||
StatusIncompatible AppStatus = "incompatible"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidStatus indicates that a transition contains an unknown status.
|
||||
ErrInvalidStatus = errors.New("invalid app status")
|
||||
// ErrInvalidTransition indicates that two valid states cannot transition directly.
|
||||
ErrInvalidTransition = errors.New("invalid app status transition")
|
||||
)
|
||||
|
||||
var validStatuses = map[AppStatus]struct{}{
|
||||
StatusNotInstalled: {},
|
||||
StatusQueued: {},
|
||||
StatusDownloading: {},
|
||||
StatusVerifying: {},
|
||||
StatusExtracting: {},
|
||||
StatusInstalling: {},
|
||||
StatusInstalled: {},
|
||||
StatusUpdateAvailable: {},
|
||||
StatusRunning: {},
|
||||
StatusFailed: {},
|
||||
StatusRollbackPending: {},
|
||||
StatusIncompatible: {},
|
||||
}
|
||||
|
||||
type statusTransition struct {
|
||||
from AppStatus
|
||||
to AppStatus
|
||||
}
|
||||
|
||||
var allowedTransitions = map[statusTransition]struct{}{
|
||||
{StatusNotInstalled, StatusQueued}: {},
|
||||
{StatusNotInstalled, StatusIncompatible}: {},
|
||||
{StatusQueued, StatusDownloading}: {},
|
||||
{StatusQueued, StatusNotInstalled}: {},
|
||||
{StatusQueued, StatusInstalled}: {},
|
||||
{StatusQueued, StatusUpdateAvailable}: {},
|
||||
{StatusQueued, StatusFailed}: {},
|
||||
{StatusDownloading, StatusQueued}: {},
|
||||
{StatusDownloading, StatusVerifying}: {},
|
||||
{StatusDownloading, StatusNotInstalled}: {},
|
||||
{StatusDownloading, StatusInstalled}: {},
|
||||
{StatusDownloading, StatusUpdateAvailable}: {},
|
||||
{StatusDownloading, StatusFailed}: {},
|
||||
{StatusVerifying, StatusExtracting}: {},
|
||||
{StatusVerifying, StatusFailed}: {},
|
||||
{StatusExtracting, StatusInstalling}: {},
|
||||
{StatusExtracting, StatusFailed}: {},
|
||||
{StatusInstalling, StatusInstalled}: {},
|
||||
{StatusInstalling, StatusRollbackPending}: {},
|
||||
{StatusInstalling, StatusFailed}: {},
|
||||
{StatusInstalled, StatusUpdateAvailable}: {},
|
||||
{StatusInstalled, StatusRunning}: {},
|
||||
{StatusInstalled, StatusQueued}: {},
|
||||
{StatusInstalled, StatusIncompatible}: {},
|
||||
{StatusUpdateAvailable, StatusQueued}: {},
|
||||
{StatusUpdateAvailable, StatusRunning}: {},
|
||||
{StatusUpdateAvailable, StatusInstalled}: {},
|
||||
{StatusUpdateAvailable, StatusIncompatible}: {},
|
||||
{StatusRunning, StatusInstalled}: {},
|
||||
{StatusRunning, StatusUpdateAvailable}: {},
|
||||
{StatusFailed, StatusQueued}: {},
|
||||
{StatusFailed, StatusNotInstalled}: {},
|
||||
{StatusFailed, StatusInstalled}: {},
|
||||
{StatusFailed, StatusUpdateAvailable}: {},
|
||||
{StatusFailed, StatusRollbackPending}: {},
|
||||
{StatusRollbackPending, StatusInstalled}: {},
|
||||
{StatusRollbackPending, StatusFailed}: {},
|
||||
{StatusIncompatible, StatusNotInstalled}: {},
|
||||
{StatusIncompatible, StatusInstalled}: {},
|
||||
{StatusIncompatible, StatusUpdateAvailable}: {},
|
||||
}
|
||||
|
||||
// Valid reports whether status is one of the documented domain states.
|
||||
func (status AppStatus) Valid() bool {
|
||||
_, ok := validStatuses[status]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CanTransition reports whether from can move directly to to.
|
||||
func CanTransition(from, to AppStatus) bool {
|
||||
return ValidateTransition(from, to) == nil
|
||||
}
|
||||
|
||||
// ValidateTransition verifies a direct state change.
|
||||
func ValidateTransition(from, to AppStatus) error {
|
||||
if !from.Valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidStatus, from)
|
||||
}
|
||||
if !to.Valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidStatus, to)
|
||||
}
|
||||
if _, ok := allowedTransitions[statusTransition{from: from, to: to}]; !ok {
|
||||
return &TransitionError{From: from, To: to}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransitionError describes a rejected direct state change.
|
||||
type TransitionError struct {
|
||||
From AppStatus
|
||||
To AppStatus
|
||||
}
|
||||
|
||||
func (err *TransitionError) Error() string {
|
||||
return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To)
|
||||
}
|
||||
|
||||
// Unwrap allows callers to use errors.Is with ErrInvalidTransition.
|
||||
func (err *TransitionError) Unwrap() error {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrInvalidStatusFacts = errors.New("invalid app status facts")
|
||||
|
||||
// AppStatusFacts are IO-free observations used to derive one visible state.
|
||||
type AppStatusFacts struct {
|
||||
Operation AppStatus
|
||||
Running bool
|
||||
RecoveryPending bool
|
||||
Incompatible bool
|
||||
InstalledVersion string
|
||||
CatalogVersion string
|
||||
}
|
||||
|
||||
// ResolveAppStatus derives the single user-visible state from local and
|
||||
// background-operation facts.
|
||||
func ResolveAppStatus(facts AppStatusFacts) (AppStatus, error) {
|
||||
if facts.RecoveryPending {
|
||||
return StatusRollbackPending, nil
|
||||
}
|
||||
if facts.Operation != "" {
|
||||
if !validOperationStatus(facts.Operation) {
|
||||
return "", fmt.Errorf(
|
||||
"%w: operation %q",
|
||||
ErrInvalidStatusFacts,
|
||||
facts.Operation,
|
||||
)
|
||||
}
|
||||
return facts.Operation, nil
|
||||
}
|
||||
if facts.Running {
|
||||
return StatusRunning, nil
|
||||
}
|
||||
if facts.Incompatible {
|
||||
return StatusIncompatible, nil
|
||||
}
|
||||
if facts.InstalledVersion == "" {
|
||||
return StatusNotInstalled, nil
|
||||
}
|
||||
if _, err := ParseSemVer(facts.InstalledVersion); err != nil {
|
||||
return "", fmt.Errorf("%w: installed version: %v", ErrInvalidStatusFacts, err)
|
||||
}
|
||||
if facts.CatalogVersion == "" {
|
||||
return StatusInstalled, nil
|
||||
}
|
||||
comparison, err := CompareSemVer(facts.InstalledVersion, facts.CatalogVersion)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: catalog version: %v", ErrInvalidStatusFacts, err)
|
||||
}
|
||||
if comparison < 0 {
|
||||
return StatusUpdateAvailable, nil
|
||||
}
|
||||
return StatusInstalled, nil
|
||||
}
|
||||
|
||||
func validOperationStatus(status AppStatus) bool {
|
||||
switch status {
|
||||
case StatusQueued,
|
||||
StatusDownloading,
|
||||
StatusVerifying,
|
||||
StatusExtracting,
|
||||
StatusInstalling,
|
||||
StatusFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAppStatusCoversAllStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
facts AppStatusFacts
|
||||
want AppStatus
|
||||
}{
|
||||
{name: "not installed", want: StatusNotInstalled},
|
||||
{name: "queued", facts: AppStatusFacts{Operation: StatusQueued}, want: StatusQueued},
|
||||
{name: "downloading", facts: AppStatusFacts{Operation: StatusDownloading}, want: StatusDownloading},
|
||||
{name: "verifying", facts: AppStatusFacts{Operation: StatusVerifying}, want: StatusVerifying},
|
||||
{name: "extracting", facts: AppStatusFacts{Operation: StatusExtracting}, want: StatusExtracting},
|
||||
{name: "installing", facts: AppStatusFacts{Operation: StatusInstalling}, want: StatusInstalling},
|
||||
{
|
||||
name: "installed",
|
||||
facts: AppStatusFacts{
|
||||
InstalledVersion: "1.2.0",
|
||||
CatalogVersion: "1.2.0",
|
||||
},
|
||||
want: StatusInstalled,
|
||||
},
|
||||
{
|
||||
name: "update available",
|
||||
facts: AppStatusFacts{
|
||||
InstalledVersion: "1.2.0",
|
||||
CatalogVersion: "1.3.0",
|
||||
},
|
||||
want: StatusUpdateAvailable,
|
||||
},
|
||||
{name: "running", facts: AppStatusFacts{Running: true}, want: StatusRunning},
|
||||
{name: "failed", facts: AppStatusFacts{Operation: StatusFailed}, want: StatusFailed},
|
||||
{
|
||||
name: "rollback pending",
|
||||
facts: AppStatusFacts{RecoveryPending: true},
|
||||
want: StatusRollbackPending,
|
||||
},
|
||||
{
|
||||
name: "incompatible",
|
||||
facts: AppStatusFacts{Incompatible: true},
|
||||
want: StatusIncompatible,
|
||||
},
|
||||
}
|
||||
|
||||
seen := make(map[AppStatus]bool)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
status, err := ResolveAppStatus(test.facts)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||
}
|
||||
if status != test.want {
|
||||
t.Fatalf("status = %q, want %q", status, test.want)
|
||||
}
|
||||
seen[status] = true
|
||||
})
|
||||
}
|
||||
if len(seen) != 12 {
|
||||
t.Fatalf("covered %d states, want 12", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppStatusPriorityAndValidation(t *testing.T) {
|
||||
status, err := ResolveAppStatus(AppStatusFacts{
|
||||
Operation: StatusDownloading,
|
||||
Running: true,
|
||||
RecoveryPending: true,
|
||||
Incompatible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||
}
|
||||
if status != StatusRollbackPending {
|
||||
t.Fatalf("status = %q, want %q", status, StatusRollbackPending)
|
||||
}
|
||||
|
||||
_, err = ResolveAppStatus(AppStatusFacts{Operation: StatusInstalled})
|
||||
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||
t.Fatalf("operation error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||
}
|
||||
_, err = ResolveAppStatus(AppStatusFacts{InstalledVersion: "not-semver"})
|
||||
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||
t.Fatalf("version error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppStatusValid(t *testing.T) {
|
||||
statuses := []AppStatus{
|
||||
StatusNotInstalled,
|
||||
StatusQueued,
|
||||
StatusDownloading,
|
||||
StatusVerifying,
|
||||
StatusExtracting,
|
||||
StatusInstalling,
|
||||
StatusInstalled,
|
||||
StatusUpdateAvailable,
|
||||
StatusRunning,
|
||||
StatusFailed,
|
||||
StatusRollbackPending,
|
||||
StatusIncompatible,
|
||||
}
|
||||
|
||||
for _, status := range statuses {
|
||||
if !status.Valid() {
|
||||
t.Errorf("status %q should be valid", status)
|
||||
}
|
||||
}
|
||||
|
||||
if AppStatus("unknown").Valid() {
|
||||
t.Fatal("unknown status should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from AppStatus
|
||||
to AppStatus
|
||||
want error
|
||||
}{
|
||||
{name: "queue install", from: StatusNotInstalled, to: StatusQueued},
|
||||
{name: "start download", from: StatusQueued, to: StatusDownloading},
|
||||
{name: "pause download", from: StatusDownloading, to: StatusQueued},
|
||||
{name: "finish install", from: StatusInstalling, to: StatusInstalled},
|
||||
{name: "start app", from: StatusInstalled, to: StatusRunning},
|
||||
{name: "recover rollback", from: StatusRollbackPending, to: StatusInstalled},
|
||||
{name: "skip install pipeline", from: StatusNotInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||
{name: "download while running", from: StatusRunning, to: StatusDownloading, want: ErrInvalidTransition},
|
||||
{name: "extract incompatible app", from: StatusIncompatible, to: StatusExtracting, want: ErrInvalidTransition},
|
||||
{name: "same status", from: StatusInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||
{name: "unknown source", from: AppStatus("unknown"), to: StatusQueued, want: ErrInvalidStatus},
|
||||
{name: "unknown target", from: StatusQueued, to: AppStatus("unknown"), want: ErrInvalidStatus},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateTransition(test.from, test.to)
|
||||
if test.want == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTransition(%q, %q) error = %v", test.from, test.to, err)
|
||||
}
|
||||
if !CanTransition(test.from, test.to) {
|
||||
t.Fatalf("CanTransition(%q, %q) = false, want true", test.from, test.to)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("ValidateTransition(%q, %q) error = %v, want %v", test.from, test.to, err, test.want)
|
||||
}
|
||||
if CanTransition(test.from, test.to) {
|
||||
t.Fatalf("CanTransition(%q, %q) = true, want false", test.from, test.to)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
// ApplicationPublisher is implemented by application.Runtime.
|
||||
type ApplicationPublisher interface {
|
||||
Publish(context.Context, application.Event) error
|
||||
}
|
||||
|
||||
// ApplicationObserver maps downloader events to the documented application
|
||||
// event envelope and concrete payloads.
|
||||
type ApplicationObserver struct {
|
||||
Publisher ApplicationPublisher
|
||||
}
|
||||
|
||||
// PublishDownload implements Observer.
|
||||
func (observer ApplicationObserver) PublishDownload(
|
||||
ctx context.Context,
|
||||
event Event,
|
||||
) error {
|
||||
if observer.Publisher == nil {
|
||||
return nil
|
||||
}
|
||||
envelope := application.Event{
|
||||
RequestID: event.RequestID,
|
||||
AppID: event.AppID,
|
||||
}
|
||||
switch event.Type {
|
||||
case EventStarted:
|
||||
envelope.Type = application.EventDownloadStarted
|
||||
envelope.Payload = application.DownloadStartedPayload{
|
||||
Attempt: event.Attempt,
|
||||
Done: event.Done,
|
||||
TotalKnown: event.TotalKnown,
|
||||
Total: event.Total,
|
||||
}
|
||||
case EventProgress:
|
||||
envelope.Type = application.EventDownloadProgress
|
||||
envelope.Payload = application.DownloadProgressPayload{
|
||||
Attempt: event.Attempt,
|
||||
Done: event.Done,
|
||||
TotalKnown: event.TotalKnown,
|
||||
Total: event.Total,
|
||||
SpeedBytesSec: event.SpeedBytesSec,
|
||||
}
|
||||
case EventPaused:
|
||||
envelope.Type = application.EventDownloadPaused
|
||||
envelope.Payload = application.DownloadPausedPayload{
|
||||
Attempt: event.Attempt,
|
||||
Done: event.Done,
|
||||
}
|
||||
case EventCompleted:
|
||||
envelope.Type = application.EventDownloadCompleted
|
||||
envelope.Payload = application.DownloadCompletedPayload{
|
||||
Attempt: event.Attempt,
|
||||
Done: event.Done,
|
||||
Path: event.CompletedPath,
|
||||
}
|
||||
case EventFailed:
|
||||
envelope.Type = application.EventDownloadFailed
|
||||
envelope.Payload = application.DownloadFailedPayload{
|
||||
Attempt: event.Attempt,
|
||||
Done: event.Done,
|
||||
ErrorCode: event.ErrorCode,
|
||||
}
|
||||
case EventCanceled:
|
||||
envelope.Type = application.EventDownloadCanceled
|
||||
envelope.Payload = application.DownloadCanceledPayload{
|
||||
Attempt: event.Attempt,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return observer.Publisher.Publish(ctx, envelope)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestApplicationObserverUsesConcretePayloads(t *testing.T) {
|
||||
publisher := &recordingApplicationPublisher{}
|
||||
observer := ApplicationObserver{Publisher: publisher}
|
||||
tests := []struct {
|
||||
event Event
|
||||
wantType application.EventType
|
||||
wantPayload any
|
||||
}{
|
||||
{
|
||||
event: Event{
|
||||
Type: EventStarted, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
|
||||
},
|
||||
wantType: application.EventDownloadStarted,
|
||||
wantPayload: application.DownloadStartedPayload{
|
||||
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
event: Event{
|
||||
Type: EventProgress, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
|
||||
},
|
||||
wantType: application.EventDownloadProgress,
|
||||
wantPayload: application.DownloadProgressPayload{
|
||||
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
event: Event{
|
||||
Type: EventPaused, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1, Done: 4,
|
||||
},
|
||||
wantType: application.EventDownloadPaused,
|
||||
wantPayload: application.DownloadPausedPayload{Attempt: 1, Done: 4},
|
||||
},
|
||||
{
|
||||
event: Event{
|
||||
Type: EventCompleted, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1, Done: 10, CompletedPath: "download",
|
||||
},
|
||||
wantType: application.EventDownloadCompleted,
|
||||
wantPayload: application.DownloadCompletedPayload{
|
||||
Attempt: 1, Done: 10, Path: "download",
|
||||
},
|
||||
},
|
||||
{
|
||||
event: Event{
|
||||
Type: EventFailed, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1, Done: 4, ErrorCode: "http_status",
|
||||
},
|
||||
wantType: application.EventDownloadFailed,
|
||||
wantPayload: application.DownloadFailedPayload{
|
||||
Attempt: 1, Done: 4, ErrorCode: "http_status",
|
||||
},
|
||||
},
|
||||
{
|
||||
event: Event{
|
||||
Type: EventCanceled, RequestID: "request-1", AppID: "app-1",
|
||||
Attempt: 1,
|
||||
},
|
||||
wantType: application.EventDownloadCanceled,
|
||||
wantPayload: application.DownloadCanceledPayload{Attempt: 1},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if err := observer.PublishDownload(context.Background(), test.event); err != nil {
|
||||
t.Fatalf("PublishDownload(%s) error = %v", test.event.Type, err)
|
||||
}
|
||||
got := publisher.events[len(publisher.events)-1]
|
||||
if got.Type != test.wantType ||
|
||||
got.RequestID != test.event.RequestID ||
|
||||
got.AppID != test.event.AppID {
|
||||
t.Fatalf("event = %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Payload, test.wantPayload) {
|
||||
t.Fatalf("payload = %#v, want %#v", got.Payload, test.wantPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordingApplicationPublisher struct {
|
||||
events []application.Event
|
||||
}
|
||||
|
||||
func (publisher *recordingApplicationPublisher) Publish(
|
||||
_ context.Context,
|
||||
event application.Event,
|
||||
) error {
|
||||
publisher.events = append(publisher.events, event)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package downloader provides resumable byte-transfer queues.
|
||||
//
|
||||
// A completed download is still untrusted input. Callers must verify the
|
||||
// signed Catalog identity, exact size, SHA-256 and package signature before
|
||||
// handing the file to the installer.
|
||||
package downloader
|
||||
@@ -0,0 +1,43 @@
|
||||
package downloader
|
||||
|
||||
import "context"
|
||||
|
||||
// EventType identifies an observer event emitted by Queue.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventStarted EventType = "started"
|
||||
EventProgress EventType = "progress"
|
||||
EventPaused EventType = "paused"
|
||||
EventCompleted EventType = "completed"
|
||||
EventFailed EventType = "failed"
|
||||
EventCanceled EventType = "canceled"
|
||||
)
|
||||
|
||||
// Event reports one generation of a download task. Done is monotonic within a
|
||||
// RequestID+Attempt pair. A new Attempt permits a reset to zero when a server
|
||||
// ignores Range or an entity cannot be safely resumed.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
RequestID string
|
||||
AppID string
|
||||
Attempt uint64
|
||||
Done int64
|
||||
TotalKnown bool
|
||||
Total int64
|
||||
SpeedBytesSec int64
|
||||
CompletedPath string
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
// Observer receives queue events. Implementations must honor context
|
||||
// cancellation so pause/cancel can wait for the old generation to exit.
|
||||
type Observer interface {
|
||||
PublishDownload(context.Context, Event) error
|
||||
}
|
||||
|
||||
type discardObserver struct{}
|
||||
|
||||
func (discardObserver) PublishDownload(context.Context, Event) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ensureTransferLayout(downloadsRoot, requestID string) (TaskPaths, error) {
|
||||
paths, err := DeriveTaskPaths(downloadsRoot, requestID)
|
||||
if err != nil {
|
||||
return TaskPaths{}, err
|
||||
}
|
||||
if err := os.MkdirAll(paths.Root, 0o700); err != nil {
|
||||
return TaskPaths{}, fmt.Errorf("create downloads root: %w", err)
|
||||
}
|
||||
for _, directory := range []string{paths.Root, paths.TasksDir, paths.FilesDir} {
|
||||
if err := os.Mkdir(directory, 0o700); err != nil && !os.IsExist(err) {
|
||||
return TaskPaths{}, fmt.Errorf("create download directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return TaskPaths{}, fmt.Errorf("inspect download directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return TaskPaths{}, fmt.Errorf("%w: managed directory is unsafe", ErrTaskCorrupt)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func regularFileSize(filePath string) (size int64, exists bool, err error) {
|
||||
info, err := os.Lstat(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("inspect download file: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return 0, false, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
|
||||
}
|
||||
return info.Size(), true, nil
|
||||
}
|
||||
|
||||
func regularFileExists(filePath string) (bool, error) {
|
||||
_, exists, err := regularFileSize(filePath)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func truncatePart(filePath string) error {
|
||||
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Truncate(0); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("truncate part: %w", err)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("seek truncated part: %w", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("sync truncated part: %w", err)
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func openPart(paths TaskPaths, offset int64, restart bool) (*os.File, error) {
|
||||
if restart {
|
||||
if _, exists, err := regularFileSize(paths.Part); err != nil {
|
||||
return nil, err
|
||||
} else if exists {
|
||||
file, _, err := openVerifiedRegular(paths.Part, os.O_RDWR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.Truncate(0); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("truncate restarted part: %w", err)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("seek restarted part: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
}
|
||||
size, exists, err := regularFileSize(paths.Part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
if offset != 0 {
|
||||
return nil, fmt.Errorf("%w: missing part for non-zero offset", ErrTaskCorrupt)
|
||||
}
|
||||
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
}
|
||||
if size != offset {
|
||||
return nil, fmt.Errorf("%w: part size changed", ErrTaskCorrupt)
|
||||
}
|
||||
file, _, err := openVerifiedRegular(paths.Part, os.O_WRONLY)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("seek part: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func syncRegularFile(filePath string) error {
|
||||
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func activateCompleted(paths TaskPaths, expected os.FileInfo) error {
|
||||
if exists, err := regularFileExists(paths.Completed); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("%w: completed path already exists", ErrTaskCorrupt)
|
||||
}
|
||||
if expected == nil {
|
||||
part, partInfo, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := part.Close(); err != nil {
|
||||
return fmt.Errorf("close part before activation: %w", err)
|
||||
}
|
||||
expected = partInfo
|
||||
}
|
||||
if err := verifyRegularIdentity(paths.Part, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(paths.Part, paths.Completed); err != nil {
|
||||
return fmt.Errorf("activate completed download: %w", err)
|
||||
}
|
||||
completedInfo, err := os.Lstat(paths.Completed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect activated download: %w", err)
|
||||
}
|
||||
if completedInfo.Mode()&os.ModeSymlink != 0 ||
|
||||
!completedInfo.Mode().IsRegular() ||
|
||||
!os.SameFile(expected, completedInfo) {
|
||||
_ = removeExactRegularFile(paths.Completed)
|
||||
return fmt.Errorf("%w: activated file identity changed", ErrTaskCorrupt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyRegularIdentity(filePath string, expected os.FileInfo) error {
|
||||
current, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect managed file identity: %w", err)
|
||||
}
|
||||
if current.Mode()&os.ModeSymlink != 0 ||
|
||||
!current.Mode().IsRegular() ||
|
||||
!os.SameFile(expected, current) {
|
||||
return fmt.Errorf("%w: managed file identity changed", ErrTaskCorrupt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeExactRegularFile(filePath string) error {
|
||||
info, err := os.Lstat(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%w: refuse removal of non-regular file", ErrTaskCorrupt)
|
||||
}
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
func syncAndClose(file *os.File) error {
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func openVerifiedRegular(
|
||||
filePath string,
|
||||
flags int,
|
||||
) (*os.File, os.FileInfo, error) {
|
||||
before, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
|
||||
return nil, nil, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
|
||||
}
|
||||
file, err := os.OpenFile(filePath, flags, 0o600)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open managed file: %w", err)
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, fmt.Errorf("stat managed file: %w", err)
|
||||
}
|
||||
after, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, fmt.Errorf("restat managed file: %w", err)
|
||||
}
|
||||
if after.Mode()&os.ModeSymlink != 0 ||
|
||||
!after.Mode().IsRegular() ||
|
||||
!os.SameFile(before, opened) ||
|
||||
!os.SameFile(opened, after) {
|
||||
file.Close()
|
||||
return nil, nil, fmt.Errorf("%w: managed file changed while opening", ErrTaskCorrupt)
|
||||
}
|
||||
return file, opened, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActivateCompletedRejectsReplacementOfWrittenPart(t *testing.T) {
|
||||
paths, err := ensureTransferLayout(t.TempDir(), "request-identity")
|
||||
if err != nil {
|
||||
t.Fatalf("ensureTransferLayout() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Part, []byte("trusted"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(original) error = %v", err)
|
||||
}
|
||||
original, expected, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
|
||||
if err != nil {
|
||||
t.Fatalf("openVerifiedRegular() error = %v", err)
|
||||
}
|
||||
if err := original.Close(); err != nil {
|
||||
t.Fatalf("Close(original) error = %v", err)
|
||||
}
|
||||
if err := os.Remove(paths.Part); err != nil {
|
||||
t.Fatalf("Remove(original) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Part, []byte("replacement"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement) error = %v", err)
|
||||
}
|
||||
|
||||
err = activateCompleted(paths, expected)
|
||||
if !errors.Is(err, ErrTaskCorrupt) {
|
||||
t.Fatalf("activateCompleted() error = %v, want %v", err, ErrTaskCorrupt)
|
||||
}
|
||||
if _, err := os.Stat(paths.Completed); !os.IsNotExist(err) {
|
||||
t.Fatalf("completed path exists after identity mismatch: %v", err)
|
||||
}
|
||||
document, err := os.ReadFile(paths.Part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(replacement) error = %v", err)
|
||||
}
|
||||
if string(document) != "replacement" {
|
||||
t.Fatalf("replacement bytes = %q", document)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHTTPStatus = errors.New("download HTTP status is not successful")
|
||||
ErrRangeMismatch = errors.New("download Content-Range does not match request")
|
||||
ErrRangeEntityChanged = errors.New("download range entity validator changed")
|
||||
ErrResponseEncoding = errors.New("download response encoding is not identity")
|
||||
ErrInsecureRedirect = errors.New("download redirect target is not secure")
|
||||
ErrTransferTooLarge = errors.New("download response exceeds byte limit")
|
||||
ErrTransferIncomplete = errors.New("download response ended before expected length")
|
||||
)
|
||||
|
||||
// OpenRequest describes one HTTP attempt.
|
||||
type OpenRequest struct {
|
||||
URL string
|
||||
Offset int64
|
||||
Validator EntityValidator
|
||||
}
|
||||
|
||||
// OpenResponse owns Body until the caller closes it.
|
||||
type OpenResponse struct {
|
||||
Body io.ReadCloser
|
||||
Restart bool
|
||||
TotalKnown bool
|
||||
Total int64
|
||||
ResponseLengthKnown bool
|
||||
ResponseLength int64
|
||||
Validator EntityValidator
|
||||
}
|
||||
|
||||
// Transport opens a remote byte stream at a requested offset.
|
||||
type Transport interface {
|
||||
Open(context.Context, OpenRequest) (OpenResponse, error)
|
||||
}
|
||||
|
||||
// HTTPTransport implements strict HTTPS and Range semantics with net/http.
|
||||
type HTTPTransport struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPTransport creates a standard-library transport.
|
||||
func NewHTTPTransport(client *http.Client) *HTTPTransport {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
clientCopy := *client
|
||||
previousRedirectCheck := client.CheckRedirect
|
||||
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if request.URL == nil || ValidateHTTPSURL(request.URL.String()) != nil {
|
||||
return ErrInsecureRedirect
|
||||
}
|
||||
if previousRedirectCheck != nil {
|
||||
return previousRedirectCheck(request, via)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &HTTPTransport{client: &clientCopy}
|
||||
}
|
||||
|
||||
// Open starts a transfer. A 200 response to a Range request is returned as
|
||||
// Restart=true so the queue truncates the old part before consuming bytes.
|
||||
func (transport *HTTPTransport) Open(
|
||||
ctx context.Context,
|
||||
request OpenRequest,
|
||||
) (OpenResponse, error) {
|
||||
if err := ValidateHTTPSURL(request.URL); err != nil {
|
||||
return OpenResponse{}, err
|
||||
}
|
||||
if request.Offset < 0 {
|
||||
return OpenResponse{}, fmt.Errorf("%w: negative offset", ErrRangeMismatch)
|
||||
}
|
||||
if err := request.Validator.Validate(); err != nil {
|
||||
return OpenResponse{}, fmt.Errorf("%w: invalid validator", ErrRangeMismatch)
|
||||
}
|
||||
if request.Offset > 0 && request.Validator.Empty() {
|
||||
return OpenResponse{}, fmt.Errorf("%w: resume needs an entity validator", ErrRangeMismatch)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil)
|
||||
if err != nil {
|
||||
return OpenResponse{}, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
httpRequest.Header.Set("Accept-Encoding", "identity")
|
||||
if request.Offset > 0 {
|
||||
httpRequest.Header.Set("Range", fmt.Sprintf("bytes=%d-", request.Offset))
|
||||
httpRequest.Header.Set("If-Range", request.Validator.HeaderValue())
|
||||
}
|
||||
|
||||
response, err := transport.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return OpenResponse{}, err
|
||||
}
|
||||
closeWithError := func(openErr error) (OpenResponse, error) {
|
||||
_ = response.Body.Close()
|
||||
return OpenResponse{}, openErr
|
||||
}
|
||||
if response.Request == nil || response.Request.URL == nil ||
|
||||
ValidateHTTPSURL(response.Request.URL.String()) != nil {
|
||||
return closeWithError(ErrInsecureRedirect)
|
||||
}
|
||||
encoding := strings.TrimSpace(response.Header.Get("Content-Encoding"))
|
||||
if encoding != "" && !strings.EqualFold(encoding, "identity") {
|
||||
return closeWithError(ErrResponseEncoding)
|
||||
}
|
||||
|
||||
validator := responseValidator(response.Header)
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
if response.Header.Get("Content-Range") != "" {
|
||||
return closeWithError(fmt.Errorf("%w: 200 includes Content-Range", ErrRangeMismatch))
|
||||
}
|
||||
totalKnown := response.ContentLength >= 0
|
||||
total := response.ContentLength
|
||||
if !totalKnown {
|
||||
total = 0
|
||||
}
|
||||
return OpenResponse{
|
||||
Body: response.Body,
|
||||
Restart: request.Offset > 0,
|
||||
TotalKnown: totalKnown,
|
||||
Total: total,
|
||||
ResponseLengthKnown: totalKnown,
|
||||
ResponseLength: total,
|
||||
Validator: validator,
|
||||
}, nil
|
||||
|
||||
case http.StatusPartialContent:
|
||||
if request.Offset == 0 {
|
||||
return closeWithError(fmt.Errorf("%w: unsolicited partial response", ErrRangeMismatch))
|
||||
}
|
||||
start, end, totalKnown, total, parseErr := parseContentRange(
|
||||
response.Header.Get("Content-Range"),
|
||||
)
|
||||
if parseErr != nil || start != request.Offset {
|
||||
return closeWithError(ErrRangeMismatch)
|
||||
}
|
||||
rangeLength := end - start + 1
|
||||
if rangeLength <= 0 ||
|
||||
(response.ContentLength >= 0 && response.ContentLength != rangeLength) {
|
||||
return closeWithError(ErrRangeMismatch)
|
||||
}
|
||||
if !validatorsMatch(request.Validator, validator) {
|
||||
return closeWithError(ErrRangeEntityChanged)
|
||||
}
|
||||
return OpenResponse{
|
||||
Body: response.Body,
|
||||
TotalKnown: totalKnown,
|
||||
Total: total,
|
||||
ResponseLengthKnown: true,
|
||||
ResponseLength: rangeLength,
|
||||
Validator: request.Validator,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return closeWithError(fmt.Errorf("%w: %d", ErrHTTPStatus, response.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func parseContentRange(value string) (
|
||||
start int64,
|
||||
end int64,
|
||||
totalKnown bool,
|
||||
total int64,
|
||||
err error,
|
||||
) {
|
||||
if !strings.HasPrefix(value, "bytes ") {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
rangeAndTotal := strings.Split(strings.TrimPrefix(value, "bytes "), "/")
|
||||
if len(rangeAndTotal) != 2 {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
bounds := strings.Split(rangeAndTotal[0], "-")
|
||||
if len(bounds) != 2 {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
start, err = strconv.ParseInt(bounds[0], 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
end, err = strconv.ParseInt(bounds[1], 10, 64)
|
||||
if err != nil || end < start || end == int64(^uint64(0)>>1) {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
if rangeAndTotal[1] == "*" {
|
||||
return start, end, false, 0, nil
|
||||
}
|
||||
total, err = strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
||||
if err != nil || total <= end {
|
||||
return 0, 0, false, 0, ErrRangeMismatch
|
||||
}
|
||||
return start, end, true, total, nil
|
||||
}
|
||||
|
||||
// CopyOptions bounds one response copy and tags progress with the generation
|
||||
// that owns it.
|
||||
type CopyOptions struct {
|
||||
MaxBytes int64
|
||||
ExpectedBytesKnown bool
|
||||
ExpectedBytes int64
|
||||
Generation uint64
|
||||
Progress func(CopyProgress) error
|
||||
}
|
||||
|
||||
// CopyProgress reports bytes written during this response only.
|
||||
type CopyProgress struct {
|
||||
Generation uint64
|
||||
Written int64
|
||||
}
|
||||
|
||||
// CopyResponse copies a response with a hard byte cap. It reads at most
|
||||
// MaxBytes+1 bytes so an oversized body is detected without unbounded IO.
|
||||
func CopyResponse(
|
||||
ctx context.Context,
|
||||
dst io.Writer,
|
||||
src io.Reader,
|
||||
options CopyOptions,
|
||||
) (int64, error) {
|
||||
if options.MaxBytes < 0 || options.MaxBytes == int64(^uint64(0)>>1) {
|
||||
return 0, fmt.Errorf("%w: invalid byte limit", ErrTransferTooLarge)
|
||||
}
|
||||
if options.ExpectedBytesKnown &&
|
||||
(options.ExpectedBytes < 0 || options.ExpectedBytes > options.MaxBytes) {
|
||||
return 0, fmt.Errorf("%w: invalid expected response length", ErrTransferIncomplete)
|
||||
}
|
||||
|
||||
limited := &io.LimitedReader{R: src, N: options.MaxBytes + 1}
|
||||
buffer := make([]byte, 32*1024)
|
||||
var written int64
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return written, err
|
||||
}
|
||||
readCount, readErr := limited.Read(buffer)
|
||||
if readCount > 0 {
|
||||
if int64(readCount) > options.MaxBytes-written {
|
||||
return written, ErrTransferTooLarge
|
||||
}
|
||||
writeCount, writeErr := dst.Write(buffer[:readCount])
|
||||
written += int64(writeCount)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
if writeCount != readCount {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
if options.Progress != nil {
|
||||
if err := options.Progress(CopyProgress{
|
||||
Generation: options.Generation,
|
||||
Written: written,
|
||||
}); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr != io.EOF {
|
||||
return written, readErr
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if options.ExpectedBytesKnown && written != options.ExpectedBytes {
|
||||
return written, ErrTransferIncomplete
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPTransportFreshAndResume(t *testing.T) {
|
||||
content := []byte("abcdefghij")
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
if request.Header.Get("Accept-Encoding") != "identity" {
|
||||
t.Errorf("Accept-Encoding = %q", request.Header.Get("Accept-Encoding"))
|
||||
}
|
||||
switch request.Header.Get("Range") {
|
||||
case "":
|
||||
writer.Header().Set("ETag", `"v1"`)
|
||||
writer.Header().Set("Content-Length", "10")
|
||||
_, _ = writer.Write(content)
|
||||
case "bytes=4-":
|
||||
if request.Header.Get("If-Range") != `"v1"` {
|
||||
t.Errorf("If-Range = %q", request.Header.Get("If-Range"))
|
||||
}
|
||||
writer.Header().Set("ETag", `"v1"`)
|
||||
writer.Header().Set("Content-Range", "bytes 4-9/10")
|
||||
writer.Header().Set("Content-Length", "6")
|
||||
writer.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = writer.Write(content[4:])
|
||||
default:
|
||||
t.Errorf("unexpected Range %q", request.Header.Get("Range"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
transport := NewHTTPTransport(server.Client())
|
||||
|
||||
fresh, err := transport.Open(context.Background(), OpenRequest{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Open(fresh) error = %v", err)
|
||||
}
|
||||
freshBytes, err := io.ReadAll(fresh.Body)
|
||||
fresh.Body.Close()
|
||||
if err != nil || !bytes.Equal(freshBytes, content) {
|
||||
t.Fatalf("fresh bytes = %q, error=%v", freshBytes, err)
|
||||
}
|
||||
if fresh.Restart || !fresh.TotalKnown || fresh.Total != 10 ||
|
||||
fresh.Validator.ETag != `"v1"` {
|
||||
t.Fatalf("fresh response = %#v", fresh)
|
||||
}
|
||||
|
||||
resumed, err := transport.Open(context.Background(), OpenRequest{
|
||||
URL: server.URL,
|
||||
Offset: 4,
|
||||
Validator: EntityValidator{ETag: `"v1"`},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open(resume) error = %v", err)
|
||||
}
|
||||
resumedBytes, err := io.ReadAll(resumed.Body)
|
||||
resumed.Body.Close()
|
||||
if err != nil || !bytes.Equal(resumedBytes, content[4:]) {
|
||||
t.Fatalf("resume bytes = %q, error=%v", resumedBytes, err)
|
||||
}
|
||||
if resumed.Restart || resumed.ResponseLength != 6 || resumed.Total != 10 {
|
||||
t.Fatalf("resume response = %#v", resumed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportRestartsWhenRangeIgnored(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("ETag", `"v2"`)
|
||||
writer.Header().Set("Content-Length", "4")
|
||||
_, _ = writer.Write([]byte("new!"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := NewHTTPTransport(server.Client()).Open(
|
||||
context.Background(),
|
||||
OpenRequest{
|
||||
URL: server.URL,
|
||||
Offset: 2,
|
||||
Validator: EntityValidator{ETag: `"v1"`},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if !response.Restart || response.Validator.ETag != `"v2"` {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportRejectsInvalidRangeResponses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentRange string
|
||||
etag string
|
||||
want error
|
||||
}{
|
||||
{name: "wrong start", contentRange: "bytes 3-9/10", etag: `"v1"`, want: ErrRangeMismatch},
|
||||
{name: "malformed", contentRange: "invalid", etag: `"v1"`, want: ErrRangeMismatch},
|
||||
{name: "entity changed", contentRange: "bytes 4-9/10", etag: `"v2"`, want: ErrRangeEntityChanged},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Content-Range", test.contentRange)
|
||||
writer.Header().Set("Content-Length", "6")
|
||||
writer.Header().Set("ETag", test.etag)
|
||||
writer.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = writer.Write([]byte("efghij"))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := NewHTTPTransport(server.Client()).Open(
|
||||
context.Background(),
|
||||
OpenRequest{
|
||||
URL: server.URL,
|
||||
Offset: 4,
|
||||
Validator: EntityValidator{ETag: `"v1"`},
|
||||
},
|
||||
)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("Open() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportRejectsNonSuccessStatuses(t *testing.T) {
|
||||
for _, status := range []int{
|
||||
http.StatusNotFound,
|
||||
http.StatusRequestedRangeNotSatisfiable,
|
||||
http.StatusInternalServerError,
|
||||
} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.WriteHeader(status)
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := NewHTTPTransport(server.Client()).Open(
|
||||
context.Background(),
|
||||
OpenRequest{URL: server.URL},
|
||||
)
|
||||
if !errors.Is(err, ErrHTTPStatus) {
|
||||
t.Fatalf("Open() error = %v, want %v", err, ErrHTTPStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportRejectsUnsafeSuccessMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int64
|
||||
headers map[string]string
|
||||
status int
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "encoded body",
|
||||
status: http.StatusOK,
|
||||
headers: map[string]string{
|
||||
"Content-Encoding": "gzip",
|
||||
},
|
||||
want: ErrResponseEncoding,
|
||||
},
|
||||
{
|
||||
name: "200 with content range",
|
||||
status: http.StatusOK,
|
||||
headers: map[string]string{
|
||||
"Content-Range": "bytes 0-3/4",
|
||||
},
|
||||
want: ErrRangeMismatch,
|
||||
},
|
||||
{
|
||||
name: "206 missing content range",
|
||||
offset: 2,
|
||||
status: http.StatusPartialContent,
|
||||
headers: map[string]string{
|
||||
"ETag": `"v1"`,
|
||||
},
|
||||
want: ErrRangeMismatch,
|
||||
},
|
||||
{
|
||||
name: "206 content length mismatch",
|
||||
offset: 2,
|
||||
status: http.StatusPartialContent,
|
||||
headers: map[string]string{
|
||||
"Content-Range": "bytes 2-3/4",
|
||||
"Content-Length": "1",
|
||||
"ETag": `"v1"`,
|
||||
},
|
||||
want: ErrRangeMismatch,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
for name, value := range test.headers {
|
||||
writer.Header().Set(name, value)
|
||||
}
|
||||
writer.WriteHeader(test.status)
|
||||
_, _ = writer.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
request := OpenRequest{URL: server.URL, Offset: test.offset}
|
||||
if test.offset > 0 {
|
||||
request.Validator = EntityValidator{ETag: `"v1"`}
|
||||
}
|
||||
_, err := NewHTTPTransport(server.Client()).Open(
|
||||
context.Background(),
|
||||
request,
|
||||
)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("Open() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransportRejectsDowngradeRedirect(t *testing.T) {
|
||||
insecure := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
_, _ = writer.Write([]byte("unsafe"))
|
||||
}))
|
||||
defer insecure.Close()
|
||||
secure := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
http.Redirect(writer, request, insecure.URL, http.StatusFound)
|
||||
}))
|
||||
defer secure.Close()
|
||||
|
||||
_, err := NewHTTPTransport(secure.Client()).Open(
|
||||
context.Background(),
|
||||
OpenRequest{URL: secure.URL},
|
||||
)
|
||||
if !errors.Is(err, ErrInsecureRedirect) {
|
||||
t.Fatalf("Open() error = %v, want %v", err, ErrInsecureRedirect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyResponseBoundsAndCompleteness(t *testing.T) {
|
||||
var destination bytes.Buffer
|
||||
written, err := CopyResponse(
|
||||
context.Background(),
|
||||
&destination,
|
||||
bytes.NewReader([]byte("1234")),
|
||||
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
|
||||
)
|
||||
if err != nil || written != 4 || destination.String() != "1234" {
|
||||
t.Fatalf("CopyResponse() = %d, %q, %v", written, destination.String(), err)
|
||||
}
|
||||
|
||||
_, err = CopyResponse(
|
||||
context.Background(),
|
||||
io.Discard,
|
||||
bytes.NewReader([]byte("12345")),
|
||||
CopyOptions{MaxBytes: 4},
|
||||
)
|
||||
if !errors.Is(err, ErrTransferTooLarge) {
|
||||
t.Fatalf("oversize error = %v, want %v", err, ErrTransferTooLarge)
|
||||
}
|
||||
_, err = CopyResponse(
|
||||
context.Background(),
|
||||
io.Discard,
|
||||
bytes.NewReader([]byte("123")),
|
||||
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
|
||||
)
|
||||
if !errors.Is(err, ErrTransferIncomplete) {
|
||||
t.Fatalf("short error = %v, want %v", err, ErrTransferIncomplete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TaskPaths are fixed local names derived only from RequestID.
|
||||
type TaskPaths struct {
|
||||
Root string
|
||||
TasksDir string
|
||||
FilesDir string
|
||||
Metadata string
|
||||
Backup string
|
||||
Part string
|
||||
Completed string
|
||||
}
|
||||
|
||||
// DeriveTaskPaths derives all local paths without accepting remote filenames
|
||||
// or Content-Disposition values.
|
||||
func DeriveTaskPaths(downloadsRoot, requestID string) (TaskPaths, error) {
|
||||
if downloadsRoot == "" {
|
||||
return TaskPaths{}, fmt.Errorf("%w: empty downloads root", ErrInvalidTask)
|
||||
}
|
||||
if !ValidRequestID(requestID) {
|
||||
return TaskPaths{}, fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
|
||||
}
|
||||
absoluteRoot, err := filepath.Abs(downloadsRoot)
|
||||
if err != nil {
|
||||
return TaskPaths{}, fmt.Errorf("%w: resolve downloads root", ErrInvalidTask)
|
||||
}
|
||||
tasksDir := filepath.Join(absoluteRoot, "tasks")
|
||||
filesDir := filepath.Join(absoluteRoot, "files")
|
||||
paths := TaskPaths{
|
||||
Root: absoluteRoot,
|
||||
TasksDir: tasksDir,
|
||||
FilesDir: filesDir,
|
||||
Metadata: filepath.Join(tasksDir, requestID+".json"),
|
||||
Backup: filepath.Join(tasksDir, requestID+".json.backup"),
|
||||
Part: filepath.Join(filesDir, requestID+".part"),
|
||||
Completed: filepath.Join(filesDir, requestID+".download"),
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
paths.TasksDir,
|
||||
paths.FilesDir,
|
||||
paths.Metadata,
|
||||
paths.Backup,
|
||||
paths.Part,
|
||||
paths.Completed,
|
||||
} {
|
||||
relative, relativeErr := filepath.Rel(absoluteRoot, candidate)
|
||||
if relativeErr != nil ||
|
||||
relative == ".." ||
|
||||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return TaskPaths{}, fmt.Errorf("%w: derived path escapes root", ErrInvalidTask)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskSchemaVersion = 1
|
||||
MaxRequestIDBytes = 128
|
||||
MaxAppIDBytes = 128
|
||||
MaxURLBytes = 4096
|
||||
MaxErrorCodeBytes = 64
|
||||
MaxValidatorBytes = 512
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTask = errors.New("download task is invalid")
|
||||
ErrTaskConflict = errors.New("download task conflicts with an existing task")
|
||||
ErrTaskNotFound = errors.New("download task not found")
|
||||
ErrInvalidCommand = errors.New("download command is invalid for task state")
|
||||
ErrTaskBusy = errors.New("download task is busy")
|
||||
ErrQueueClosed = errors.New("download queue is closed")
|
||||
ErrTaskCorrupt = errors.New("download task state is corrupt")
|
||||
requestIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
|
||||
appIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
|
||||
errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,63}$`)
|
||||
strongETagPattern = regexp.MustCompile(`^"[\x21\x23-\x7E\x80-\xFF]*"$`)
|
||||
)
|
||||
|
||||
// TaskStatus is the durable lifecycle of one byte transfer.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusQueued TaskStatus = "queued"
|
||||
StatusDownloading TaskStatus = "downloading"
|
||||
StatusPaused TaskStatus = "paused"
|
||||
StatusFailed TaskStatus = "failed"
|
||||
StatusCompleted TaskStatus = "completed"
|
||||
)
|
||||
|
||||
// EntityValidator binds a resumed range to the same remote representation.
|
||||
// ETag must be strong. LastModified must be an HTTP-date.
|
||||
type EntityValidator struct {
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"last_modified,omitempty"`
|
||||
}
|
||||
|
||||
// Task is the persisted download-task.json v1 protocol.
|
||||
//
|
||||
// Local paths are deliberately absent. They are derived from RequestID below
|
||||
// the configured downloads root so remote metadata cannot choose filesystem
|
||||
// destinations.
|
||||
type Task struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
RequestID string `json:"request_id"`
|
||||
AppID string `json:"app_id"`
|
||||
URL string `json:"url"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Attempt uint64 `json:"attempt"`
|
||||
Done int64 `json:"done"`
|
||||
TotalKnown bool `json:"total_known"`
|
||||
Total int64 `json:"total"`
|
||||
Validator EntityValidator `json:"validator"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Terminal reports whether the task no longer occupies the per-app active
|
||||
// task slot. Completed transfers remain queryable until a later workflow
|
||||
// consumes or removes them.
|
||||
func (task Task) Terminal() bool {
|
||||
return task.Status == StatusCompleted
|
||||
}
|
||||
|
||||
// Validate enforces the durable protocol independently of JSON Schema.
|
||||
func (task Task) Validate() error {
|
||||
if task.SchemaVersion != TaskSchemaVersion {
|
||||
return fmt.Errorf("%w: schema_version=%d", ErrInvalidTask, task.SchemaVersion)
|
||||
}
|
||||
if !ValidRequestID(task.RequestID) {
|
||||
return fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
|
||||
}
|
||||
if !appIDPattern.MatchString(task.AppID) || len(task.AppID) > MaxAppIDBytes {
|
||||
return fmt.Errorf("%w: invalid app_id", ErrInvalidTask)
|
||||
}
|
||||
if err := ValidateHTTPSURL(task.URL); err != nil {
|
||||
return fmt.Errorf("%w: url: %v", ErrInvalidTask, err)
|
||||
}
|
||||
switch task.Status {
|
||||
case StatusQueued, StatusDownloading, StatusPaused, StatusFailed, StatusCompleted:
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid status %q", ErrInvalidTask, task.Status)
|
||||
}
|
||||
if task.Attempt > uint64(^uint64(0)>>1) {
|
||||
return fmt.Errorf("%w: attempt is too large", ErrInvalidTask)
|
||||
}
|
||||
if task.Done < 0 {
|
||||
return fmt.Errorf("%w: done=%d", ErrInvalidTask, task.Done)
|
||||
}
|
||||
if task.TotalKnown {
|
||||
if task.Total <= 0 || task.Done > task.Total {
|
||||
return fmt.Errorf(
|
||||
"%w: invalid known total done=%d total=%d",
|
||||
ErrInvalidTask,
|
||||
task.Done,
|
||||
task.Total,
|
||||
)
|
||||
}
|
||||
if task.Status == StatusCompleted && task.Done != task.Total {
|
||||
return fmt.Errorf(
|
||||
"%w: completed done=%d total=%d",
|
||||
ErrInvalidTask,
|
||||
task.Done,
|
||||
task.Total,
|
||||
)
|
||||
}
|
||||
} else if task.Total != 0 {
|
||||
return fmt.Errorf("%w: unknown total must be zero", ErrInvalidTask)
|
||||
}
|
||||
if err := task.Validator.Validate(); err != nil {
|
||||
return fmt.Errorf("%w: validator: %v", ErrInvalidTask, err)
|
||||
}
|
||||
if task.Status == StatusFailed {
|
||||
if !errorCodePattern.MatchString(task.ErrorCode) ||
|
||||
len(task.ErrorCode) > MaxErrorCodeBytes {
|
||||
return fmt.Errorf("%w: failed task needs a stable error_code", ErrInvalidTask)
|
||||
}
|
||||
} else if task.ErrorCode != "" {
|
||||
return fmt.Errorf("%w: error_code is only valid for failed tasks", ErrInvalidTask)
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, task.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: created_at: %v", ErrInvalidTask, err)
|
||||
}
|
||||
_, offset := createdAt.Zone()
|
||||
if offset != 0 {
|
||||
return fmt.Errorf("%w: created_at must be UTC", ErrInvalidTask)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates an entity validator.
|
||||
func (validator EntityValidator) Validate() error {
|
||||
if validator.ETag != "" && validator.LastModified != "" {
|
||||
return errors.New("etag and last_modified are mutually exclusive")
|
||||
}
|
||||
if validator.ETag != "" {
|
||||
if len(validator.ETag) > MaxValidatorBytes ||
|
||||
strings.HasPrefix(strings.ToUpper(validator.ETag), "W/") ||
|
||||
!strongETagPattern.MatchString(validator.ETag) {
|
||||
return errors.New("etag is not a strong ETag")
|
||||
}
|
||||
}
|
||||
if validator.LastModified != "" {
|
||||
if len(validator.LastModified) > MaxValidatorBytes {
|
||||
return errors.New("last_modified is too long")
|
||||
}
|
||||
if _, err := http.ParseTime(validator.LastModified); err != nil {
|
||||
return errors.New("last_modified is not an HTTP-date")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Empty reports whether no reliable resume validator is available.
|
||||
func (validator EntityValidator) Empty() bool {
|
||||
return validator.ETag == "" && validator.LastModified == ""
|
||||
}
|
||||
|
||||
// HeaderValue returns the If-Range value.
|
||||
func (validator EntityValidator) HeaderValue() string {
|
||||
if validator.ETag != "" {
|
||||
return validator.ETag
|
||||
}
|
||||
return validator.LastModified
|
||||
}
|
||||
|
||||
// ValidRequestID reports whether a request ID can safely derive local names.
|
||||
func ValidRequestID(requestID string) bool {
|
||||
return len(requestID) <= MaxRequestIDBytes && requestIDPattern.MatchString(requestID)
|
||||
}
|
||||
|
||||
// ValidateHTTPSURL applies the same transport restrictions as the Catalog.
|
||||
func ValidateHTTPSURL(value string) error {
|
||||
if value == "" || len(value) > MaxURLBytes {
|
||||
return errors.New("URL length is invalid")
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return errors.New("URL is malformed")
|
||||
}
|
||||
if parsed.Scheme != "https" ||
|
||||
parsed.Host == "" ||
|
||||
parsed.User != nil ||
|
||||
parsed.Fragment != "" ||
|
||||
!parsed.IsAbs() {
|
||||
return errors.New("URL must be absolute HTTPS without user info or fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatorsMatch(expected, actual EntityValidator) bool {
|
||||
if expected.ETag != "" {
|
||||
if actual.Empty() {
|
||||
return true
|
||||
}
|
||||
return expected.ETag == actual.ETag
|
||||
}
|
||||
if expected.LastModified != "" {
|
||||
if actual.Empty() {
|
||||
return true
|
||||
}
|
||||
return expected.LastModified == actual.LastModified
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func responseValidator(header http.Header) EntityValidator {
|
||||
etag := strings.TrimSpace(header.Get("ETag"))
|
||||
if etag != "" &&
|
||||
!strings.HasPrefix(strings.ToUpper(etag), "W/") &&
|
||||
strongETagPattern.MatchString(etag) &&
|
||||
len(etag) <= MaxValidatorBytes {
|
||||
return EntityValidator{ETag: etag}
|
||||
}
|
||||
lastModified := strings.TrimSpace(header.Get("Last-Modified"))
|
||||
if lastModified != "" && len(lastModified) <= MaxValidatorBytes {
|
||||
if _, err := http.ParseTime(lastModified); err == nil {
|
||||
return EntityValidator{LastModified: lastModified}
|
||||
}
|
||||
}
|
||||
return EntityValidator{}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskValidateAndDerivedPaths(t *testing.T) {
|
||||
task := validTaskForTest()
|
||||
if err := task.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
paths, err := DeriveTaskPaths(filepath.Join(t.TempDir(), "downloads"), task.RequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveTaskPaths() error = %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
paths.Metadata,
|
||||
paths.Backup,
|
||||
paths.Part,
|
||||
paths.Completed,
|
||||
} {
|
||||
relative, err := filepath.Rel(paths.Root, path)
|
||||
if err != nil || relative == ".." {
|
||||
t.Fatalf("derived path escapes root: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRejectsInvalidFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Task)
|
||||
}{
|
||||
{name: "request id", mutate: func(task *Task) { task.RequestID = "../escape" }},
|
||||
{name: "app id", mutate: func(task *Task) { task.AppID = "Bad App" }},
|
||||
{name: "URL", mutate: func(task *Task) { task.URL = "http://example.invalid/app.zip" }},
|
||||
{name: "state", mutate: func(task *Task) { task.Status = "unknown" }},
|
||||
{name: "negative done", mutate: func(task *Task) { task.Done = -1 }},
|
||||
{name: "done exceeds total", mutate: func(task *Task) { task.Done = task.Total + 1 }},
|
||||
{name: "unknown total value", mutate: func(task *Task) {
|
||||
task.TotalKnown = false
|
||||
task.Total = 10
|
||||
}},
|
||||
{name: "weak etag", mutate: func(task *Task) {
|
||||
task.Validator = EntityValidator{ETag: `W/"weak"`}
|
||||
}},
|
||||
{name: "failed without code", mutate: func(task *Task) {
|
||||
task.Status = StatusFailed
|
||||
task.ErrorCode = ""
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
task := validTaskForTest()
|
||||
test.mutate(&task)
|
||||
if err := task.Validate(); !errors.Is(err, ErrInvalidTask) {
|
||||
t.Fatalf("Validate() error = %v, want %v", err, ErrInvalidTask)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorsMatchRejectsDifferentKinds(t *testing.T) {
|
||||
expected := EntityValidator{ETag: `"v1"`}
|
||||
if validatorsMatch(expected, EntityValidator{LastModified: "Wed, 16 Jul 2026 10:00:00 GMT"}) {
|
||||
t.Fatal("ETag unexpectedly matched Last-Modified")
|
||||
}
|
||||
if validatorsMatch(expected, EntityValidator{ETag: `"v2"`}) {
|
||||
t.Fatal("different ETags matched")
|
||||
}
|
||||
if !validatorsMatch(expected, EntityValidator{}) {
|
||||
t.Fatal("absent response validator should not contradict If-Range")
|
||||
}
|
||||
}
|
||||
|
||||
func validTaskForTest() Task {
|
||||
return Task{
|
||||
SchemaVersion: TaskSchemaVersion,
|
||||
RequestID: "request-001",
|
||||
AppID: "json-parser",
|
||||
URL: "https://download.invalid/json-parser.zip",
|
||||
Status: StatusQueued,
|
||||
TotalKnown: true,
|
||||
Total: 42,
|
||||
CreatedAt: "2026-07-16T00:00:00Z",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (queue *Queue) transfer(
|
||||
runtime *runtimeTask,
|
||||
ctx context.Context,
|
||||
attempt uint64,
|
||||
) (completedPath string, currentAttempt uint64, transferErr error) {
|
||||
currentAttempt = attempt
|
||||
task, valid := queue.taskForAttempt(runtime, currentAttempt)
|
||||
if !valid {
|
||||
return "", currentAttempt, context.Canceled
|
||||
}
|
||||
paths, err := ensureTransferLayout(queue.root, task.RequestID)
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
offset, _, err := regularFileSize(paths.Part)
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
|
||||
response, err := queue.transport.Open(ctx, OpenRequest{
|
||||
URL: task.URL,
|
||||
Offset: offset,
|
||||
Validator: task.Validator,
|
||||
})
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
body := response.Body
|
||||
defer func() {
|
||||
if body != nil {
|
||||
_ = body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if task.TotalKnown && response.TotalKnown && response.Total != task.Total {
|
||||
return "", currentAttempt, fmt.Errorf(
|
||||
"%w: response total %d, expected %d",
|
||||
ErrTransferIncomplete,
|
||||
response.Total,
|
||||
task.Total,
|
||||
)
|
||||
}
|
||||
if !task.TotalKnown && response.TotalKnown && response.Total > queue.maxUnknown {
|
||||
return "", currentAttempt, ErrTransferTooLarge
|
||||
}
|
||||
if !task.TotalKnown && offset > 0 && !response.Restart && !response.TotalKnown {
|
||||
return "", currentAttempt, fmt.Errorf(
|
||||
"%w: resumed response has unknown complete length",
|
||||
ErrTransferIncomplete,
|
||||
)
|
||||
}
|
||||
|
||||
var part *os.File
|
||||
if response.Restart {
|
||||
part, err = openPart(paths, offset, true)
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
if err := part.Sync(); err != nil {
|
||||
_ = part.Close()
|
||||
return "", currentAttempt, fmt.Errorf("sync restarted part: %w", err)
|
||||
}
|
||||
currentAttempt, task, err = queue.restartAttempt(
|
||||
runtime,
|
||||
currentAttempt,
|
||||
response,
|
||||
)
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
offset = 0
|
||||
} else {
|
||||
task, err = queue.applyResponseFacts(runtime, currentAttempt, response)
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
}
|
||||
|
||||
var maximum int64
|
||||
var expectedKnown bool
|
||||
var expected int64
|
||||
if task.TotalKnown {
|
||||
if offset > task.Total {
|
||||
return "", currentAttempt, fmt.Errorf(
|
||||
"%w: offset exceeds total",
|
||||
ErrTaskCorrupt,
|
||||
)
|
||||
}
|
||||
maximum = task.Total - offset
|
||||
expectedKnown = true
|
||||
expected = maximum
|
||||
} else {
|
||||
if offset >= queue.maxUnknown {
|
||||
return "", currentAttempt, ErrTransferTooLarge
|
||||
}
|
||||
maximum = queue.maxUnknown - offset
|
||||
if response.ResponseLengthKnown {
|
||||
expectedKnown = true
|
||||
expected = response.ResponseLength
|
||||
}
|
||||
}
|
||||
if !task.TotalKnown && response.TotalKnown {
|
||||
if response.Total > queue.maxUnknown ||
|
||||
offset > response.Total ||
|
||||
!response.ResponseLengthKnown ||
|
||||
response.ResponseLength != response.Total-offset {
|
||||
if part != nil {
|
||||
_ = part.Close()
|
||||
}
|
||||
return "", currentAttempt, ErrTransferIncomplete
|
||||
}
|
||||
}
|
||||
if response.ResponseLengthKnown && response.ResponseLength > maximum {
|
||||
if part != nil {
|
||||
_ = part.Close()
|
||||
}
|
||||
return "", currentAttempt, ErrTransferTooLarge
|
||||
}
|
||||
if expectedKnown &&
|
||||
response.ResponseLengthKnown &&
|
||||
response.ResponseLength != expected {
|
||||
if part != nil {
|
||||
_ = part.Close()
|
||||
}
|
||||
return "", currentAttempt, ErrTransferIncomplete
|
||||
}
|
||||
|
||||
if part == nil {
|
||||
part, err = openPart(paths, offset, false)
|
||||
if err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
}
|
||||
lastEventAt := queue.clock.Now()
|
||||
lastEventDone := offset
|
||||
lastPublishedDone := offset
|
||||
copyOptions := CopyOptions{
|
||||
MaxBytes: maximum,
|
||||
ExpectedBytesKnown: expectedKnown,
|
||||
ExpectedBytes: expected,
|
||||
Generation: currentAttempt,
|
||||
Progress: func(progress CopyProgress) error {
|
||||
absoluteDone := offset + progress.Written
|
||||
now := queue.clock.Now()
|
||||
emit := queue.progressEvery == 0 ||
|
||||
now.Sub(lastEventAt) >= queue.progressEvery
|
||||
if err := queue.updateAttemptDone(
|
||||
runtime,
|
||||
currentAttempt,
|
||||
absoluteDone,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if !emit {
|
||||
return nil
|
||||
}
|
||||
speed := progressSpeed(lastEventAt, now, lastEventDone, absoluteDone)
|
||||
if err := queue.persistAndPublishProgress(
|
||||
runtime,
|
||||
currentAttempt,
|
||||
speed,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
lastEventAt = now
|
||||
lastEventDone = absoluteDone
|
||||
lastPublishedDone = absoluteDone
|
||||
return nil
|
||||
},
|
||||
}
|
||||
written, copyErr := CopyResponse(ctx, part, body, copyOptions)
|
||||
writtenInfo, statErr := part.Stat()
|
||||
bodyCloseErr := body.Close()
|
||||
body = nil
|
||||
syncErr := syncAndClose(part)
|
||||
if syncErr != nil {
|
||||
return "", currentAttempt, syncErr
|
||||
}
|
||||
if bodyCloseErr != nil {
|
||||
return "", currentAttempt, bodyCloseErr
|
||||
}
|
||||
if statErr != nil {
|
||||
return "", currentAttempt, fmt.Errorf("stat written part: %w", statErr)
|
||||
}
|
||||
if err := verifyRegularIdentity(paths.Part, writtenInfo); err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
if copyErr != nil {
|
||||
return "", currentAttempt, copyErr
|
||||
}
|
||||
|
||||
finalDone := offset + written
|
||||
if err := queue.updateAttemptDone(runtime, currentAttempt, finalDone); err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
if finalDone != lastPublishedDone {
|
||||
now := queue.clock.Now()
|
||||
speed := progressSpeed(lastEventAt, now, lastEventDone, finalDone)
|
||||
if err := queue.persistAndPublishProgress(
|
||||
runtime,
|
||||
currentAttempt,
|
||||
speed,
|
||||
); err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
}
|
||||
if task.TotalKnown && finalDone != task.Total {
|
||||
return "", currentAttempt, ErrTransferIncomplete
|
||||
}
|
||||
if err := activateCompleted(paths, writtenInfo); err != nil {
|
||||
return "", currentAttempt, err
|
||||
}
|
||||
return paths.Completed, currentAttempt, nil
|
||||
}
|
||||
|
||||
func (queue *Queue) restartAttempt(
|
||||
runtime *runtimeTask,
|
||||
attempt uint64,
|
||||
response OpenResponse,
|
||||
) (uint64, Task, error) {
|
||||
queue.mu.Lock()
|
||||
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
|
||||
queue.mu.Unlock()
|
||||
return attempt, Task{}, context.Canceled
|
||||
}
|
||||
runtime.task.Attempt++
|
||||
runtime.task.Done = 0
|
||||
runtime.task.Validator = response.Validator
|
||||
task := runtime.task
|
||||
newAttempt := task.Attempt
|
||||
queue.mu.Unlock()
|
||||
if err := queue.store.Write(task); err != nil {
|
||||
return newAttempt, task, err
|
||||
}
|
||||
queue.publishBestEffort(Event{
|
||||
Type: EventStarted,
|
||||
RequestID: task.RequestID,
|
||||
AppID: task.AppID,
|
||||
Attempt: task.Attempt,
|
||||
Done: 0,
|
||||
TotalKnown: task.TotalKnown,
|
||||
Total: task.Total,
|
||||
})
|
||||
return newAttempt, task, nil
|
||||
}
|
||||
|
||||
func (queue *Queue) applyResponseFacts(
|
||||
runtime *runtimeTask,
|
||||
attempt uint64,
|
||||
response OpenResponse,
|
||||
) (Task, error) {
|
||||
queue.mu.Lock()
|
||||
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
|
||||
queue.mu.Unlock()
|
||||
return Task{}, context.Canceled
|
||||
}
|
||||
if runtime.task.Validator.Empty() {
|
||||
runtime.task.Validator = response.Validator
|
||||
}
|
||||
task := runtime.task
|
||||
queue.mu.Unlock()
|
||||
if err := queue.store.Write(task); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (queue *Queue) updateAttemptDone(
|
||||
runtime *runtimeTask,
|
||||
attempt uint64,
|
||||
done int64,
|
||||
) error {
|
||||
queue.mu.Lock()
|
||||
defer queue.mu.Unlock()
|
||||
if !queue.attemptCurrent(runtime, attempt) ||
|
||||
runtime.stop != stopNone ||
|
||||
done < runtime.task.Done {
|
||||
return context.Canceled
|
||||
}
|
||||
if runtime.task.TotalKnown && done > runtime.task.Total {
|
||||
return ErrTransferTooLarge
|
||||
}
|
||||
runtime.task.Done = done
|
||||
return nil
|
||||
}
|
||||
|
||||
func (queue *Queue) persistAndPublishProgress(
|
||||
runtime *runtimeTask,
|
||||
attempt uint64,
|
||||
speed int64,
|
||||
) error {
|
||||
queue.mu.Lock()
|
||||
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
|
||||
queue.mu.Unlock()
|
||||
return context.Canceled
|
||||
}
|
||||
task := runtime.task
|
||||
queue.mu.Unlock()
|
||||
if err := queue.store.Write(task); err != nil {
|
||||
return err
|
||||
}
|
||||
queue.publishBestEffort(Event{
|
||||
Type: EventProgress,
|
||||
RequestID: task.RequestID,
|
||||
AppID: task.AppID,
|
||||
Attempt: task.Attempt,
|
||||
Done: task.Done,
|
||||
TotalKnown: task.TotalKnown,
|
||||
Total: task.Total,
|
||||
SpeedBytesSec: speed,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func progressSpeed(start, end time.Time, startDone, endDone int64) int64 {
|
||||
elapsed := end.Sub(start)
|
||||
if elapsed <= 0 || endDone <= startDone {
|
||||
return 0
|
||||
}
|
||||
bytesPerSecond := float64(endDone-startDone) / elapsed.Seconds()
|
||||
if bytesPerSecond <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(bytesPerSecond)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module softbox.local/core
|
||||
|
||||
go 1.20
|
||||
@@ -0,0 +1,99 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var ErrDurability = errors.New("install durability fence failed")
|
||||
|
||||
type durabilityFence interface {
|
||||
syncFile(file *os.File) error
|
||||
syncDirectory(path string) error
|
||||
}
|
||||
|
||||
type filesystemDurability struct{}
|
||||
|
||||
func (filesystemDurability) syncFile(file *os.File) error {
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func (filesystemDurability) syncDirectory(path string) error {
|
||||
return syncDirectoryPath(path)
|
||||
}
|
||||
|
||||
func defaultDurability() durabilityFence {
|
||||
return filesystemDurability{}
|
||||
}
|
||||
|
||||
func effectiveDurability(fence durabilityFence) durabilityFence {
|
||||
if fence == nil {
|
||||
return defaultDurability()
|
||||
}
|
||||
return fence
|
||||
}
|
||||
|
||||
func syncFileWithFence(fence durabilityFence, file *os.File, description string) error {
|
||||
if err := effectiveDurability(fence).syncFile(file); err != nil {
|
||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDirectoryWithFence(fence durabilityFence, path, description string) error {
|
||||
if err := effectiveDurability(fence).syncDirectory(path); err != nil {
|
||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncStagingTree(fence durabilityFence, root string) error {
|
||||
directories := make([]string, 0)
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: staging tree contains a symbolic link", ErrUnsafeInstallLayout)
|
||||
}
|
||||
if entry.IsDir() {
|
||||
directories = append(directories, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: walk staging tree: %w", ErrDurability, err)
|
||||
}
|
||||
sort.Slice(directories, func(left, right int) bool {
|
||||
return len(directories[left]) > len(directories[right])
|
||||
})
|
||||
for _, directory := range directories {
|
||||
if err := syncDirectoryWithFence(fence, directory, "staging directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(root)
|
||||
if parent != root {
|
||||
if err := syncDirectoryWithFence(fence, parent, "staging parent directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameManagedDirectory(
|
||||
layout appLayout,
|
||||
source string,
|
||||
target string,
|
||||
fence durabilityFence,
|
||||
description string,
|
||||
) error {
|
||||
if err := os.Rename(source, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDirectoryWithFence(fence, layout.root, description)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user