Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2cda73deb | ||
|
|
2c561894fd | ||
|
|
06234e11bf | ||
|
|
13004218cf | ||
|
|
2900deba4f | ||
|
|
339beaa9b3 | ||
|
|
fda52a57ca | ||
|
|
87083c387f | ||
|
|
d0cf333394 | ||
|
|
df6c243b21 | ||
|
|
d1603c52b6 | ||
|
|
09f56478d3 | ||
|
|
1ad00f8ef2 | ||
|
|
ae3f64c407 | ||
|
|
449b183ca3 | ||
|
|
14589abb31 | ||
|
|
6575c9ad9b | ||
|
|
a72e7b04dc | ||
|
|
0c1b7662c6 | ||
|
|
9e5f3f4840 | ||
|
|
20596a7de4 | ||
|
|
84befee70f | ||
|
|
0f69fa330e | ||
|
|
65ff7a3f23 | ||
|
|
320b83d929 | ||
|
|
6455fec811 | ||
|
|
e9386d26e7 | ||
|
|
75b1803564 | ||
|
|
171572973b | ||
|
|
ed9ded2110 | ||
|
|
1b7f72e658 | ||
|
|
0705948d74 | ||
|
|
0945fe93dc | ||
|
|
fba672381e | ||
|
|
f1cc7308db | ||
|
|
8873a5261d | ||
|
|
6fd19d0f43 | ||
|
|
f7a803d944 | ||
|
|
7efcab5dfe | ||
|
|
e4a9295cbf | ||
|
|
a036ce9a8e | ||
|
|
90dc09d13d | ||
|
|
8dad40f934 | ||
|
|
2d302b731a | ||
|
|
a52acbf926 | ||
|
|
db8d93e843 | ||
|
|
819b1cdf88 | ||
|
|
2e21c9f327 | ||
|
|
9da72caa01 | ||
|
|
0ffeff63e1 | ||
|
|
0d6ed05d7e | ||
|
|
45c242ecec | ||
|
|
6f920eb457 | ||
|
|
0e20dd76b2 | ||
|
|
cf9fc01c68 |
@@ -0,0 +1,23 @@
|
||||
name: Phase 0 build gate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.0"
|
||||
cache: false
|
||||
- name: Run Phase 0 verification
|
||||
run: bash scripts/verify_phase0.sh
|
||||
@@ -30,3 +30,6 @@ gitea.env.*
|
||||
# Python 本地校验缓存
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# 本地编辑器 workspace 配置(个人,不入库)
|
||||
*.code-workspace
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
||||
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
||||
|
||||
## Agent 执行模式
|
||||
|
||||
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
|
||||
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
|
||||
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
|
||||
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
|
||||
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
|
||||
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
||||
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
||||
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
||||
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
|
||||
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
||||
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
|
||||
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||
runtime := application.NewRuntime(1)
|
||||
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||
}))
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
event := <-runtime.Events()
|
||||
if event.Type != application.EventCatalogRefreshed {
|
||||
t.Fatalf("event type = %q", event.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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() {
|
||||
if handled, err := acknowledgeInternalUpdateHealth(os.Args[1:]); handled {
|
||||
if err != nil {
|
||||
log.Printf("%s internal update health failed: %v", core.ProductName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("%s stopped: %v", core.ProductName, err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) 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,
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
relay.Close()
|
||||
if bootstrapErr := <-catalogDone; bootstrapErr != nil &&
|
||||
!errors.Is(bootstrapErr, context.Canceled) &&
|
||||
!errors.Is(bootstrapErr, application.ErrCatalogSourceUnconfigured) &&
|
||||
!errors.Is(bootstrapErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCatalogBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.CatalogSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewCatalogBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"softbox.local/app-modern/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
|
||||
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
|
||||
return false, nil
|
||||
}
|
||||
if len(arguments) != 2 {
|
||||
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("locate current executable: %w", err)
|
||||
}
|
||||
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
|
||||
return true, fmt.Errorf("acknowledge self-update health: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"softbox.local/app-modern/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
for _, option := range []string{"--pid", "--staging", "--target"} {
|
||||
if err := requireOneOption(arguments, option); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
pid := flags.Int("pid", 0, "main SoftBox PID")
|
||||
staging := flags.String("staging", "", "prepared staging directory")
|
||||
target := flags.String("target", "", "fixed app target directory")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return fmt.Errorf("parse updater arguments: %w", err)
|
||||
}
|
||||
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
|
||||
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
|
||||
}
|
||||
requestID := filepath.Base(filepath.Clean(*staging))
|
||||
platform := windows.New()
|
||||
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
|
||||
ParentExit: 2 * time.Minute,
|
||||
Health: 45 * time.Second,
|
||||
})
|
||||
return service.Update(context.Background(), updater.Request{
|
||||
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func requireOneOption(arguments []string, option string) error {
|
||||
count := 0
|
||||
for _, argument := range arguments {
|
||||
if argument == option || strings.HasPrefix(argument, option+"=") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%s must appear exactly once", option)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
|
||||
if err := run(nil); err == nil {
|
||||
t.Fatal("run(nil) succeeded")
|
||||
}
|
||||
if err := run([]string{
|
||||
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
|
||||
}); err == nil {
|
||||
t.Fatal("run() accepted duplicate --pid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module softbox.local/app-modern
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
gioui.org v0.10.1
|
||||
golang.org/x/sys v0.39.0
|
||||
softbox.local/core v0.0.0
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package windows
|
||||
|
||||
import "fmt"
|
||||
|
||||
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
|
||||
switch minOS {
|
||||
case "windows-7-sp1":
|
||||
if major > 6 || (major == 6 && minor > 1) {
|
||||
return true, nil
|
||||
}
|
||||
return major == 6 && minor == 1 && servicePack >= 1, nil
|
||||
case "windows-10":
|
||||
return major >= 10, nil
|
||||
case "windows-11":
|
||||
return major >= 10 && build >= 22000, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package windows provides modern Windows platform adapters and non-Windows
|
||||
// stubs for package-level tests.
|
||||
package windows
|
||||
@@ -0,0 +1,38 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
// Edition identifies the application build channel shown by the UI.
|
||||
type Edition string
|
||||
|
||||
const EditionModern Edition = "Modern"
|
||||
|
||||
// ErrUnsupported reports that a Windows-only capability is unavailable on the
|
||||
// current host. Callers must treat it as an unknown state, never as a stopped
|
||||
// process or a compatible system.
|
||||
var ErrUnsupported = errors.New("windows platform capability is unsupported on this host")
|
||||
|
||||
// Platform is the minimal boundary for target-specific capabilities.
|
||||
type Platform interface {
|
||||
OS() string
|
||||
Edition() Edition
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
IsRunning(appID, entrypoint string) (bool, error)
|
||||
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
|
||||
Start(command launch.Command) (int, error)
|
||||
WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error
|
||||
StartSelfUpdate(command updater.StartCommand) (int, error)
|
||||
SyncDirectory(path string) error
|
||||
}
|
||||
|
||||
// New returns the platform implementation selected by build tags.
|
||||
func New() Platform {
|
||||
return newPlatform()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
type platformStub struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platformStub{}
|
||||
}
|
||||
|
||||
func (platformStub) OS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (platformStub) Edition() Edition {
|
||||
return EditionModern
|
||||
}
|
||||
|
||||
func (platformStub) IsCompatible(string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) IsRunning(string, string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) Start(launch.Command) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) SyncDirectory(string) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func TestPlatformStubFailsClosed(t *testing.T) {
|
||||
platform := New()
|
||||
if _, err := platform.IsRunning("test-app", "C:/test/App.exe"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsRunning() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "one", "App.exe")
|
||||
collision := filepath.Join(root, "two", "App.exe")
|
||||
|
||||
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("entrypointIsRunning() error = %v", err)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
|
||||
}
|
||||
|
||||
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
|
||||
})
|
||||
if err != nil || !running {
|
||||
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
|
||||
expected := errors.New("snapshot failed")
|
||||
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||
return nil, expected
|
||||
})
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||
}
|
||||
|
||||
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
|
||||
})
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSupports(t *testing.T) {
|
||||
cases := []struct {
|
||||
minOS string
|
||||
major, minor, build, service uint32
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
|
||||
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
|
||||
{minOS: "windows-10", major: 10, want: true},
|
||||
{minOS: "windows-11", major: 10, build: 19045, want: false},
|
||||
{minOS: "windows-11", major: 10, build: 22000, want: true},
|
||||
{minOS: "unknown", wantErr: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
|
||||
if (err != nil) != test.wantErr || got != test.want {
|
||||
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForExit(t *testing.T) {
|
||||
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||
errSnapshot := errors.New("snapshot failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
running []bool
|
||||
runErr error
|
||||
timeout time.Duration
|
||||
wantErr error
|
||||
wantSleeps int
|
||||
}{
|
||||
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
clock := &fakeExitWaitClock{now: initial}
|
||||
index := 0
|
||||
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||
if test.runErr != nil {
|
||||
return false, test.runErr
|
||||
}
|
||||
if index >= len(test.running) {
|
||||
return test.running[len(test.running)-1], nil
|
||||
}
|
||||
running := test.running[index]
|
||||
index++
|
||||
return running, nil
|
||||
}, clock)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if clock.sleeps != test.wantSleeps {
|
||||
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canceledWaitContext() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeExitWaitClock struct {
|
||||
now time.Time
|
||||
sleeps int
|
||||
}
|
||||
|
||||
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||
|
||||
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
clock.sleeps++
|
||||
clock.now = clock.now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotItem struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
type scriptedSnapshot struct {
|
||||
items []snapshotItem
|
||||
next int
|
||||
}
|
||||
|
||||
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
|
||||
if snapshot.next == len(snapshot.items) {
|
||||
return "", false, nil
|
||||
}
|
||||
item := snapshot.items[snapshot.next]
|
||||
snapshot.next++
|
||||
return item.path, true, item.err
|
||||
}
|
||||
|
||||
func (*scriptedSnapshot) Close() error { return nil }
|
||||
@@ -0,0 +1,220 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
|
||||
const seeMaskNoCloseProcess = 0x00000040
|
||||
|
||||
var (
|
||||
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
|
||||
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
type platform struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platform{}
|
||||
}
|
||||
|
||||
func (platform) OS() string {
|
||||
return "windows"
|
||||
}
|
||||
|
||||
func (platform) Edition() Edition {
|
||||
return EditionModern
|
||||
}
|
||||
|
||||
func (platform) IsCompatible(minOS string) (bool, error) {
|
||||
version, err := currentVersion()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
|
||||
}
|
||||
|
||||
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
|
||||
return newToolhelpSnapshot(filepath.Base(entrypoint))
|
||||
})
|
||||
}
|
||||
|
||||
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||
return target.IsRunning(appID, entrypoint)
|
||||
}, systemExitWaitClock{})
|
||||
}
|
||||
|
||||
func (platform) Start(command launch.Command) (int, error) {
|
||||
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||
return 0, fmt.Errorf("launch command must contain absolute paths")
|
||||
}
|
||||
if command.RequiresAdmin {
|
||||
return startElevated(command)
|
||||
}
|
||||
|
||||
cmd := exec.Command(command.Entrypoint)
|
||||
cmd.Dir = command.WorkingDirectory
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
type toolhelpSnapshot struct {
|
||||
handle windows.Handle
|
||||
targetName string
|
||||
entry windows.ProcessEntry32
|
||||
started bool
|
||||
}
|
||||
|
||||
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
|
||||
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
|
||||
}
|
||||
|
||||
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
|
||||
for {
|
||||
var err error
|
||||
if !snapshot.started {
|
||||
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
|
||||
err = windows.Process32First(snapshot.handle, &snapshot.entry)
|
||||
snapshot.started = true
|
||||
} else {
|
||||
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
// ExeFile only narrows the expensive query. The identity decision below
|
||||
// always uses QueryFullProcessImageName's normalized full path.
|
||||
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
|
||||
continue
|
||||
}
|
||||
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (snapshot *toolhelpSnapshot) Close() error {
|
||||
return windows.CloseHandle(snapshot.handle)
|
||||
}
|
||||
|
||||
func fullProcessImagePath(pid uint32) (string, error) {
|
||||
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CloseHandle(process)
|
||||
|
||||
for size := uint32(260); size <= 32768; size *= 2 {
|
||||
buffer := make([]uint16, size)
|
||||
length := size
|
||||
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
|
||||
if err == nil {
|
||||
return windows.UTF16ToString(buffer[:length]), nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
|
||||
}
|
||||
|
||||
type rtlOSVersionInfoEx struct {
|
||||
size uint32
|
||||
major uint32
|
||||
minor uint32
|
||||
build uint32
|
||||
platformID uint32
|
||||
csdVersion [128]uint16
|
||||
servicePack uint16
|
||||
servicePackMinor uint16
|
||||
suiteMask uint16
|
||||
productType byte
|
||||
reserved byte
|
||||
}
|
||||
|
||||
func currentVersion() (rtlOSVersionInfoEx, error) {
|
||||
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
|
||||
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
|
||||
if status != 0 {
|
||||
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
type shellExecuteInfo struct {
|
||||
size uint32
|
||||
mask uint32
|
||||
hwnd uintptr
|
||||
verb *uint16
|
||||
file *uint16
|
||||
parameters *uint16
|
||||
directory *uint16
|
||||
show int32
|
||||
instance uintptr
|
||||
idList uintptr
|
||||
class *uint16
|
||||
keyClass uintptr
|
||||
hotKey uint32
|
||||
icon uintptr
|
||||
process windows.Handle
|
||||
}
|
||||
|
||||
func startElevated(command launch.Command) (int, error) {
|
||||
verb, err := windows.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(command.Entrypoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info := shellExecuteInfo{
|
||||
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
|
||||
mask: seeMaskNoCloseProcess,
|
||||
verb: verb,
|
||||
file: file,
|
||||
directory: directory,
|
||||
show: 1,
|
||||
}
|
||||
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
|
||||
if result == 0 {
|
||||
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
|
||||
}
|
||||
if info.process == 0 {
|
||||
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
|
||||
}
|
||||
defer windows.CloseHandle(info.process)
|
||||
pid, err := windows.GetProcessId(info.process)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(pid), nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// processSnapshot is deliberately small so the matching invariant can be
|
||||
// tested without a Windows host. NextImagePath returns more=false only after a
|
||||
// successful end-of-snapshot; any enumeration or image-path failure is an
|
||||
// error, rather than evidence that the target is not running.
|
||||
type processSnapshot interface {
|
||||
NextImagePath() (path string, more bool, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type processSnapshotFactory func() (processSnapshot, error)
|
||||
|
||||
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
|
||||
target, err := canonicalProcessPath(entrypoint)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
|
||||
}
|
||||
|
||||
snapshot, err := newSnapshot()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create process snapshot: %w", err)
|
||||
}
|
||||
defer snapshot.Close()
|
||||
|
||||
for {
|
||||
path, more, err := snapshot.NextImagePath()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("enumerate process image path: %w", err)
|
||||
}
|
||||
if !more {
|
||||
return false, nil
|
||||
}
|
||||
candidate, err := canonicalProcessPath(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("canonicalize process image path: %w", err)
|
||||
}
|
||||
if strings.EqualFold(target, candidate) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalProcessPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pidWaitHandle interface {
|
||||
Wait(time.Duration) (bool, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type pidOpener func(int) (pidWaitHandle, error)
|
||||
|
||||
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("process PID must be positive")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("process wait timeout must be positive")
|
||||
}
|
||||
handle, err := open(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open process %d: %w", pid, err)
|
||||
}
|
||||
defer handle.Close()
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
exited, err := handle.Wait(250 * time.Millisecond)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for process %d: %w", pid, err)
|
||||
}
|
||||
if exited {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return context.DeadlineExceeded
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakePIDHandle struct {
|
||||
exited bool
|
||||
waitErr error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
|
||||
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
|
||||
|
||||
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
|
||||
handle := &fakePIDHandle{exited: true}
|
||||
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
|
||||
if pid != 9 {
|
||||
t.Fatalf("PID = %d, want 9", pid)
|
||||
}
|
||||
return handle, nil
|
||||
})
|
||||
if err != nil || !handle.closed {
|
||||
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
|
||||
openErr := errors.New("access denied")
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
|
||||
t.Fatalf("open error = %v", err)
|
||||
}
|
||||
waitErr := errors.New("wait failed")
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
|
||||
t.Fatalf("wait error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error = %v", err)
|
||||
}
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("timeout error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
|
||||
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
|
||||
}
|
||||
|
||||
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
|
||||
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||
return 0, fmt.Errorf("self-update launch paths must be absolute")
|
||||
}
|
||||
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
|
||||
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
|
||||
command.HealthRequestID == "" {
|
||||
return 0, fmt.Errorf("invalid fixed self-update launch command")
|
||||
}
|
||||
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
|
||||
commandLine.Dir = command.WorkingDirectory
|
||||
if err := commandLine.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return commandLine.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (platform) SyncDirectory(path string) error {
|
||||
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode directory path: %w", err)
|
||||
}
|
||||
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
|
||||
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory handle: %w", err)
|
||||
}
|
||||
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||
_ = syscall.CloseHandle(handle)
|
||||
return fmt.Errorf("flush directory handle: %w", err)
|
||||
}
|
||||
if err := syscall.CloseHandle(handle); err != nil {
|
||||
return fmt.Errorf("close directory handle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type windowsPIDHandle struct{ handle windows.Handle }
|
||||
|
||||
func openWindowsProcess(pid int) (pidWaitHandle, error) {
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return windowsPIDHandle{handle: handle}, nil
|
||||
}
|
||||
|
||||
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
|
||||
milliseconds := uint32(timeout / time.Millisecond)
|
||||
if milliseconds == 0 {
|
||||
milliseconds = 1
|
||||
}
|
||||
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return true, nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
|
||||
@@ -0,0 +1,64 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exitPollInterval = 250 * time.Millisecond
|
||||
|
||||
type exitWaitClock interface {
|
||||
Now() time.Time
|
||||
Wait(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type systemExitWaitClock struct{}
|
||||
|
||||
func (systemExitWaitClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("wait context is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("exit timeout must be positive")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := clock.Now().Add(timeout)
|
||||
for {
|
||||
isRunning, err := running()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isRunning {
|
||||
return nil
|
||||
}
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
interval := exitPollInterval
|
||||
if remaining < interval {
|
||||
interval = remaining
|
||||
}
|
||||
if err := clock.Wait(ctx, interval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-loading 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,46 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
type catalogPresentationState string
|
||||
|
||||
const (
|
||||
catalogStateLoading catalogPresentationState = "loading"
|
||||
catalogStateReady catalogPresentationState = "ready"
|
||||
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||
)
|
||||
|
||||
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
switch payload.Type {
|
||||
case application.EventCatalogRefreshed:
|
||||
shell.SetItems(payload.Items)
|
||||
case application.EventCatalogRejected:
|
||||
switch payload.FailureCode {
|
||||
case application.CatalogFailureSourceUnconfigured:
|
||||
shell.catalogState = catalogStateUnconfigured
|
||||
case application.CatalogFailureLoadFailed:
|
||||
shell.catalogState = catalogStateLoadFailed
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (shell *AppShell) catalogStatusText() string {
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
return "目录状态:正在加载已验证 Catalog"
|
||||
case catalogStateUnconfigured:
|
||||
return "目录状态:Catalog 来源尚未配置"
|
||||
case catalogStateLoadFailed:
|
||||
return "目录状态:Catalog 加载失败"
|
||||
case catalogStateReady:
|
||||
return "目录状态:已加载已验证 Catalog"
|
||||
default:
|
||||
return "目录状态:未知"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||
shell := NewAppShell("Modern")
|
||||
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||
shell := NewAppShell("Modern")
|
||||
if shell.catalogState != catalogStateLoading {
|
||||
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||
}
|
||||
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||
t.Fatal("unconfigured state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||
t.Fatal("load-failed state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||
t.Fatal("loaded-empty state was not visible")
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("bad payload error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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,114 @@
|
||||
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 {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
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}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("ApplyEvent(malformed catalog payload) 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,192 @@
|
||||
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
|
||||
catalogState catalogPresentationState
|
||||
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)
|
||||
if len(items) == 0 {
|
||||
shell.catalogState = catalogStateLoading
|
||||
}
|
||||
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)
|
||||
shell.catalogState = catalogStateReady
|
||||
|
||||
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,340 @@
|
||||
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 = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
title = "正在加载软件目录"
|
||||
body = "正在等待已验证 Catalog 快照。"
|
||||
case catalogStateUnconfigured:
|
||||
title = "Catalog 来源尚未配置"
|
||||
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||
case catalogStateLoadFailed:
|
||||
title = "Catalog 加载失败"
|
||||
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||
case catalogStateReady:
|
||||
title = "Catalog 暂无可显示软件"
|
||||
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||
}
|
||||
}
|
||||
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, shell.catalogStatusText())
|
||||
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,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||
runtime := application.NewRuntime(1)
|
||||
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||
}))
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
event := <-runtime.Events()
|
||||
if event.Type != application.EventCatalogRefreshed {
|
||||
t.Fatalf("event type = %q", event.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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() {
|
||||
if handled, err := acknowledgeInternalUpdateHealth(os.Args[1:]); handled {
|
||||
if err != nil {
|
||||
log.Printf("%s internal update health failed: %v", core.ProductName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("%s Legacy stopped: %v", core.ProductName, err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
return runWithCatalogLoader(application.UnconfiguredCatalogLoader{})
|
||||
}
|
||||
|
||||
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) 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,
|
||||
)
|
||||
}()
|
||||
catalogDone := startCatalogBootstrap(eventContext, runtime, loader)
|
||||
defer func() {
|
||||
cancelEvents()
|
||||
runtime.Close()
|
||||
relay.Close()
|
||||
if bootstrapErr := <-catalogDone; bootstrapErr != nil &&
|
||||
!errors.Is(bootstrapErr, context.Canceled) &&
|
||||
!errors.Is(bootstrapErr, application.ErrCatalogSourceUnconfigured) &&
|
||||
!errors.Is(bootstrapErr, application.ErrRuntimeClosed) &&
|
||||
!errors.Is(bootstrapErr, application.ErrEventRelayClosed) {
|
||||
log.Printf("%s catalog bootstrap failed", core.ProductName)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startCatalogBootstrap(
|
||||
ctx context.Context,
|
||||
runtime *application.Runtime,
|
||||
loader application.CatalogSnapshotLoader,
|
||||
) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.NewCatalogBootstrap(loader, runtime).Run(ctx)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"softbox.local/app-win7/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
|
||||
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
|
||||
return false, nil
|
||||
}
|
||||
if len(arguments) != 2 {
|
||||
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("locate current executable: %w", err)
|
||||
}
|
||||
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
|
||||
return true, fmt.Errorf("acknowledge self-update health: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"softbox.local/app-win7/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
for _, option := range []string{"--pid", "--staging", "--target"} {
|
||||
if err := requireOneOption(arguments, option); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
pid := flags.Int("pid", 0, "main SoftBox PID")
|
||||
staging := flags.String("staging", "", "prepared staging directory")
|
||||
target := flags.String("target", "", "fixed app target directory")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return fmt.Errorf("parse updater arguments: %w", err)
|
||||
}
|
||||
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
|
||||
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
|
||||
}
|
||||
requestID := filepath.Base(filepath.Clean(*staging))
|
||||
platform := windows.New()
|
||||
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
|
||||
ParentExit: 2 * time.Minute,
|
||||
Health: 45 * time.Second,
|
||||
})
|
||||
return service.Update(context.Background(), updater.Request{
|
||||
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func requireOneOption(arguments []string, option string) error {
|
||||
count := 0
|
||||
for _, argument := range arguments {
|
||||
if argument == option || strings.HasPrefix(argument, option+"=") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%s must appear exactly once", option)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
|
||||
if err := run(nil); err == nil {
|
||||
t.Fatal("run(nil) succeeded")
|
||||
}
|
||||
if err := run([]string{
|
||||
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
|
||||
}); err == nil {
|
||||
t.Fatal("run() accepted duplicate --pid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module softbox.local/app-win7
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
gioui.org v0.6.0
|
||||
golang.org/x/sys v0.5.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,11 @@
|
||||
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/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
@@ -0,0 +1,19 @@
|
||||
package windows
|
||||
|
||||
import "fmt"
|
||||
|
||||
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
|
||||
switch minOS {
|
||||
case "windows-7-sp1":
|
||||
if major > 6 || (major == 6 && minor > 1) {
|
||||
return true, nil
|
||||
}
|
||||
return major == 6 && minor == 1 && servicePack >= 1, nil
|
||||
case "windows-10":
|
||||
return major >= 10, nil
|
||||
case "windows-11":
|
||||
return major >= 10 && build >= 22000, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package windows provides Win7-compatible platform adapters and non-Windows
|
||||
// stubs for package-level tests.
|
||||
package windows
|
||||
@@ -0,0 +1,38 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
// Edition identifies the application build channel shown by the UI.
|
||||
type Edition string
|
||||
|
||||
const EditionLegacy Edition = "Legacy"
|
||||
|
||||
// ErrUnsupported reports that a Windows-only capability is unavailable on the
|
||||
// current host. Callers must treat it as an unknown state, never as a stopped
|
||||
// process or a compatible system.
|
||||
var ErrUnsupported = errors.New("windows platform capability is unsupported on this host")
|
||||
|
||||
// Platform is the minimal boundary for target-specific capabilities.
|
||||
type Platform interface {
|
||||
OS() string
|
||||
Edition() Edition
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
IsRunning(appID, entrypoint string) (bool, error)
|
||||
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
|
||||
Start(command launch.Command) (int, error)
|
||||
WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error
|
||||
StartSelfUpdate(command updater.StartCommand) (int, error)
|
||||
SyncDirectory(path string) error
|
||||
}
|
||||
|
||||
// New returns the platform implementation selected by build tags.
|
||||
func New() Platform {
|
||||
return newPlatform()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
type platformStub struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platformStub{}
|
||||
}
|
||||
|
||||
func (platformStub) OS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (platformStub) Edition() Edition {
|
||||
return EditionLegacy
|
||||
}
|
||||
|
||||
func (platformStub) IsCompatible(string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) IsRunning(string, string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) Start(launch.Command) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) SyncDirectory(string) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func TestPlatformStubFailsClosed(t *testing.T) {
|
||||
platform := New()
|
||||
if _, err := platform.IsRunning("test-app", "C:/test/App.exe"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsRunning() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "one", "App.exe")
|
||||
collision := filepath.Join(root, "two", "App.exe")
|
||||
|
||||
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("entrypointIsRunning() error = %v", err)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
|
||||
}
|
||||
|
||||
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
|
||||
})
|
||||
if err != nil || !running {
|
||||
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
|
||||
expected := errors.New("snapshot failed")
|
||||
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||
return nil, expected
|
||||
})
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||
}
|
||||
|
||||
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
|
||||
})
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSupports(t *testing.T) {
|
||||
cases := []struct {
|
||||
minOS string
|
||||
major, minor, build, service uint32
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
|
||||
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
|
||||
{minOS: "windows-10", major: 10, want: true},
|
||||
{minOS: "windows-11", major: 10, build: 19045, want: false},
|
||||
{minOS: "windows-11", major: 10, build: 22000, want: true},
|
||||
{minOS: "unknown", wantErr: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
|
||||
if (err != nil) != test.wantErr || got != test.want {
|
||||
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForExit(t *testing.T) {
|
||||
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||
errSnapshot := errors.New("snapshot failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
running []bool
|
||||
runErr error
|
||||
timeout time.Duration
|
||||
wantErr error
|
||||
wantSleeps int
|
||||
}{
|
||||
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
clock := &fakeExitWaitClock{now: initial}
|
||||
index := 0
|
||||
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||
if test.runErr != nil {
|
||||
return false, test.runErr
|
||||
}
|
||||
if index >= len(test.running) {
|
||||
return test.running[len(test.running)-1], nil
|
||||
}
|
||||
running := test.running[index]
|
||||
index++
|
||||
return running, nil
|
||||
}, clock)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if clock.sleeps != test.wantSleeps {
|
||||
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canceledWaitContext() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeExitWaitClock struct {
|
||||
now time.Time
|
||||
sleeps int
|
||||
}
|
||||
|
||||
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||
|
||||
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
clock.sleeps++
|
||||
clock.now = clock.now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotItem struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
type scriptedSnapshot struct {
|
||||
items []snapshotItem
|
||||
next int
|
||||
}
|
||||
|
||||
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
|
||||
if snapshot.next == len(snapshot.items) {
|
||||
return "", false, nil
|
||||
}
|
||||
item := snapshot.items[snapshot.next]
|
||||
snapshot.next++
|
||||
return item.path, true, item.err
|
||||
}
|
||||
|
||||
func (*scriptedSnapshot) Close() error { return nil }
|
||||
@@ -0,0 +1,220 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
|
||||
const seeMaskNoCloseProcess = 0x00000040
|
||||
|
||||
var (
|
||||
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
|
||||
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
type platform struct{}
|
||||
|
||||
func newPlatform() Platform {
|
||||
return platform{}
|
||||
}
|
||||
|
||||
func (platform) OS() string {
|
||||
return "windows"
|
||||
}
|
||||
|
||||
func (platform) Edition() Edition {
|
||||
return EditionLegacy
|
||||
}
|
||||
|
||||
func (platform) IsCompatible(minOS string) (bool, error) {
|
||||
version, err := currentVersion()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
|
||||
}
|
||||
|
||||
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
|
||||
return newToolhelpSnapshot(filepath.Base(entrypoint))
|
||||
})
|
||||
}
|
||||
|
||||
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||
return target.IsRunning(appID, entrypoint)
|
||||
}, systemExitWaitClock{})
|
||||
}
|
||||
|
||||
func (platform) Start(command launch.Command) (int, error) {
|
||||
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||
return 0, fmt.Errorf("launch command must contain absolute paths")
|
||||
}
|
||||
if command.RequiresAdmin {
|
||||
return startElevated(command)
|
||||
}
|
||||
|
||||
cmd := exec.Command(command.Entrypoint)
|
||||
cmd.Dir = command.WorkingDirectory
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
type toolhelpSnapshot struct {
|
||||
handle windows.Handle
|
||||
targetName string
|
||||
entry windows.ProcessEntry32
|
||||
started bool
|
||||
}
|
||||
|
||||
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
|
||||
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
|
||||
}
|
||||
|
||||
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
|
||||
for {
|
||||
var err error
|
||||
if !snapshot.started {
|
||||
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
|
||||
err = windows.Process32First(snapshot.handle, &snapshot.entry)
|
||||
snapshot.started = true
|
||||
} else {
|
||||
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
// ExeFile only narrows the expensive query. The identity decision below
|
||||
// always uses QueryFullProcessImageName's normalized full path.
|
||||
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
|
||||
continue
|
||||
}
|
||||
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (snapshot *toolhelpSnapshot) Close() error {
|
||||
return windows.CloseHandle(snapshot.handle)
|
||||
}
|
||||
|
||||
func fullProcessImagePath(pid uint32) (string, error) {
|
||||
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CloseHandle(process)
|
||||
|
||||
for size := uint32(260); size <= 32768; size *= 2 {
|
||||
buffer := make([]uint16, size)
|
||||
length := size
|
||||
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
|
||||
if err == nil {
|
||||
return windows.UTF16ToString(buffer[:length]), nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
|
||||
}
|
||||
|
||||
type rtlOSVersionInfoEx struct {
|
||||
size uint32
|
||||
major uint32
|
||||
minor uint32
|
||||
build uint32
|
||||
platformID uint32
|
||||
csdVersion [128]uint16
|
||||
servicePack uint16
|
||||
servicePackMinor uint16
|
||||
suiteMask uint16
|
||||
productType byte
|
||||
reserved byte
|
||||
}
|
||||
|
||||
func currentVersion() (rtlOSVersionInfoEx, error) {
|
||||
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
|
||||
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
|
||||
if status != 0 {
|
||||
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
type shellExecuteInfo struct {
|
||||
size uint32
|
||||
mask uint32
|
||||
hwnd uintptr
|
||||
verb *uint16
|
||||
file *uint16
|
||||
parameters *uint16
|
||||
directory *uint16
|
||||
show int32
|
||||
instance uintptr
|
||||
idList uintptr
|
||||
class *uint16
|
||||
keyClass uintptr
|
||||
hotKey uint32
|
||||
icon uintptr
|
||||
process windows.Handle
|
||||
}
|
||||
|
||||
func startElevated(command launch.Command) (int, error) {
|
||||
verb, err := windows.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(command.Entrypoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info := shellExecuteInfo{
|
||||
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
|
||||
mask: seeMaskNoCloseProcess,
|
||||
verb: verb,
|
||||
file: file,
|
||||
directory: directory,
|
||||
show: 1,
|
||||
}
|
||||
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
|
||||
if result == 0 {
|
||||
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
|
||||
}
|
||||
if info.process == 0 {
|
||||
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
|
||||
}
|
||||
defer windows.CloseHandle(info.process)
|
||||
pid, err := windows.GetProcessId(info.process)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(pid), nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// processSnapshot is deliberately small so the matching invariant can be
|
||||
// tested without a Windows host. NextImagePath returns more=false only after a
|
||||
// successful end-of-snapshot; any enumeration or image-path failure is an
|
||||
// error, rather than evidence that the target is not running.
|
||||
type processSnapshot interface {
|
||||
NextImagePath() (path string, more bool, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type processSnapshotFactory func() (processSnapshot, error)
|
||||
|
||||
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
|
||||
target, err := canonicalProcessPath(entrypoint)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
|
||||
}
|
||||
|
||||
snapshot, err := newSnapshot()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create process snapshot: %w", err)
|
||||
}
|
||||
defer snapshot.Close()
|
||||
|
||||
for {
|
||||
path, more, err := snapshot.NextImagePath()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("enumerate process image path: %w", err)
|
||||
}
|
||||
if !more {
|
||||
return false, nil
|
||||
}
|
||||
candidate, err := canonicalProcessPath(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("canonicalize process image path: %w", err)
|
||||
}
|
||||
if strings.EqualFold(target, candidate) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalProcessPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pidWaitHandle interface {
|
||||
Wait(time.Duration) (bool, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type pidOpener func(int) (pidWaitHandle, error)
|
||||
|
||||
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("process PID must be positive")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("process wait timeout must be positive")
|
||||
}
|
||||
handle, err := open(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open process %d: %w", pid, err)
|
||||
}
|
||||
defer handle.Close()
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
exited, err := handle.Wait(250 * time.Millisecond)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for process %d: %w", pid, err)
|
||||
}
|
||||
if exited {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return context.DeadlineExceeded
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakePIDHandle struct {
|
||||
exited bool
|
||||
waitErr error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
|
||||
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
|
||||
|
||||
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
|
||||
handle := &fakePIDHandle{exited: true}
|
||||
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
|
||||
if pid != 9 {
|
||||
t.Fatalf("PID = %d, want 9", pid)
|
||||
}
|
||||
return handle, nil
|
||||
})
|
||||
if err != nil || !handle.closed {
|
||||
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
|
||||
openErr := errors.New("access denied")
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
|
||||
t.Fatalf("open error = %v", err)
|
||||
}
|
||||
waitErr := errors.New("wait failed")
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
|
||||
t.Fatalf("wait error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error = %v", err)
|
||||
}
|
||||
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("timeout error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//go:build windows
|
||||
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
|
||||
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
|
||||
}
|
||||
|
||||
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
|
||||
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||
return 0, fmt.Errorf("self-update launch paths must be absolute")
|
||||
}
|
||||
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
|
||||
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
|
||||
command.HealthRequestID == "" {
|
||||
return 0, fmt.Errorf("invalid fixed self-update launch command")
|
||||
}
|
||||
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
|
||||
commandLine.Dir = command.WorkingDirectory
|
||||
if err := commandLine.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return commandLine.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (platform) SyncDirectory(path string) error {
|
||||
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode directory path: %w", err)
|
||||
}
|
||||
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
|
||||
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory handle: %w", err)
|
||||
}
|
||||
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||
_ = syscall.CloseHandle(handle)
|
||||
return fmt.Errorf("flush directory handle: %w", err)
|
||||
}
|
||||
if err := syscall.CloseHandle(handle); err != nil {
|
||||
return fmt.Errorf("close directory handle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type windowsPIDHandle struct{ handle windows.Handle }
|
||||
|
||||
func openWindowsProcess(pid int) (pidWaitHandle, error) {
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return windowsPIDHandle{handle: handle}, nil
|
||||
}
|
||||
|
||||
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
|
||||
milliseconds := uint32(timeout / time.Millisecond)
|
||||
if milliseconds == 0 {
|
||||
milliseconds = 1
|
||||
}
|
||||
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return true, nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
|
||||
@@ -0,0 +1,64 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exitPollInterval = 250 * time.Millisecond
|
||||
|
||||
type exitWaitClock interface {
|
||||
Now() time.Time
|
||||
Wait(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type systemExitWaitClock struct{}
|
||||
|
||||
func (systemExitWaitClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("wait context is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("exit timeout must be positive")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := clock.Now().Add(timeout)
|
||||
for {
|
||||
isRunning, err := running()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isRunning {
|
||||
return nil
|
||||
}
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
interval := exitPollInterval
|
||||
if remaining < interval {
|
||||
interval = remaining
|
||||
}
|
||||
if err := clock.Wait(ctx, interval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-loading 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,46 @@
|
||||
package gio
|
||||
|
||||
import "softbox.local/core/application"
|
||||
|
||||
type catalogPresentationState string
|
||||
|
||||
const (
|
||||
catalogStateLoading catalogPresentationState = "loading"
|
||||
catalogStateReady catalogPresentationState = "ready"
|
||||
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||
)
|
||||
|
||||
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||
payload, handled, err := application.ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
return handled, err
|
||||
}
|
||||
switch payload.Type {
|
||||
case application.EventCatalogRefreshed:
|
||||
shell.SetItems(payload.Items)
|
||||
case application.EventCatalogRejected:
|
||||
switch payload.FailureCode {
|
||||
case application.CatalogFailureSourceUnconfigured:
|
||||
shell.catalogState = catalogStateUnconfigured
|
||||
case application.CatalogFailureLoadFailed:
|
||||
shell.catalogState = catalogStateLoadFailed
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (shell *AppShell) catalogStatusText() string {
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
return "目录状态:正在加载已验证 Catalog"
|
||||
case catalogStateUnconfigured:
|
||||
return "目录状态:Catalog 来源尚未配置"
|
||||
case catalogStateLoadFailed:
|
||||
return "目录状态:Catalog 加载失败"
|
||||
case catalogStateReady:
|
||||
return "目录状态:已加载已验证 Catalog"
|
||||
default:
|
||||
return "目录状态:未知"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package gio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||
shell := NewAppShell("Legacy")
|
||||
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||
}
|
||||
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||
shell := NewAppShell("Legacy")
|
||||
if shell.catalogState != catalogStateLoading {
|
||||
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||
}
|
||||
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||
t.Fatal("unconfigured state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRejected,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||
t.Fatal("load-failed state was not visible")
|
||||
}
|
||||
if err := shell.ApplyEvent(application.Event{
|
||||
Type: application.EventCatalogRefreshed,
|
||||
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||
}
|
||||
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||
t.Fatal("loaded-empty state was not visible")
|
||||
}
|
||||
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("bad payload error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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,114 @@
|
||||
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 {
|
||||
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
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}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||
t.Fatalf("ApplyEvent(malformed catalog payload) 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,196 @@
|
||||
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
|
||||
catalogState catalogPresentationState
|
||||
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)
|
||||
if len(items) == 0 {
|
||||
shell.catalogState = catalogStateLoading
|
||||
}
|
||||
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)
|
||||
shell.catalogState = catalogStateReady
|
||||
|
||||
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.catalogStatusText()+" · "+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,313 @@
|
||||
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 = "联网刷新或读取已验证缓存后会显示软件。"
|
||||
switch shell.catalogState {
|
||||
case catalogStateLoading:
|
||||
title = "正在加载软件目录"
|
||||
body = "正在等待已验证 Catalog 快照。"
|
||||
case catalogStateUnconfigured:
|
||||
title = "Catalog 来源尚未配置"
|
||||
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||
case catalogStateLoadFailed:
|
||||
title = "Catalog 加载失败"
|
||||
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||
case catalogStateReady:
|
||||
title = "Catalog 暂无可显示软件"
|
||||
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||
}
|
||||
}
|
||||
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,183 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCatalogSourceUnconfigured means this build has no trusted Catalog
|
||||
// endpoint/key composition. It must never be replaced with test data.
|
||||
ErrCatalogSourceUnconfigured = errors.New("catalog source is unconfigured")
|
||||
ErrCatalogBootstrapInvalid = errors.New("catalog bootstrap is invalid")
|
||||
ErrCatalogEventPayload = errors.New("catalog event payload is invalid")
|
||||
)
|
||||
|
||||
// CatalogSource identifies the verified source that produced a snapshot.
|
||||
type CatalogSource string
|
||||
|
||||
const (
|
||||
CatalogSourceRemote CatalogSource = "remote"
|
||||
CatalogSourceCache CatalogSource = "cache"
|
||||
)
|
||||
|
||||
// CatalogFailureCode is the public, non-sensitive reason shown by adapters.
|
||||
type CatalogFailureCode string
|
||||
|
||||
const (
|
||||
CatalogFailureSourceUnconfigured CatalogFailureCode = "catalog_source_unconfigured"
|
||||
CatalogFailureLoadFailed CatalogFailureCode = "catalog_load_failed"
|
||||
)
|
||||
|
||||
// CatalogSnapshot is an IO-free, verified and target-filtered list prepared
|
||||
// before it crosses into the UI event boundary.
|
||||
type CatalogSnapshot struct {
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoader prepares an already verified in-memory snapshot. Its
|
||||
// implementation belongs in composition/infrastructure, never in Gio Layout.
|
||||
type CatalogSnapshotLoader interface {
|
||||
LoadCatalogSnapshot(context.Context) (CatalogSnapshot, error)
|
||||
}
|
||||
|
||||
// CatalogSnapshotLoaderFunc adapts a function to CatalogSnapshotLoader.
|
||||
type CatalogSnapshotLoaderFunc func(context.Context) (CatalogSnapshot, error)
|
||||
|
||||
func (function CatalogSnapshotLoaderFunc) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
return function(ctx)
|
||||
}
|
||||
|
||||
// EventPublisher is the narrow runtime boundary used by CatalogBootstrap.
|
||||
type EventPublisher interface {
|
||||
Publish(context.Context, Event) error
|
||||
}
|
||||
|
||||
// CatalogEvent is the typed, sanitized payload accepted by Gio adapters.
|
||||
type CatalogEvent struct {
|
||||
Type EventType
|
||||
Items []CatalogListItem
|
||||
Source CatalogSource
|
||||
FailureCode CatalogFailureCode
|
||||
}
|
||||
|
||||
// CatalogBootstrap invokes one loader and publishes exactly one catalog result.
|
||||
// It is safe to run only in a background goroutine.
|
||||
type CatalogBootstrap struct {
|
||||
loader CatalogSnapshotLoader
|
||||
publisher EventPublisher
|
||||
}
|
||||
|
||||
// NewCatalogBootstrap creates the pure-core startup bridge.
|
||||
func NewCatalogBootstrap(loader CatalogSnapshotLoader, publisher EventPublisher) *CatalogBootstrap {
|
||||
return &CatalogBootstrap{loader: loader, publisher: publisher}
|
||||
}
|
||||
|
||||
// Run publishes a sanitized success or failure event. The returned error keeps
|
||||
// the original loader/publisher cause for non-UI diagnostics.
|
||||
func (bootstrap *CatalogBootstrap) Run(ctx context.Context) error {
|
||||
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||
return ErrCatalogBootstrapInvalid
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := bootstrap.loader.LoadCatalogSnapshot(ctx)
|
||||
if err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
if err := validateCatalogSnapshot(snapshot); err != nil {
|
||||
return bootstrap.publishFailure(ctx, err)
|
||||
}
|
||||
return bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRefreshed,
|
||||
Payload: CatalogEvent{
|
||||
Type: EventCatalogRefreshed,
|
||||
Items: cloneCatalogItems(snapshot.Items),
|
||||
Source: snapshot.Source,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (bootstrap *CatalogBootstrap) publishFailure(ctx context.Context, cause error) error {
|
||||
publishErr := bootstrap.publisher.Publish(ctx, Event{
|
||||
Type: EventCatalogRejected,
|
||||
Payload: CatalogEvent{Type: EventCatalogRejected, FailureCode: catalogFailureCode(cause)},
|
||||
})
|
||||
if publishErr != nil {
|
||||
return errors.Join(cause, publishErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// ParseCatalogEvent validates and deep-copies the payload before an adapter
|
||||
// changes UI state. Non-catalog events are left for other event handlers.
|
||||
func ParseCatalogEvent(event Event) (CatalogEvent, bool, error) {
|
||||
if event.Type != EventCatalogRefreshed && event.Type != EventCatalogRejected {
|
||||
return CatalogEvent{}, false, nil
|
||||
}
|
||||
payload, ok := event.Payload.(CatalogEvent)
|
||||
if !ok || payload.Type != event.Type || event.RequestID != "" || event.AppID != "" {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
switch payload.Type {
|
||||
case EventCatalogRefreshed:
|
||||
if payload.FailureCode != "" || validateCatalogSnapshot(CatalogSnapshot{Items: payload.Items, Source: payload.Source}) != nil {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
payload.Items = cloneCatalogItems(payload.Items)
|
||||
return payload, true, nil
|
||||
case EventCatalogRejected:
|
||||
if len(payload.Items) != 0 || payload.Source != "" || !payload.FailureCode.valid() {
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
return payload, true, nil
|
||||
default:
|
||||
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||
}
|
||||
}
|
||||
|
||||
// UnconfiguredCatalogLoader is the fail-closed default for builds where the
|
||||
// trusted endpoint and public key have not been provisioned.
|
||||
type UnconfiguredCatalogLoader struct{}
|
||||
|
||||
func (UnconfiguredCatalogLoader) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
return CatalogSnapshot{}, ErrCatalogSourceUnconfigured
|
||||
}
|
||||
|
||||
func validateCatalogSnapshot(snapshot CatalogSnapshot) error {
|
||||
if !snapshot.Source.valid() {
|
||||
return fmt.Errorf("%w: unrecognized catalog source", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(snapshot.Items))
|
||||
for _, item := range snapshot.Items {
|
||||
if item.ID == "" || item.Name == "" || item.Version == "" {
|
||||
return fmt.Errorf("%w: incomplete catalog item", ErrCatalogBootstrapInvalid)
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate catalog item %q", ErrCatalogBootstrapInvalid, item.ID)
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func catalogFailureCode(err error) CatalogFailureCode {
|
||||
if errors.Is(err, ErrCatalogSourceUnconfigured) {
|
||||
return CatalogFailureSourceUnconfigured
|
||||
}
|
||||
return CatalogFailureLoadFailed
|
||||
}
|
||||
|
||||
func (source CatalogSource) valid() bool {
|
||||
return source == CatalogSourceRemote || source == CatalogSourceCache
|
||||
}
|
||||
|
||||
func (code CatalogFailureCode) valid() bool {
|
||||
return code == CatalogFailureSourceUnconfigured || code == CatalogFailureLoadFailed
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
func TestCatalogBootstrapPublishesIndependentVerifiedSnapshot(t *testing.T) {
|
||||
items := []CatalogListItem{{
|
||||
ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Tags: []string{"json"}, Status: domain.StatusNotInstalled,
|
||||
}}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Items: items, Source: CatalogSourceCache}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
items[0].Name = "mutated"
|
||||
items[0].Tags[0] = "mutated"
|
||||
event := <-runtime.Events()
|
||||
payload, handled, err := ParseCatalogEvent(event)
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("ParseCatalogEvent() = %#v, %v, %v", payload, handled, err)
|
||||
}
|
||||
if payload.Source != CatalogSourceCache || payload.Items[0].Name != "JSON Tool" || payload.Items[0].Tags[0] != "json" {
|
||||
t.Fatalf("payload = %#v, want independent verified snapshot", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapPublishesStableFailureCode(t *testing.T) {
|
||||
loadErr := errors.New("network endpoint details must not reach UI")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
err error
|
||||
code CatalogFailureCode
|
||||
}{
|
||||
{name: "unconfigured", err: ErrCatalogSourceUnconfigured, code: CatalogFailureSourceUnconfigured},
|
||||
{name: "load failed", err: loadErr, code: CatalogFailureLoadFailed},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, test.err
|
||||
}), runtime)
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, test.err) {
|
||||
t.Fatalf("Run() error = %v, want original loader error", err)
|
||||
}
|
||||
payload, handled, parseErr := ParseCatalogEvent(<-runtime.Events())
|
||||
if parseErr != nil || !handled || payload.FailureCode != test.code {
|
||||
t.Fatalf("failure payload = %#v, handled=%v, error=%v", payload, handled, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapFailsClosedForInvalidDependenciesAndPayload(t *testing.T) {
|
||||
if err := (*CatalogBootstrap)(nil).Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("nil bootstrap error = %v", err)
|
||||
}
|
||||
runtime := NewRuntime(1)
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}, nil
|
||||
}), runtime)
|
||||
if err := bootstrap.Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||
t.Fatalf("invalid snapshot error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRejected, Payload: CatalogEvent{Type: EventCatalogRejected}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid rejection payload error = %v", err)
|
||||
}
|
||||
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRefreshed, Payload: CatalogEvent{Type: EventCatalogRefreshed, Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||
t.Fatalf("invalid refresh payload error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredCatalogLoaderHonorsCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (UnconfiguredCatalogLoader{}).LoadCatalogSnapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("LoadCatalogSnapshot() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogBootstrapHonorsCancellationAndPreservesPublisherFailure(t *testing.T) {
|
||||
called := false
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
called = true
|
||||
return CatalogSnapshot{}, nil
|
||||
}), NewRuntime(1))
|
||||
if err := bootstrap.Run(ctx); !errors.Is(err, context.Canceled) || called {
|
||||
t.Fatalf("canceled Run() = %v, loader called=%v", err, called)
|
||||
}
|
||||
publishErr := errors.New("runtime publish unavailable")
|
||||
loaderErr := errors.New("loader failed")
|
||||
bootstrap = NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||
return CatalogSnapshot{}, loaderErr
|
||||
}), catalogFailPublisher{err: publishErr})
|
||||
err := bootstrap.Run(context.Background())
|
||||
if !errors.Is(err, loaderErr) || !errors.Is(err, publishErr) {
|
||||
t.Fatalf("combined failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type catalogFailPublisher struct{ err error }
|
||||
|
||||
func (publisher catalogFailPublisher) Publish(context.Context, Event) error { return publisher.err }
|
||||
@@ -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,414 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
|
||||
"softbox.local/core/catalog"
|
||||
"softbox.local/core/installer"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInstallServiceConfig = errors.New("invalid install service configuration")
|
||||
ErrInstallRequestInvalid = errors.New("invalid install request")
|
||||
ErrInstallRecordWrite = errors.New("write installed app record")
|
||||
ErrDiskSpaceInsufficient = errors.New("insufficient disk space for staging")
|
||||
ErrDiskSpaceCheck = errors.New("disk space check failed")
|
||||
ErrTargetRunning = errors.New("installed app is running")
|
||||
ErrTargetStateCheck = errors.New("target state check failed")
|
||||
)
|
||||
|
||||
const StagingDiskReserveBytes int64 = 64 * 1024 * 1024
|
||||
|
||||
// InstallStage makes the security-sensitive installation path observable to a
|
||||
// background caller without giving the Gio layout any filesystem work.
|
||||
type InstallStage string
|
||||
|
||||
const (
|
||||
InstallStageVerify InstallStage = "verify"
|
||||
InstallStageManifest InstallStage = "manifest"
|
||||
InstallStagePreflight InstallStage = "preflight"
|
||||
InstallStageExtract InstallStage = "extract"
|
||||
InstallStageRecover InstallStage = "recover"
|
||||
InstallStageSwitch InstallStage = "switch"
|
||||
InstallStageHealth InstallStage = "health"
|
||||
InstallStageRecord InstallStage = "record"
|
||||
InstallStageRollback InstallStage = "rollback"
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of an installation
|
||||
// attempt. UI code may localize this code but must not display raw errors.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureCodeHashMismatch FailureCode = "hash_mismatch"
|
||||
FailureCodeZIPPathEscape FailureCode = "zip_path_escape"
|
||||
FailureCodeZIPCorrupt FailureCode = "zip_corrupt"
|
||||
FailureCodePackageInvalid FailureCode = "package_invalid"
|
||||
FailureCodeDiskFull FailureCode = "disk_full"
|
||||
FailureCodeDiskCheckFailed FailureCode = "disk_check_failed"
|
||||
FailureCodeAppRunning FailureCode = "app_running"
|
||||
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||||
FailureCodeInstallFailed FailureCode = "install_failed"
|
||||
)
|
||||
|
||||
// InstallError preserves a stable stage and its underlying cause.
|
||||
type InstallError struct {
|
||||
Stage InstallStage
|
||||
Code FailureCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *InstallError) Error() string {
|
||||
return fmt.Sprintf("install %s (%s): %v", err.Stage, err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *InstallError) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// InstallRecordStore provides the app-root and installed-app record boundary
|
||||
// needed by an installation transaction.
|
||||
type InstallRecordStore interface {
|
||||
EnsureAppRoot(appID string) (string, error)
|
||||
Write(record storage.InstalledApp) error
|
||||
}
|
||||
|
||||
// DiskSpaceChecker reports bytes currently available on the volume that
|
||||
// contains appRoot. Platform-specific implementations stay outside core.
|
||||
type DiskSpaceChecker interface {
|
||||
AvailableBytes(appRoot string) (int64, error)
|
||||
}
|
||||
|
||||
// StorageFailureClassifier lets the platform identify a preserved staging I/O
|
||||
// failure as insufficient storage without importing platform APIs into core.
|
||||
type StorageFailureClassifier interface {
|
||||
IsDiskFull(err error) bool
|
||||
}
|
||||
|
||||
// TargetStateChecker reports whether the verified current entrypoint is still
|
||||
// running. It never starts, waits for, or terminates a process.
|
||||
type TargetStateChecker interface {
|
||||
IsRunning(appID string, entrypointPath string) (bool, error)
|
||||
}
|
||||
|
||||
// InstallRequest joins an untrusted completed download with the trusted
|
||||
// Catalog selection that describes it.
|
||||
type InstallRequest struct {
|
||||
Entry catalog.Entry
|
||||
Architecture catalog.Architecture
|
||||
DownloadPath string
|
||||
}
|
||||
|
||||
// InstallResult describes an installed version after the switch commits.
|
||||
type InstallResult struct {
|
||||
AppID string
|
||||
Version string
|
||||
EntrypointPath string
|
||||
Recovery installer.RecoveryResult
|
||||
}
|
||||
|
||||
// InstallServiceConfig makes all external installation dependencies explicit.
|
||||
// Disk, storage-failure and target-state checks are mandatory so no caller can
|
||||
// silently bypass the pre-extract safety boundary or disk-full diagnosis.
|
||||
type InstallServiceConfig struct {
|
||||
Extractor installer.Extractor
|
||||
Records InstallRecordStore
|
||||
Health installer.HealthCheck
|
||||
DiskSpace DiskSpaceChecker
|
||||
StorageFailures StorageFailureClassifier
|
||||
TargetState TargetStateChecker
|
||||
}
|
||||
|
||||
// InstallService implements the core-only verified package installation use
|
||||
// case. The caller must supply entries produced by catalog.Client.
|
||||
type InstallService struct {
|
||||
extractor installer.Extractor
|
||||
records InstallRecordStore
|
||||
health installer.HealthCheck
|
||||
diskSpace DiskSpaceChecker
|
||||
storageFailures StorageFailureClassifier
|
||||
targetState TargetStateChecker
|
||||
}
|
||||
|
||||
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig)
|
||||
}
|
||||
if config.Health == nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired)
|
||||
}
|
||||
if config.DiskSpace == nil {
|
||||
return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig)
|
||||
}
|
||||
if config.StorageFailures == nil {
|
||||
return nil, fmt.Errorf("%w: storage failure classifier is required", ErrInstallServiceConfig)
|
||||
}
|
||||
if config.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
|
||||
}
|
||||
return &InstallService{
|
||||
extractor: config.Extractor,
|
||||
records: config.Records,
|
||||
health: config.Health,
|
||||
diskSpace: config.DiskSpace,
|
||||
storageFailures: config.StorageFailures,
|
||||
targetState: config.TargetState,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Install verifies and extracts one completed download, then atomically
|
||||
// switches staging into current. Metadata is written inside the Switcher
|
||||
// health phase so a write failure follows the same rollback path as health.
|
||||
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
|
||||
expectation, record, err := resolveInstallRequest(request)
|
||||
if err != nil {
|
||||
return InstallResult{}, service.installError(InstallStageVerify, err)
|
||||
}
|
||||
|
||||
appRoot, err := service.records.EnsureAppRoot(record.ID)
|
||||
if err != nil {
|
||||
return InstallResult{}, service.installError(InstallStageRecover, err)
|
||||
}
|
||||
recovery, err := installer.Recover(appRoot)
|
||||
if err != nil {
|
||||
return InstallResult{}, service.installError(InstallStageRecover, err)
|
||||
}
|
||||
|
||||
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
|
||||
request.DownloadPath,
|
||||
filepath.Join(appRoot, "staging"),
|
||||
expectation,
|
||||
service.preExtractCheck(appRoot, record.ID),
|
||||
)
|
||||
if err != nil {
|
||||
return InstallResult{}, service.installError(stageForPackageError(err), err)
|
||||
}
|
||||
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
|
||||
for _, file := range extracted.PayloadFiles {
|
||||
record.Files = append(record.Files, storage.InstalledFile{
|
||||
Path: file.Path,
|
||||
Size: file.Size,
|
||||
SHA256: file.SHA256,
|
||||
})
|
||||
}
|
||||
record.Entrypoint = expectation.App.Entrypoint
|
||||
record.WorkingDirectory = extracted.WorkingDir
|
||||
record.MinOS = expectation.App.MinOS
|
||||
record.RequiresAdmin = expectation.App.RequiresAdmin
|
||||
|
||||
var recordWriteErr error
|
||||
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
|
||||
if err := service.health(currentPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.records.Write(record); err != nil {
|
||||
recordWriteErr = err
|
||||
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
|
||||
}
|
||||
return nil
|
||||
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
|
||||
if err := switcher.Switch(appRoot); err != nil {
|
||||
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
||||
}
|
||||
return InstallResult{
|
||||
AppID: record.ID,
|
||||
Version: record.Version,
|
||||
EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint),
|
||||
Recovery: recovery,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *InstallService) preSwitchCheck(
|
||||
appRoot string,
|
||||
appID string,
|
||||
entrypoint string,
|
||||
) installer.PreSwitchCheck {
|
||||
return func() error {
|
||||
running, err := service.targetState.IsRunning(
|
||||
appID,
|
||||
filepath.Join(appRoot, "current", entrypoint),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
||||
}
|
||||
if running {
|
||||
return ErrTargetRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (service *InstallService) preExtractCheck(
|
||||
appRoot string,
|
||||
appID string,
|
||||
) installer.PreExtractCheck {
|
||||
return func(verified installer.VerifiedPackage) error {
|
||||
required, err := requiredStagingBytes(verified.PayloadBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
available, err := service.diskSpace.AvailableBytes(appRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrDiskSpaceCheck, err)
|
||||
}
|
||||
if available < 0 {
|
||||
return fmt.Errorf("%w: negative available bytes", ErrDiskSpaceCheck)
|
||||
}
|
||||
if available < required {
|
||||
return fmt.Errorf("%w: available=%d required=%d", ErrDiskSpaceInsufficient, available, required)
|
||||
}
|
||||
|
||||
running, err := service.targetState.IsRunning(
|
||||
appID,
|
||||
filepath.Join(appRoot, "current", verified.Entrypoint),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
||||
}
|
||||
if running {
|
||||
return ErrTargetRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func requiredStagingBytes(payloadBytes int64) (int64, error) {
|
||||
if payloadBytes < 0 || payloadBytes > math.MaxInt64-StagingDiskReserveBytes {
|
||||
return 0, fmt.Errorf("%w: invalid payload size", ErrDiskSpaceCheck)
|
||||
}
|
||||
return payloadBytes + StagingDiskReserveBytes, nil
|
||||
}
|
||||
|
||||
func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) {
|
||||
if request.DownloadPath == "" {
|
||||
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||
"%w: completed download path is empty",
|
||||
ErrInstallRequestInvalid,
|
||||
)
|
||||
}
|
||||
if !request.Entry.Installable || request.Entry.Package == nil {
|
||||
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||
"%w: Catalog entry is not installable",
|
||||
ErrInstallRequestInvalid,
|
||||
)
|
||||
}
|
||||
if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 {
|
||||
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||
"%w: unsupported architecture %q",
|
||||
ErrInstallRequestInvalid,
|
||||
request.Architecture,
|
||||
)
|
||||
}
|
||||
publishedPackage, exists := request.Entry.App.Packages[request.Architecture]
|
||||
if !exists || publishedPackage != *request.Entry.Package {
|
||||
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||
"%w: selected package does not match app architecture",
|
||||
ErrInstallRequestInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
app := request.Entry.App
|
||||
return installer.PackageExpectation{
|
||||
Size: publishedPackage.Size,
|
||||
SHA256: publishedPackage.SHA256,
|
||||
App: installer.AppExpectation{
|
||||
ID: app.ID,
|
||||
Version: app.Version,
|
||||
Channel: string(app.Channel),
|
||||
MinOS: string(app.MinOS),
|
||||
Architecture: string(request.Architecture),
|
||||
Entrypoint: app.EntryEXE,
|
||||
RequiresAdmin: app.RequiresAdmin,
|
||||
},
|
||||
}, storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: app.ID,
|
||||
Version: app.Version,
|
||||
Architecture: string(request.Architecture),
|
||||
Channel: string(app.Channel),
|
||||
Files: []storage.InstalledFile{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *InstallService) installError(stage InstallStage, err error) error {
|
||||
return &InstallError{Stage: stage, Code: service.failureCodeFor(err), Err: err}
|
||||
}
|
||||
|
||||
func (service *InstallService) failureCodeFor(err error) FailureCode {
|
||||
if errors.Is(err, installer.ErrStagingOutput) && service.storageFailures.IsDiskFull(err) {
|
||||
return FailureCodeDiskFull
|
||||
}
|
||||
return failureCodeFor(err)
|
||||
}
|
||||
|
||||
func stageForPackageError(err error) InstallStage {
|
||||
var packageErr *installer.PackageError
|
||||
if errors.As(err, &packageErr) {
|
||||
switch packageErr.Stage {
|
||||
case installer.PackageStageManifest:
|
||||
return InstallStageManifest
|
||||
case installer.PackageStagePreflight:
|
||||
return InstallStagePreflight
|
||||
case installer.PackageStageExtract:
|
||||
return InstallStageExtract
|
||||
}
|
||||
}
|
||||
return InstallStageVerify
|
||||
}
|
||||
|
||||
func failureCodeFor(err error) FailureCode {
|
||||
switch {
|
||||
case errors.Is(err, ErrDiskSpaceInsufficient):
|
||||
return FailureCodeDiskFull
|
||||
case errors.Is(err, ErrDiskSpaceCheck):
|
||||
return FailureCodeDiskCheckFailed
|
||||
case errors.Is(err, ErrTargetRunning):
|
||||
return FailureCodeAppRunning
|
||||
case errors.Is(err, ErrTargetStateCheck):
|
||||
return FailureCodeTargetStateUnavailable
|
||||
case errors.Is(err, installer.ErrPackageHashMismatch),
|
||||
errors.Is(err, installer.ErrArchiveSizeMismatch):
|
||||
return FailureCodeHashMismatch
|
||||
case errors.Is(err, installer.ErrPathEscape),
|
||||
errors.Is(err, installer.ErrEntrypointInvalid):
|
||||
return FailureCodeZIPPathEscape
|
||||
case errors.Is(err, installer.ErrPackageExpectationInvalid),
|
||||
errors.Is(err, installer.ErrAppManifestTooLarge),
|
||||
errors.Is(err, installer.ErrAppManifestInvalid),
|
||||
errors.Is(err, installer.ErrAppManifestMissing),
|
||||
errors.Is(err, installer.ErrPackageIdentityMismatch),
|
||||
errors.Is(err, installer.ErrEntrypointMissing),
|
||||
errors.Is(err, installer.ErrUnexpectedEntry),
|
||||
errors.Is(err, installer.ErrUnsupportedEntry),
|
||||
errors.Is(err, installer.ErrEncryptedEntry):
|
||||
return FailureCodePackageInvalid
|
||||
case errors.Is(err, installer.ErrInvalidArchive),
|
||||
errors.Is(err, installer.ErrArchiveCorrupt),
|
||||
errors.Is(err, installer.ErrArchiveTooLarge),
|
||||
errors.Is(err, installer.ErrCentralDirectoryTooLarge),
|
||||
errors.Is(err, installer.ErrTooManyEntries),
|
||||
errors.Is(err, installer.ErrExpandedTooLarge),
|
||||
errors.Is(err, installer.ErrCompressionRatio),
|
||||
errors.Is(err, installer.ErrDuplicateEntry):
|
||||
return FailureCodeZIPCorrupt
|
||||
default:
|
||||
return FailureCodeInstallFailed
|
||||
}
|
||||
}
|
||||
|
||||
func stageForSwitchError(err error, recordWriteErr error) InstallStage {
|
||||
if errors.Is(err, installer.ErrRollbackFailed) {
|
||||
return InstallStageRollback
|
||||
}
|
||||
if recordWriteErr != nil {
|
||||
return InstallStageRecord
|
||||
}
|
||||
if errors.Is(err, installer.ErrHealthCheckFailed) {
|
||||
return InstallStageHealth
|
||||
}
|
||||
return InstallStageSwitch
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/catalog"
|
||||
"softbox.local/core/installer"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
service := newInstallService(t, store, func(currentPath string) error {
|
||||
_, err := os.Stat(filepath.Join(currentPath, "bin", "App.exe"))
|
||||
return err
|
||||
})
|
||||
|
||||
result, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Install() error = %v", err)
|
||||
}
|
||||
if result.AppID != "test-app" || result.Version != "1.2.3" || result.Recovery.Action != installer.RecoveryNone {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "new executable" {
|
||||
t.Fatalf("current entrypoint = %q", got)
|
||||
}
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
if record.Version != "1.2.3" || len(record.Files) != 2 {
|
||||
t.Fatalf("record = %#v", record)
|
||||
}
|
||||
if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." ||
|
||||
record.MinOS != "windows-10" || record.RequiresAdmin {
|
||||
t.Fatalf("launch metadata = %#v", record)
|
||||
}
|
||||
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
|
||||
t.Fatalf("record first file = %#v", record.Files[0])
|
||||
}
|
||||
hash := sha256.Sum256([]byte("new executable"))
|
||||
if record.Files[0].SHA256 != hex.EncodeToString(hash[:]) {
|
||||
t.Fatalf("record first hash = %q", record.Files[0].SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
tests := []struct {
|
||||
name string
|
||||
entry catalog.Entry
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "selected package differs from architecture package",
|
||||
entry: func() catalog.Entry {
|
||||
entry := installEntry(publishedPackage, "1.2.3")
|
||||
forged := publishedPackage
|
||||
forged.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
entry.Package = &forged
|
||||
return entry
|
||||
}(),
|
||||
wantErr: ErrInstallRequestInvalid,
|
||||
wantCode: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "download hash differs from Catalog",
|
||||
entry: func() catalog.Entry {
|
||||
entry := installEntry(publishedPackage, "1.2.3")
|
||||
entry.App.Packages[catalog.ArchitectureAMD64] = catalog.Package{
|
||||
Size: publishedPackage.Size,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Signature: publishedPackage.Signature,
|
||||
URL: publishedPackage.URL,
|
||||
}
|
||||
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
|
||||
return entry
|
||||
}(),
|
||||
wantErr: installer.ErrPackageHashMismatch,
|
||||
wantCode: FailureCodeHashMismatch,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
service := newInstallService(t, store, func(string) error { return nil })
|
||||
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: test.entry,
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStageVerify {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("rejected install left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
service func(t *testing.T) *InstallService
|
||||
wantStage InstallStage
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "health failure",
|
||||
service: func(t *testing.T) *InstallService {
|
||||
return newInstallService(t, store, func(string) error { return errors.New("health failed") })
|
||||
},
|
||||
wantStage: InstallStageHealth,
|
||||
wantErr: installer.ErrHealthCheckFailed,
|
||||
},
|
||||
{
|
||||
name: "record write failure",
|
||||
service: func(t *testing.T) *InstallService {
|
||||
return newInstallService(t, &failingRecordStore{
|
||||
InstalledAppStore: store,
|
||||
writeErr: errors.New("record disk error"),
|
||||
}, func(string) error { return nil })
|
||||
},
|
||||
wantStage: InstallStageRecord,
|
||||
wantErr: ErrInstallRecordWrite,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
_, err := test.service(t).Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != test.wantStage {
|
||||
t.Fatalf("stage = %q, want %q", stage, test.wantStage)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current entrypoint after failure = %q", got)
|
||||
}
|
||||
record, found, readErr := store.Read("test-app")
|
||||
if readErr != nil || !found || record.Version != "1.0.0" {
|
||||
t.Fatalf("record after failure found=%t record=%#v err=%v", found, record, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceRechecksTargetImmediatelyBeforeUpdateSwitch(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
||||
targetErr := errors.New("target probe failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
probe func(int) (bool, error)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "target starts during extraction",
|
||||
probe: func(call int) (bool, error) {
|
||||
return call == 2, nil
|
||||
},
|
||||
wantErr: ErrTargetRunning,
|
||||
wantCode: FailureCodeAppRunning,
|
||||
},
|
||||
{
|
||||
name: "target state fails at switch",
|
||||
probe: func(call int) (bool, error) {
|
||||
if call == 2 {
|
||||
return false, targetErr
|
||||
}
|
||||
return false, nil
|
||||
},
|
||||
wantErr: targetErr,
|
||||
wantCode: FailureCodeTargetStateUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
service := newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
func(string) error { return nil },
|
||||
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return StagingDiskReserveBytes + 64*1024, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(appID, entrypoint string) (bool, error) {
|
||||
calls++
|
||||
if appID != "test-app" || entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
||||
t.Fatalf("target check = (%q, %q)", appID, entrypoint)
|
||||
}
|
||||
return test.probe(calls)
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStageSwitch {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStageSwitch)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("target check calls = %d, want 2", calls)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current after failed update = %q", got)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
||||
t.Fatal("installed-app record changed after failed update")
|
||||
}
|
||||
for _, path := range []string{"staging", "backup", "install-transaction.json"} {
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, path)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed update left %s, stat error = %v", path, statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
||||
extractor, err := installer.NewExtractor(installTestLimits())
|
||||
if err != nil {
|
||||
t.Fatalf("NewExtractor() error = %v", err)
|
||||
}
|
||||
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
|
||||
config := InstallServiceConfig{
|
||||
Extractor: extractor,
|
||||
Records: store,
|
||||
Health: func(string) error { return nil },
|
||||
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return StagingDiskReserveBytes, nil
|
||||
}),
|
||||
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||
return false
|
||||
}),
|
||||
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modify func(*InstallServiceConfig)
|
||||
}{
|
||||
{
|
||||
name: "disk space checker",
|
||||
modify: func(config *InstallServiceConfig) {
|
||||
config.DiskSpace = nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "target state checker",
|
||||
modify: func(config *InstallServiceConfig) {
|
||||
config.TargetState = nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storage failure classifier",
|
||||
modify: func(config *InstallServiceConfig) {
|
||||
config.StorageFailures = nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.modify(&candidate)
|
||||
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
|
||||
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureCodeForPackageFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
code FailureCode
|
||||
}{
|
||||
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
|
||||
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
|
||||
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
|
||||
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
|
||||
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := failureCodeFor(test.err); got != test.code {
|
||||
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceClassifiesStagingOutputFailures(t *testing.T) {
|
||||
errDiskFull := errors.New("injected disk full")
|
||||
errOutput := errors.New("injected output failure")
|
||||
service := &InstallService{
|
||||
storageFailures: storageFailureClassifierFunc(func(err error) bool {
|
||||
return errors.Is(err, errDiskFull)
|
||||
}),
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
err error
|
||||
code FailureCode
|
||||
}{
|
||||
{
|
||||
name: "write disk full",
|
||||
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||
code: FailureCodeDiskFull,
|
||||
},
|
||||
{
|
||||
name: "sync disk full",
|
||||
err: fmt.Errorf("%w: sync staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||
code: FailureCodeDiskFull,
|
||||
},
|
||||
{
|
||||
name: "close disk full",
|
||||
err: fmt.Errorf("%w: close staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||
code: FailureCodeDiskFull,
|
||||
},
|
||||
{
|
||||
name: "generic output I O",
|
||||
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errOutput),
|
||||
code: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "ZIP input remains corrupt",
|
||||
err: fmt.Errorf("%w: %w", installer.ErrArchiveCorrupt, errDiskFull),
|
||||
code: FailureCodeZIPCorrupt,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
wrapped := service.installError(InstallStageExtract, test.err)
|
||||
var installErr *InstallError
|
||||
if !errors.As(wrapped, &installErr) {
|
||||
t.Fatalf("install error = %v, want InstallError", wrapped)
|
||||
}
|
||||
if installErr.Code != test.code {
|
||||
t.Fatalf("code = %q, want %q", installErr.Code, test.code)
|
||||
}
|
||||
if !errors.Is(wrapped, test.err) {
|
||||
t.Fatalf("install error = %v, want preserved cause", wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||
var diskRoot string
|
||||
var targetAppID, targetEntrypoint string
|
||||
service := newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
func(string) error { return nil },
|
||||
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
|
||||
diskRoot = appRoot
|
||||
return required, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
|
||||
targetAppID = appID
|
||||
targetEntrypoint = entrypointPath
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
if _, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
}); err != nil {
|
||||
t.Fatalf("Install() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
if diskRoot != appRoot {
|
||||
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
|
||||
}
|
||||
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
||||
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
service := newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
func(string) error { return nil },
|
||||
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, ErrDiskSpaceInsufficient) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
for _, managed := range []string{"staging", "current"} {
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||
diskProbeErr := errors.New("disk probe unavailable")
|
||||
targetProbeErr := errors.New("target state unavailable")
|
||||
tests := []struct {
|
||||
name string
|
||||
disk DiskSpaceChecker
|
||||
target TargetStateChecker
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "insufficient disk space",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required - 1, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
wantErr: ErrDiskSpaceInsufficient,
|
||||
wantCode: FailureCodeDiskFull,
|
||||
},
|
||||
{
|
||||
name: "disk capacity check fails",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return 0, diskProbeErr
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
wantErr: diskProbeErr,
|
||||
wantCode: FailureCodeDiskCheckFailed,
|
||||
},
|
||||
{
|
||||
name: "current app is running",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}),
|
||||
wantErr: ErrTargetRunning,
|
||||
wantCode: FailureCodeAppRunning,
|
||||
},
|
||||
{
|
||||
name: "target state check fails",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, targetProbeErr
|
||||
}),
|
||||
wantErr: targetProbeErr,
|
||||
wantCode: FailureCodeTargetStateUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current entrypoint after failure = %q", got)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
||||
t.Fatal("installed-app record changed after preflight failure")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
|
||||
document, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read corrupted archive: %v", err)
|
||||
}
|
||||
hash := sha256.Sum256(document)
|
||||
publishedPackage.Size = int64(len(document))
|
||||
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
|
||||
|
||||
service := newInstallService(t, store, func(string) error { return nil })
|
||||
_, err = service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, installer.ErrArchiveCorrupt) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStageExtract {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current entrypoint after corruption = %q", got)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
appRoot, err := store.EnsureAppRoot("test-app")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAppRoot() error = %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(appRoot, "current"), 0o700); err != nil {
|
||||
t.Fatalf("create current: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(appRoot, "staging"), 0o700); err != nil {
|
||||
t.Fatalf("create stale staging: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(appRoot, "install-transaction.json"),
|
||||
[]byte(`{"schema_version":1,"phase":"prepared","had_current":true}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write transaction: %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
service := newInstallService(t, store, func(string) error { return nil })
|
||||
result, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Install() error = %v", err)
|
||||
}
|
||||
if result.Recovery.Action != installer.RecoveryAborted {
|
||||
t.Fatalf("recovery action = %q, want %q", result.Recovery.Action, installer.RecoveryAborted)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "new executable" {
|
||||
t.Fatalf("current entrypoint = %q", got)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("staging remains, stat error = %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "install-transaction.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transaction remains, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRecordStore struct {
|
||||
*storage.InstalledAppStore
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func (store *failingRecordStore) Write(storage.InstalledApp) error {
|
||||
return store.writeErr
|
||||
}
|
||||
|
||||
func newInstallService(
|
||||
t *testing.T,
|
||||
store InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
) *InstallService {
|
||||
return newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
health,
|
||||
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return StagingDiskReserveBytes + 64*1024, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func newInstallServiceWithCheckers(
|
||||
t *testing.T,
|
||||
store InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
diskSpace DiskSpaceChecker,
|
||||
targetState TargetStateChecker,
|
||||
) *InstallService {
|
||||
t.Helper()
|
||||
extractor, err := installer.NewExtractor(installTestLimits())
|
||||
if err != nil {
|
||||
t.Fatalf("NewExtractor() error = %v", err)
|
||||
}
|
||||
service, err := NewInstallService(InstallServiceConfig{
|
||||
Extractor: extractor,
|
||||
Records: store,
|
||||
Health: health,
|
||||
DiskSpace: diskSpace,
|
||||
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||
return false
|
||||
}),
|
||||
TargetState: targetState,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewInstallService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
type diskSpaceCheckerFunc func(string) (int64, error)
|
||||
|
||||
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
|
||||
return check(appRoot)
|
||||
}
|
||||
|
||||
type storageFailureClassifierFunc func(error) bool
|
||||
|
||||
func (classifier storageFailureClassifierFunc) IsDiskFull(err error) bool {
|
||||
return classifier(err)
|
||||
}
|
||||
|
||||
type targetStateCheckerFunc func(string, string) (bool, error)
|
||||
|
||||
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
||||
return check(appID, entrypointPath)
|
||||
}
|
||||
|
||||
func installTestLimits() installer.Limits {
|
||||
return installer.Limits{
|
||||
MaxEntries: 20,
|
||||
MaxArchiveBytes: 64 * 1024,
|
||||
MaxCentralDirectoryBytes: 4 * 1024,
|
||||
MaxUncompressedBytes: 64 * 1024,
|
||||
MaxCompressionRatio: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func installEntry(publishedPackage catalog.Package, version string) catalog.Entry {
|
||||
selectedPackage := publishedPackage
|
||||
return catalog.Entry{
|
||||
App: catalog.App{
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Channel: catalog.ReleaseStable,
|
||||
Status: catalog.CatalogStatusActive,
|
||||
MinOS: catalog.Windows10,
|
||||
Architectures: []catalog.Architecture{catalog.ArchitectureAMD64},
|
||||
EntryEXE: "bin/App.exe",
|
||||
Packages: map[catalog.Architecture]catalog.Package{
|
||||
catalog.ArchitectureAMD64: publishedPackage,
|
||||
},
|
||||
},
|
||||
Package: &selectedPackage,
|
||||
Installable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func writeInstallPackage(t *testing.T, version, executable string) (string, catalog.Package) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "package.download")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create package: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
entries := []struct {
|
||||
name string
|
||||
body []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{name: "app.json", body: installManifest(version)},
|
||||
{name: "payload/bin/App.exe", body: []byte(executable), mode: 0o755},
|
||||
{name: "payload/readme.txt", body: []byte("readme")},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
header := &zip.FileHeader{Name: entry.name}
|
||||
mode := entry.mode
|
||||
if mode == 0 {
|
||||
mode = 0o600
|
||||
}
|
||||
header.SetMode(mode)
|
||||
part, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatalf("create ZIP entry: %v", err)
|
||||
}
|
||||
if _, err := part.Write(entry.body); err != nil {
|
||||
t.Fatalf("write ZIP entry: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("close ZIP writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close package: %v", err)
|
||||
}
|
||||
document, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read package: %v", err)
|
||||
}
|
||||
hash := sha256.Sum256(document)
|
||||
return path, catalog.Package{
|
||||
URL: "https://download.invalid/test-app.zip",
|
||||
Size: int64(len(document)),
|
||||
SHA256: hex.EncodeToString(hash[:]),
|
||||
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
}
|
||||
}
|
||||
|
||||
func installManifest(version string) []byte {
|
||||
return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"bin/App.exe","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`)
|
||||
}
|
||||
|
||||
func installErrorStage(t *testing.T, err error) InstallStage {
|
||||
t.Helper()
|
||||
var installErr *InstallError
|
||||
if !errors.As(err, &installErr) {
|
||||
t.Fatalf("error %v is not InstallError", err)
|
||||
}
|
||||
return installErr.Stage
|
||||
}
|
||||
|
||||
func installErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var installErr *InstallError
|
||||
if !errors.As(err, &installErr) {
|
||||
t.Fatalf("error %v is not InstallError", err)
|
||||
}
|
||||
return installErr.Code
|
||||
}
|
||||
|
||||
func corruptInstallPackageEntry(t *testing.T, archivePath, entryName string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open ZIP for corruption: %v", err)
|
||||
}
|
||||
var offset int64 = -1
|
||||
for _, file := range reader.File {
|
||||
if file.Name != entryName {
|
||||
continue
|
||||
}
|
||||
offset, err = file.DataOffset()
|
||||
if err != nil {
|
||||
_ = reader.Close()
|
||||
t.Fatalf("entry data offset: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatalf("close ZIP reader: %v", err)
|
||||
}
|
||||
if offset < 0 {
|
||||
t.Fatalf("entry %s not found", entryName)
|
||||
}
|
||||
|
||||
document, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ZIP for corruption: %v", err)
|
||||
}
|
||||
document[offset] ^= 0xff
|
||||
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
|
||||
t.Fatalf("write corrupted ZIP: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
document, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(document)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// Package launch contains the core-only, fail-closed application startup use
|
||||
// case. Platform process, compatibility and process-creation capabilities are
|
||||
// supplied by the caller.
|
||||
package launch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"softbox.local/core/internal/safepath"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLaunchConfig = errors.New("invalid launch service configuration")
|
||||
ErrLaunchRequest = errors.New("invalid launch request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed")
|
||||
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
|
||||
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
|
||||
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
|
||||
ErrCompatibilityCheck = errors.New("system compatibility check failed")
|
||||
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
|
||||
ErrAuthorizationCheck = errors.New("launch authorization check failed")
|
||||
ErrLaunchUnauthorized = errors.New("launch is not authorized")
|
||||
ErrTargetStateCheck = errors.New("launch target state check failed")
|
||||
ErrAppRunning = errors.New("installed app is already running")
|
||||
ErrProcessStart = errors.New("start installed app")
|
||||
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of a launch attempt.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureCodeNotInstalled FailureCode = "not_installed"
|
||||
FailureCodeLaunchMetadataInvalid FailureCode = "launch_metadata_invalid"
|
||||
FailureCodeLaunchTargetUnsafe FailureCode = "launch_target_unsafe"
|
||||
FailureCodeEntrypointMissing FailureCode = "entrypoint_missing"
|
||||
FailureCodeCompatibilityUnavailable FailureCode = "compatibility_unavailable"
|
||||
FailureCodeAppIncompatible FailureCode = "app_incompatible"
|
||||
FailureCodeAuthorizationFailed FailureCode = "authorization_unavailable"
|
||||
FailureCodeLaunchUnauthorized FailureCode = "not_authorized"
|
||||
FailureCodeAppRunning FailureCode = "app_running"
|
||||
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||||
FailureCodeLaunchFailed FailureCode = "launch_failed"
|
||||
)
|
||||
|
||||
// Error preserves a stable launch code and the diagnostic cause.
|
||||
type Error struct {
|
||||
Code FailureCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
return fmt.Sprintf("launch (%s): %v", err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *Error) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// Request names the installed app to start. The caller cannot supply a path,
|
||||
// arguments or working directory.
|
||||
type Request struct {
|
||||
AppID string
|
||||
}
|
||||
|
||||
// Command contains only paths resolved from verified installed metadata.
|
||||
type Command struct {
|
||||
Entrypoint string
|
||||
WorkingDirectory string
|
||||
RequiresAdmin bool
|
||||
}
|
||||
|
||||
// Result is returned only after the platform accepted the process start.
|
||||
type Result struct {
|
||||
AppID string
|
||||
PID int
|
||||
}
|
||||
|
||||
// InstalledAppResolver returns an installed record paired with a validated
|
||||
// real current directory.
|
||||
type InstalledAppResolver interface {
|
||||
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
||||
}
|
||||
|
||||
// CompatibilityChecker reports whether a recorded min_os may run here.
|
||||
type CompatibilityChecker interface {
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
}
|
||||
|
||||
// AuthorizationChecker decides whether the user may launch one app. It is a
|
||||
// required boundary; license policy is implemented by the later licensing task.
|
||||
type AuthorizationChecker interface {
|
||||
IsAuthorized(appID string) (bool, error)
|
||||
}
|
||||
|
||||
// TargetStateChecker reports whether this precise entrypoint is running.
|
||||
type TargetStateChecker interface {
|
||||
IsRunning(appID string, entrypointPath string) (bool, error)
|
||||
}
|
||||
|
||||
// ProcessLauncher starts one verified command without accepting shell input.
|
||||
type ProcessLauncher interface {
|
||||
Start(command Command) (int, error)
|
||||
}
|
||||
|
||||
// ServiceConfig makes every external launch dependency explicit.
|
||||
type ServiceConfig struct {
|
||||
Records InstalledAppResolver
|
||||
Compatibility CompatibilityChecker
|
||||
Authorization AuthorizationChecker
|
||||
TargetState TargetStateChecker
|
||||
Launcher ProcessLauncher
|
||||
}
|
||||
|
||||
// Service executes the safe local launch flow.
|
||||
type Service struct {
|
||||
records InstalledAppResolver
|
||||
compatibility CompatibilityChecker
|
||||
authorization AuthorizationChecker
|
||||
targetState TargetStateChecker
|
||||
launcher ProcessLauncher
|
||||
}
|
||||
|
||||
func NewService(config ServiceConfig) (*Service, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: installed app resolver is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Compatibility == nil {
|
||||
return nil, fmt.Errorf("%w: compatibility checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Authorization == nil {
|
||||
return nil, fmt.Errorf("%w: authorization checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrLaunchConfig)
|
||||
}
|
||||
if config.Launcher == nil {
|
||||
return nil, fmt.Errorf("%w: process launcher is required", ErrLaunchConfig)
|
||||
}
|
||||
return &Service{
|
||||
records: config.Records,
|
||||
compatibility: config.Compatibility,
|
||||
authorization: config.Authorization,
|
||||
targetState: config.TargetState,
|
||||
launcher: config.Launcher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start validates local metadata and filesystem identity before invoking the
|
||||
// platform process launcher.
|
||||
func (service *Service) Start(request Request) (Result, error) {
|
||||
if !launchAppIDPattern.MatchString(request.AppID) {
|
||||
return Result{}, launchError(ErrLaunchRequest)
|
||||
}
|
||||
record, current, err := service.records.ResolveCurrent(request.AppID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Result{}, launchError(ErrAppNotInstalled)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrLaunchTargetUnsafe, err))
|
||||
}
|
||||
return Result{}, launchError(fmt.Errorf("resolve installed app: %w", err))
|
||||
}
|
||||
command, err := launchCommand(record, current)
|
||||
if err != nil {
|
||||
return Result{}, launchError(err)
|
||||
}
|
||||
compatible, err := service.compatibility.IsCompatible(record.MinOS)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrCompatibilityCheck, err))
|
||||
}
|
||||
if !compatible {
|
||||
return Result{}, launchError(ErrAppIncompatible)
|
||||
}
|
||||
authorized, err := service.authorization.IsAuthorized(record.ID)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
|
||||
}
|
||||
if !authorized {
|
||||
return Result{}, launchError(ErrLaunchUnauthorized)
|
||||
}
|
||||
running, err := service.targetState.IsRunning(record.ID, command.Entrypoint)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||||
}
|
||||
if running {
|
||||
return Result{}, launchError(ErrAppRunning)
|
||||
}
|
||||
pid, err := service.launcher.Start(command)
|
||||
if err != nil {
|
||||
return Result{}, launchError(fmt.Errorf("%w: %w", ErrProcessStart, err))
|
||||
}
|
||||
if pid <= 0 {
|
||||
return Result{}, launchError(fmt.Errorf("%w: invalid process ID", ErrProcessStart))
|
||||
}
|
||||
return Result{AppID: record.ID, PID: pid}, nil
|
||||
}
|
||||
|
||||
func launchCommand(record storage.InstalledApp, current string) (Command, error) {
|
||||
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||||
return Command{}, ErrLaunchMetadata
|
||||
}
|
||||
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchMetadata, err)
|
||||
}
|
||||
if record.WorkingDirectory != "." {
|
||||
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchMetadata, err)
|
||||
}
|
||||
}
|
||||
if !validMinOS(record.MinOS) {
|
||||
return Command{}, ErrLaunchMetadata
|
||||
}
|
||||
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint is not in installed files", ErrLaunchMetadata)
|
||||
}
|
||||
if err := requireRealDirectory(current); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchTargetUnsafe, err)
|
||||
}
|
||||
workingDirectory := current
|
||||
if record.WorkingDirectory != "." {
|
||||
workingDirectory, err = safepath.JoinUnder(current, record.WorkingDirectory)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchTargetUnsafe, err)
|
||||
}
|
||||
}
|
||||
if err := requireRealDirectory(workingDirectory); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
if err := requireRegularFile(entrypoint); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return Command{
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDirectory: workingDirectory,
|
||||
RequiresAdmin: record.RequiresAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
||||
for _, file := range files {
|
||||
if file.Path == entrypoint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validMinOS(minOS string) bool {
|
||||
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
||||
}
|
||||
|
||||
func requireRealDirectory(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %s", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("%w: %s is not a real directory", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireRegularFile(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %s", ErrEntrypointMissing, path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%w: %s is not a regular file", ErrLaunchTargetUnsafe, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func launchError(err error) error {
|
||||
return &Error{Code: failureCodeFor(err), Err: err}
|
||||
}
|
||||
|
||||
func failureCodeFor(err error) FailureCode {
|
||||
switch {
|
||||
case errors.Is(err, ErrAppNotInstalled):
|
||||
return FailureCodeNotInstalled
|
||||
case errors.Is(err, ErrLaunchMetadata):
|
||||
return FailureCodeLaunchMetadataInvalid
|
||||
case errors.Is(err, ErrLaunchTargetUnsafe):
|
||||
return FailureCodeLaunchTargetUnsafe
|
||||
case errors.Is(err, ErrEntrypointMissing):
|
||||
return FailureCodeEntrypointMissing
|
||||
case errors.Is(err, ErrCompatibilityCheck):
|
||||
return FailureCodeCompatibilityUnavailable
|
||||
case errors.Is(err, ErrAppIncompatible):
|
||||
return FailureCodeAppIncompatible
|
||||
case errors.Is(err, ErrAuthorizationCheck):
|
||||
return FailureCodeAuthorizationFailed
|
||||
case errors.Is(err, ErrLaunchUnauthorized):
|
||||
return FailureCodeLaunchUnauthorized
|
||||
case errors.Is(err, ErrAppRunning):
|
||||
return FailureCodeAppRunning
|
||||
case errors.Is(err, ErrTargetStateCheck):
|
||||
return FailureCodeTargetStateUnavailable
|
||||
default:
|
||||
return FailureCodeLaunchFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t)
|
||||
launcher := &recordingLauncher{pid: 42}
|
||||
service := newService(t, store, launcher)
|
||||
|
||||
result, err := service.Start(Request{AppID: "test-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if result != (Result{AppID: "test-app", PID: 42}) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
wantEntrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if launcher.command.Entrypoint != wantEntrypoint ||
|
||||
launcher.command.WorkingDirectory != filepath.Join(appRoot, "current", "bin") ||
|
||||
!launcher.command.RequiresAdmin {
|
||||
t.Fatalf("command = %#v", launcher.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
|
||||
errCompatibility := errors.New("compatibility unavailable")
|
||||
errAuthorization := errors.New("authorization unavailable")
|
||||
errTargetState := errors.New("target state unavailable")
|
||||
errStart := errors.New("start failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*storage.InstalledApp, string)
|
||||
service func(*Service)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "missing legacy metadata",
|
||||
mutate: func(record *storage.InstalledApp, _ string) {
|
||||
record.Entrypoint = ""
|
||||
},
|
||||
wantErr: ErrLaunchMetadata,
|
||||
wantCode: FailureCodeLaunchMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "entrypoint is absent",
|
||||
mutate: func(_ *storage.InstalledApp, appRoot string) {
|
||||
if err := os.Remove(filepath.Join(appRoot, "current", "bin", "App.exe")); err != nil {
|
||||
t.Fatalf("remove entrypoint: %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: ErrEntrypointMissing,
|
||||
wantCode: FailureCodeEntrypointMissing,
|
||||
},
|
||||
{
|
||||
name: "unsafe current layout",
|
||||
service: func(service *Service) {
|
||||
service.records = resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
||||
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
||||
})
|
||||
},
|
||||
wantErr: ErrLaunchTargetUnsafe,
|
||||
wantCode: FailureCodeLaunchTargetUnsafe,
|
||||
},
|
||||
{
|
||||
name: "incompatible system",
|
||||
service: func(service *Service) {
|
||||
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, nil })
|
||||
},
|
||||
wantErr: ErrAppIncompatible,
|
||||
wantCode: FailureCodeAppIncompatible,
|
||||
},
|
||||
{
|
||||
name: "compatibility failure",
|
||||
service: func(service *Service) {
|
||||
service.compatibility = compatibilityFunc(func(string) (bool, error) { return false, errCompatibility })
|
||||
},
|
||||
wantErr: errCompatibility,
|
||||
wantCode: FailureCodeCompatibilityUnavailable,
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
service: func(service *Service) {
|
||||
service.authorization = authorizationFunc(func(string) (bool, error) { return false, nil })
|
||||
},
|
||||
wantErr: ErrLaunchUnauthorized,
|
||||
wantCode: FailureCodeLaunchUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "authorization failure",
|
||||
service: func(service *Service) {
|
||||
service.authorization = authorizationFunc(func(string) (bool, error) { return false, errAuthorization })
|
||||
},
|
||||
wantErr: errAuthorization,
|
||||
wantCode: FailureCodeAuthorizationFailed,
|
||||
},
|
||||
{
|
||||
name: "already running",
|
||||
service: func(service *Service) {
|
||||
service.targetState = targetStateFunc(func(string, string) (bool, error) { return true, nil })
|
||||
},
|
||||
wantErr: ErrAppRunning,
|
||||
wantCode: FailureCodeAppRunning,
|
||||
},
|
||||
{
|
||||
name: "target state failure",
|
||||
service: func(service *Service) {
|
||||
service.targetState = targetStateFunc(func(string, string) (bool, error) { return false, errTargetState })
|
||||
},
|
||||
wantErr: errTargetState,
|
||||
wantCode: FailureCodeTargetStateUnavailable,
|
||||
},
|
||||
{
|
||||
name: "launcher failure",
|
||||
service: func(service *Service) {
|
||||
service.launcher = &recordingLauncher{err: errStart}
|
||||
},
|
||||
wantErr: errStart,
|
||||
wantCode: FailureCodeLaunchFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t)
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
if test.mutate != nil {
|
||||
test.mutate(&record, appRoot)
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
}
|
||||
launcher := &recordingLauncher{pid: 42}
|
||||
service := newService(t, store, launcher)
|
||||
if test.service != nil {
|
||||
test.service(service)
|
||||
}
|
||||
|
||||
_, err = service.Start(Request{AppID: "test-app"})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Start() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := launchErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if launcher.calls != 0 {
|
||||
t.Fatalf("launcher calls = %d, want 0", launcher.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresEveryDependency(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t)
|
||||
config := ServiceConfig{
|
||||
Records: store,
|
||||
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
||||
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Launcher: &recordingLauncher{pid: 1},
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ServiceConfig)
|
||||
}{
|
||||
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
||||
{"compatibility", func(config *ServiceConfig) { config.Compatibility = nil }},
|
||||
{"authorization", func(config *ServiceConfig) { config.Authorization = nil }},
|
||||
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
||||
{"launcher", func(config *ServiceConfig) { config.Launcher = nil }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.mutate(&candidate)
|
||||
if _, err := NewService(candidate); !errors.Is(err, ErrLaunchConfig) {
|
||||
t.Fatalf("NewService() error = %v, want %v", err, ErrLaunchConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
|
||||
t.Helper()
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
record := storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: "test-app",
|
||||
Version: "1.2.3",
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
RequiresAdmin: true,
|
||||
Files: []storage.InstalledFile{{
|
||||
Path: "bin/App.exe",
|
||||
Size: 1,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}},
|
||||
}
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, record.ID)
|
||||
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(entrypoint, []byte("x"), 0o700); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
return store, appRoot
|
||||
}
|
||||
|
||||
func newService(t *testing.T, records InstalledAppResolver, launcher ProcessLauncher) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(ServiceConfig{
|
||||
Records: records,
|
||||
Compatibility: compatibilityFunc(func(string) (bool, error) { return true, nil }),
|
||||
Authorization: authorizationFunc(func(string) (bool, error) { return true, nil }),
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Launcher: launcher,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func launchErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var launchErr *Error
|
||||
if !errors.As(err, &launchErr) {
|
||||
t.Fatalf("error = %v, want launch Error", err)
|
||||
}
|
||||
return launchErr.Code
|
||||
}
|
||||
|
||||
type compatibilityFunc func(string) (bool, error)
|
||||
|
||||
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
||||
|
||||
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
||||
return resolver(appID)
|
||||
}
|
||||
|
||||
func (checker compatibilityFunc) IsCompatible(minOS string) (bool, error) {
|
||||
return checker(minOS)
|
||||
}
|
||||
|
||||
type authorizationFunc func(string) (bool, error)
|
||||
|
||||
func (checker authorizationFunc) IsAuthorized(appID string) (bool, error) {
|
||||
return checker(appID)
|
||||
}
|
||||
|
||||
type targetStateFunc func(string, string) (bool, error)
|
||||
|
||||
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
||||
return checker(appID, entrypoint)
|
||||
}
|
||||
|
||||
type recordingLauncher struct {
|
||||
command Command
|
||||
pid int
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (launcher *recordingLauncher) Start(command Command) (int, error) {
|
||||
launcher.calls++
|
||||
launcher.command = command
|
||||
if launcher.err != nil {
|
||||
return 0, launcher.err
|
||||
}
|
||||
return launcher.pid, nil
|
||||
}
|
||||
@@ -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,322 @@
|
||||
// Package update contains the core-only application update orchestration.
|
||||
// It never obtains downloads, controls a window, or terminates a process.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/install"
|
||||
"softbox.local/core/domain"
|
||||
"softbox.local/core/internal/safepath"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
minCloseTimeout = time.Second
|
||||
maxCloseTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUpdateConfig = errors.New("invalid update service configuration")
|
||||
ErrUpdateRequest = errors.New("invalid update request")
|
||||
ErrAppNotInstalled = errors.New("app is not installed for update")
|
||||
ErrUpdateMetadata = errors.New("installed update metadata is invalid")
|
||||
ErrUpdateTargetUnsafe = errors.New("installed update target is unsafe")
|
||||
ErrUpdateNotAvailable = errors.New("catalog target is not newer than installed version")
|
||||
ErrTargetStateCheck = errors.New("update target state check failed")
|
||||
ErrCloseConfirmation = errors.New("update close confirmation failed")
|
||||
ErrCloseDeclined = errors.New("update close request was declined")
|
||||
ErrExitWait = errors.New("wait for update target exit failed")
|
||||
updateAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
)
|
||||
|
||||
// FailureCode is the stable, non-localized result of a failed update attempt.
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureCodeNotInstalled FailureCode = "not_installed"
|
||||
FailureCodeUpdateMetadataInvalid FailureCode = "update_metadata_invalid"
|
||||
FailureCodeUpdateTargetUnsafe FailureCode = "update_target_unsafe"
|
||||
FailureCodeUpdateNotAvailable FailureCode = "update_not_available"
|
||||
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||||
FailureCodeCloseConfirmationFailed FailureCode = "close_confirmation_unavailable"
|
||||
FailureCodeCloseDeclined FailureCode = "close_declined"
|
||||
FailureCodeExitWaitCanceled FailureCode = "exit_wait_canceled"
|
||||
FailureCodeExitWaitTimedOut FailureCode = "exit_wait_timeout"
|
||||
FailureCodeExitWaitFailed FailureCode = "exit_wait_unavailable"
|
||||
FailureCodeInstallFailed FailureCode = "install_failed"
|
||||
)
|
||||
|
||||
// Error preserves a stable update code and the diagnostic cause. Installation
|
||||
// errors remain in the unwrap chain so their stage and code stay observable.
|
||||
type Error struct {
|
||||
Code FailureCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
return fmt.Sprintf("update (%s): %v", err.Code, err.Err)
|
||||
}
|
||||
|
||||
func (err *Error) Unwrap() error {
|
||||
return err.Err
|
||||
}
|
||||
|
||||
// Request carries the trusted Catalog selection and completed-file candidate
|
||||
// already required by install.InstallService. It accepts no process path,
|
||||
// command, URL, or UI-provided version.
|
||||
type Request struct {
|
||||
Install install.InstallRequest
|
||||
}
|
||||
|
||||
// Result reports the version committed by the existing installer.
|
||||
type Result struct {
|
||||
AppID string
|
||||
Version string
|
||||
}
|
||||
|
||||
// InstalledAppResolver supplies one verified record and its real current root.
|
||||
type InstalledAppResolver interface {
|
||||
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
||||
}
|
||||
|
||||
// TargetStateChecker observes the precise old entrypoint before a close
|
||||
// request. It must not turn a query failure into a stopped result.
|
||||
type TargetStateChecker interface {
|
||||
IsRunning(appID string, entrypointPath string) (bool, error)
|
||||
}
|
||||
|
||||
// CloseConfirmer obtains the user's decision outside of Gio Layout. It must
|
||||
// not close or terminate the process itself.
|
||||
type CloseConfirmer interface {
|
||||
ConfirmClose(ctx context.Context, appID string) (bool, error)
|
||||
}
|
||||
|
||||
// ExitWaiter waits only for natural exit of one precise entrypoint. A timeout
|
||||
// is reported as context.DeadlineExceeded and cancellation retains ctx.Err().
|
||||
type ExitWaiter interface {
|
||||
WaitForExit(ctx context.Context, appID, entrypointPath string, timeout time.Duration) error
|
||||
}
|
||||
|
||||
// InstallRunner is the verified installation boundary. The update service
|
||||
// delegates all staging, switch, rollback and second running-state checks to it.
|
||||
type InstallRunner interface {
|
||||
Install(request install.InstallRequest) (install.InstallResult, error)
|
||||
}
|
||||
|
||||
// ServiceConfig makes the close confirmation and exit-wait policy explicit.
|
||||
type ServiceConfig struct {
|
||||
Records InstalledAppResolver
|
||||
TargetState TargetStateChecker
|
||||
Confirmation CloseConfirmer
|
||||
ExitWaiter ExitWaiter
|
||||
Installer InstallRunner
|
||||
CloseTimeout time.Duration
|
||||
}
|
||||
|
||||
// Service implements a safe, non-destructive update flow.
|
||||
type Service struct {
|
||||
records InstalledAppResolver
|
||||
targetState TargetStateChecker
|
||||
confirmation CloseConfirmer
|
||||
exitWaiter ExitWaiter
|
||||
installer InstallRunner
|
||||
closeTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewService validates every dependency. There is deliberately no default
|
||||
// confirmation, wait policy, or installer implementation.
|
||||
func NewService(config ServiceConfig) (*Service, error) {
|
||||
if config.Records == nil {
|
||||
return nil, fmt.Errorf("%w: installed app resolver is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.TargetState == nil {
|
||||
return nil, fmt.Errorf("%w: target state checker is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.Confirmation == nil {
|
||||
return nil, fmt.Errorf("%w: close confirmer is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.ExitWaiter == nil {
|
||||
return nil, fmt.Errorf("%w: exit waiter is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.Installer == nil {
|
||||
return nil, fmt.Errorf("%w: install runner is required", ErrUpdateConfig)
|
||||
}
|
||||
if config.CloseTimeout < minCloseTimeout || config.CloseTimeout > maxCloseTimeout {
|
||||
return nil, fmt.Errorf("%w: close timeout must be between %s and %s", ErrUpdateConfig, minCloseTimeout, maxCloseTimeout)
|
||||
}
|
||||
return &Service{
|
||||
records: config.Records,
|
||||
targetState: config.TargetState,
|
||||
confirmation: config.Confirmation,
|
||||
exitWaiter: config.ExitWaiter,
|
||||
installer: config.Installer,
|
||||
closeTimeout: config.CloseTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update confirms a running app may be closed, waits for its natural exit, and
|
||||
// then delegates to the verified installer. The installer repeats target-state
|
||||
// checks before extraction and immediately before replacing current.
|
||||
func (service *Service) Update(ctx context.Context, request Request) (Result, error) {
|
||||
if ctx == nil {
|
||||
return Result{}, updateError(ErrUpdateRequest)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
appID := request.Install.Entry.App.ID
|
||||
if !updateAppIDPattern.MatchString(appID) {
|
||||
return Result{}, updateError(ErrUpdateRequest)
|
||||
}
|
||||
record, current, err := service.records.ResolveCurrent(appID)
|
||||
if err != nil {
|
||||
return Result{}, updateResolverError(err)
|
||||
}
|
||||
entrypoint, err := updateEntrypoint(record, current)
|
||||
if err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
if err := validateTargetVersion(record, request.Install); err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
|
||||
running, err := service.targetState.IsRunning(record.ID, entrypoint)
|
||||
if err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||||
}
|
||||
if running {
|
||||
confirmed, err := service.confirmation.ConfirmClose(ctx, record.ID)
|
||||
if err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrCloseConfirmation, err))
|
||||
}
|
||||
if !confirmed {
|
||||
return Result{}, updateError(ErrCloseDeclined)
|
||||
}
|
||||
if err := service.exitWaiter.WaitForExit(ctx, record.ID, entrypoint, service.closeTimeout); err != nil {
|
||||
return Result{}, updateError(fmt.Errorf("%w: %w", ErrExitWait, err))
|
||||
}
|
||||
}
|
||||
|
||||
installed, err := service.installer.Install(request.Install)
|
||||
if err != nil {
|
||||
return Result{}, updateError(err)
|
||||
}
|
||||
return Result{AppID: installed.AppID, Version: installed.Version}, nil
|
||||
}
|
||||
|
||||
func validateTargetVersion(record storage.InstalledApp, request install.InstallRequest) error {
|
||||
app := request.Entry.App
|
||||
if request.DownloadPath == "" || app.ID != record.ID || request.Entry.Package == nil || !request.Entry.Installable {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
if request.Architecture != "386" && request.Architecture != "amd64" {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
publishedPackage, exists := app.Packages[request.Architecture]
|
||||
if !exists || publishedPackage != *request.Entry.Package {
|
||||
return ErrUpdateRequest
|
||||
}
|
||||
comparison, err := domain.CompareSemVer(app.Version, record.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUpdateRequest, err)
|
||||
}
|
||||
if comparison <= 0 {
|
||||
return ErrUpdateNotAvailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateEntrypoint(record storage.InstalledApp, current string) (string, error) {
|
||||
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||||
return "", ErrUpdateMetadata
|
||||
}
|
||||
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateMetadata, err)
|
||||
}
|
||||
if record.WorkingDirectory != "." {
|
||||
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||
return "", fmt.Errorf("%w: working directory: %v", ErrUpdateMetadata, err)
|
||||
}
|
||||
}
|
||||
if !validMinOS(record.MinOS) {
|
||||
return "", ErrUpdateMetadata
|
||||
}
|
||||
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||||
return "", fmt.Errorf("%w: entrypoint is not in installed files", ErrUpdateMetadata)
|
||||
}
|
||||
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateTargetUnsafe, err)
|
||||
}
|
||||
info, err := os.Lstat(entrypoint)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("%w: entrypoint is missing", ErrUpdateTargetUnsafe)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: inspect entrypoint: %w", ErrUpdateTargetUnsafe, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("%w: entrypoint is not a regular file", ErrUpdateTargetUnsafe)
|
||||
}
|
||||
return entrypoint, nil
|
||||
}
|
||||
|
||||
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
||||
for _, file := range files {
|
||||
if file.Path == entrypoint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validMinOS(minOS string) bool {
|
||||
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
||||
}
|
||||
|
||||
func updateResolverError(err error) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return updateError(ErrAppNotInstalled)
|
||||
}
|
||||
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
||||
return updateError(fmt.Errorf("%w: %w", ErrUpdateTargetUnsafe, err))
|
||||
}
|
||||
return updateError(fmt.Errorf("resolve installed app: %w", err))
|
||||
}
|
||||
|
||||
func updateError(err error) error {
|
||||
return &Error{Code: failureCodeFor(err), Err: err}
|
||||
}
|
||||
|
||||
func failureCodeFor(err error) FailureCode {
|
||||
switch {
|
||||
case errors.Is(err, ErrAppNotInstalled):
|
||||
return FailureCodeNotInstalled
|
||||
case errors.Is(err, ErrUpdateMetadata):
|
||||
return FailureCodeUpdateMetadataInvalid
|
||||
case errors.Is(err, ErrUpdateTargetUnsafe):
|
||||
return FailureCodeUpdateTargetUnsafe
|
||||
case errors.Is(err, ErrUpdateNotAvailable):
|
||||
return FailureCodeUpdateNotAvailable
|
||||
case errors.Is(err, ErrTargetStateCheck):
|
||||
return FailureCodeTargetStateUnavailable
|
||||
case errors.Is(err, ErrCloseDeclined):
|
||||
return FailureCodeCloseDeclined
|
||||
case errors.Is(err, context.Canceled):
|
||||
return FailureCodeExitWaitCanceled
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return FailureCodeExitWaitTimedOut
|
||||
case errors.Is(err, ErrCloseConfirmation):
|
||||
return FailureCodeCloseConfirmationFailed
|
||||
case errors.Is(err, ErrExitWait):
|
||||
return FailureCodeExitWaitFailed
|
||||
default:
|
||||
return FailureCodeInstallFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/install"
|
||||
"softbox.local/core/catalog"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestServiceUpdatesAfterNaturalExit(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
result, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if result != (Result{AppID: "test-app", Version: "1.1.0"}) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if confirmation.calls != 1 || waiter.calls != 1 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 1 each", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
if waiter.appID != "test-app" || waiter.entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") || waiter.timeout != 30*time.Second {
|
||||
t.Fatalf("waiter input = %#v", waiter)
|
||||
}
|
||||
if installer.request.Entry.App.Version != "1.1.0" {
|
||||
t.Fatalf("installer request = %#v", installer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSkipsCloseFlowWhenTargetAlreadyStopped(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
if _, err := service.Update(context.Background(), updateRequest("1.1.0")); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 0/0/1", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsInvalidOrUnreadyUpdatesBeforeSideEffects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T) (InstalledAppResolver, Request)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "not installed",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrAppNotInstalled,
|
||||
wantCode: FailureCodeNotInstalled,
|
||||
},
|
||||
{
|
||||
name: "legacy record lacks launch metadata",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
record.Entrypoint = ""
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
return store, updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateMetadata,
|
||||
wantCode: FailureCodeUpdateMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "same version",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
return store, updateRequest("1.0.0")
|
||||
},
|
||||
wantErr: ErrUpdateNotAvailable,
|
||||
wantCode: FailureCodeUpdateNotAvailable,
|
||||
},
|
||||
{
|
||||
name: "catalog package mismatches selected architecture",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
request := updateRequest("1.1.0")
|
||||
different := *request.Install.Entry.Package
|
||||
different.Size++
|
||||
request.Install.Entry.Package = &different
|
||||
return store, request
|
||||
},
|
||||
wantErr: ErrUpdateRequest,
|
||||
wantCode: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "unsafe current layout",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
||||
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
||||
}), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateTargetUnsafe,
|
||||
wantCode: FailureCodeUpdateTargetUnsafe,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
records, request := test.prepare(t)
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, records, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), request)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseFailuresDoNotInstallOrModifyUserRoots(t *testing.T) {
|
||||
errConfirmation := errors.New("confirmation unavailable")
|
||||
errWait := errors.New("snapshot unavailable")
|
||||
tests := []struct {
|
||||
name string
|
||||
context func() context.Context
|
||||
confirm recordingConfirmation
|
||||
wait recordingExitWaiter
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "close declined",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: false},
|
||||
wantErr: ErrCloseDeclined,
|
||||
wantCode: FailureCodeCloseDeclined,
|
||||
},
|
||||
{
|
||||
name: "confirmation unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{err: errConfirmation},
|
||||
wantErr: errConfirmation,
|
||||
wantCode: FailureCodeCloseConfirmationFailed,
|
||||
},
|
||||
{
|
||||
name: "wait canceled",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.Canceled},
|
||||
wantErr: context.Canceled,
|
||||
wantCode: FailureCodeExitWaitCanceled,
|
||||
},
|
||||
{
|
||||
name: "wait timed out",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.DeadlineExceeded},
|
||||
wantErr: context.DeadlineExceeded,
|
||||
wantCode: FailureCodeExitWaitTimedOut,
|
||||
},
|
||||
{
|
||||
name: "wait unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: errWait},
|
||||
wantErr: errWait,
|
||||
wantCode: FailureCodeExitWaitFailed,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
data := filepath.Join(filepath.Dir(appRoot), "..", "data", "test-app", "data.txt")
|
||||
license := filepath.Join(filepath.Dir(appRoot), "..", "licenses", "license.txt")
|
||||
for _, path := range []string{data, license} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(path), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||
}
|
||||
}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), &test.confirm, &test.wait, installer)
|
||||
|
||||
_, err := service.Update(test.context(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if installer.calls != 0 {
|
||||
t.Fatalf("installer calls = %d, want 0", installer.calls)
|
||||
}
|
||||
for _, path := range []string{data, license} {
|
||||
contents, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(contents) != path {
|
||||
t.Fatalf("protected file %q = %q, err=%v", path, contents, readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTargetStateFailureDoesNotRequestCloseOrInstall(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
expected := errors.New("Toolhelp unavailable")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, expected
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, expected)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeTargetStateUnavailable {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeTargetStateUnavailable)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePreservesInstallErrorForRestartRace(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
installErr := &install.InstallError{Stage: install.InstallStagePreflight, Code: install.FailureCodeAppRunning, Err: install.ErrTargetRunning}
|
||||
installer := &recordingInstaller{err: installErr}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), &recordingConfirmation{}, &recordingExitWaiter{}, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, install.ErrTargetRunning) {
|
||||
t.Fatalf("Update() error = %v, want preserved %v", err, install.ErrTargetRunning)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeInstallFailed {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeInstallFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresAllDependenciesAndBoundedTimeout(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
config := ServiceConfig{
|
||||
Records: store,
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Confirmation: &recordingConfirmation{},
|
||||
ExitWaiter: &recordingExitWaiter{},
|
||||
Installer: &recordingInstaller{},
|
||||
CloseTimeout: 30 * time.Second,
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ServiceConfig)
|
||||
}{
|
||||
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
||||
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
||||
{"confirmation", func(config *ServiceConfig) { config.Confirmation = nil }},
|
||||
{"exit waiter", func(config *ServiceConfig) { config.ExitWaiter = nil }},
|
||||
{"installer", func(config *ServiceConfig) { config.Installer = nil }},
|
||||
{"timeout too short", func(config *ServiceConfig) { config.CloseTimeout = time.Millisecond }},
|
||||
{"timeout too long", func(config *ServiceConfig) { config.CloseTimeout = 11 * time.Minute }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.mutate(&candidate)
|
||||
if _, err := NewService(candidate); !errors.Is(err, ErrUpdateConfig) {
|
||||
t.Fatalf("NewService() error = %v, want %v", err, ErrUpdateConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedInstalledApp(t *testing.T, version string) (*storage.InstalledAppStore, string) {
|
||||
t.Helper()
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
record := storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
Files: []storage.InstalledFile{{
|
||||
Path: "bin/App.exe",
|
||||
Size: 1,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}},
|
||||
}
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, record.ID)
|
||||
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(entrypoint, []byte("old executable"), 0o700); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
return store, appRoot
|
||||
}
|
||||
|
||||
func updateRequest(version string) Request {
|
||||
pkg := catalog.Package{URL: "https://download.invalid/test-app.zip", Size: 1, SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Signature: "placeholder"}
|
||||
return Request{Install: install.InstallRequest{
|
||||
Entry: catalog.Entry{
|
||||
Installable: true,
|
||||
Package: &pkg,
|
||||
App: catalog.App{
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Packages: map[catalog.Architecture]catalog.Package{
|
||||
catalog.ArchitectureAMD64: pkg,
|
||||
},
|
||||
},
|
||||
},
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: "untrusted-candidate.download",
|
||||
}}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, records InstalledAppResolver, target TargetStateChecker, confirmation CloseConfirmer, waiter ExitWaiter, installer InstallRunner) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(ServiceConfig{
|
||||
Records: records,
|
||||
TargetState: target,
|
||||
Confirmation: confirmation,
|
||||
ExitWaiter: waiter,
|
||||
Installer: installer,
|
||||
CloseTimeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func updateErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var updateErr *Error
|
||||
if !errors.As(err, &updateErr) {
|
||||
t.Fatalf("error = %v, want update Error", err)
|
||||
}
|
||||
return updateErr.Code
|
||||
}
|
||||
|
||||
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
||||
|
||||
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
||||
return resolver(appID)
|
||||
}
|
||||
|
||||
type targetStateFunc func(string, string) (bool, error)
|
||||
|
||||
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
||||
return checker(appID, entrypoint)
|
||||
}
|
||||
|
||||
type recordingConfirmation struct {
|
||||
confirmed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (confirmation *recordingConfirmation) ConfirmClose(context.Context, string) (bool, error) {
|
||||
confirmation.calls++
|
||||
return confirmation.confirmed, confirmation.err
|
||||
}
|
||||
|
||||
type recordingExitWaiter struct {
|
||||
err error
|
||||
calls int
|
||||
appID string
|
||||
entrypoint string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (waiter *recordingExitWaiter) WaitForExit(_ context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
waiter.calls++
|
||||
waiter.appID = appID
|
||||
waiter.entrypoint = entrypoint
|
||||
waiter.timeout = timeout
|
||||
return waiter.err
|
||||
}
|
||||
|
||||
type recordingInstaller struct {
|
||||
result install.InstallResult
|
||||
err error
|
||||
calls int
|
||||
request install.InstallRequest
|
||||
}
|
||||
|
||||
func (installer *recordingInstaller) Install(request install.InstallRequest) (install.InstallResult, error) {
|
||||
installer.calls++
|
||||
installer.request = request
|
||||
return installer.result, installer.err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user