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 本地校验缓存
|
# Python 本地校验缓存
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
|
# 本地编辑器 workspace 配置(个人,不入库)
|
||||||
|
*.code-workspace
|
||||||
|
|||||||
@@ -45,6 +45,15 @@
|
|||||||
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
||||||
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
||||||
|
|
||||||
|
## Agent 执行模式
|
||||||
|
|
||||||
|
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
|
||||||
|
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
|
||||||
|
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
|
||||||
|
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
|
||||||
|
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
|
||||||
|
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
||||||
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
||||||
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
||||||
|
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
|
||||||
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
| [`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/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) | 已有项目接入迁移清单(备查) |
|
| [`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