55 Commits
Author SHA1 Message Date
ilaandClaude Fable 5 a2cda73deb Add Phase 4 launch/update/self-update review with cross-check ruling
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Code-level audit of T-401~T-403: launch has no command-injection surface
(AppID-only request, entrypoint must be in recorded installed files,
safepath+JoinUnder+Lstat, no-arg exec.Command and parameterless
ShellExecuteExW), update never force-kills (confirm + natural-exit wait
only), and self-update is journaled, rollback-capable and health-gated
before backup deletion. Confirms Phase 3 O1 is closed by T-401's
preSwitchCheck.

Ruling accepts Codex's follow-up: O4 (self-flagged T-403 fault-injection
and mid-phase Recover test gaps - all four claims verified) and O5
(split assembly prerequisites instead of lumping them under T-502).
Adds a low-cost path to close O4 mostly via the existing DirectorySyncer
seam and filesystem permission tricks, without new production seams.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:44:01 +08:00
ila 2c561894fd Implement catalog startup diagnostics (T-616) 2026-07-19 22:22:46 +08:00
ila 06234e11bf Define catalog startup task (T-616) 2026-07-19 22:09:16 +08:00
ila 13004218cf Implement self-update recovery (T-403) 2026-07-19 22:00:38 +08:00
ila 2900deba4f Define self update task (T-403) 2026-07-19 21:42:23 +08:00
ila 339beaa9b3 Implement app update orchestration (T-402) 2026-07-19 21:39:33 +08:00
ila fda52a57ca Define app update orchestration task (T-402) 2026-07-19 21:27:26 +08:00
ila 87083c387f Implement controlled app launch (T-401) 2026-07-19 21:10:59 +08:00
ila d0cf333394 Define process and launch task (T-401) 2026-07-19 20:47:45 +08:00
ila df6c243b21 Preserve staging I/O failure causes (T-615) 2026-07-19 20:28:56 +08:00
ila d1603c52b6 Define I/O diagnostic remediation task (T-615) 2026-07-19 20:13:03 +08:00
ilaandClaude Fable 5 09f56478d3 Add Phase 3 review cross-check ruling
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Adjudicate Codex's review correction: concede three overstatements in
the original review (M3 loop not actually complete - InstallService has
no production assembly and T-401 launch is unbuilt; scope 'no security
defect' to T-302/T-303; O2 mischaracterized - ENOSPC during io.Copy is
misclassified as zip_corrupt with a broken error chain, escalate to P1;
O1 fix was flawed - recheck IsRunning explicitly instead of inferring
app_running from rename failure). Add finding: disk-full surfaces at
write/sync/close with three different codes, so the O2 fix must span the
whole write->sync->close sequence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:06:55 +08:00
ilaandClaude Fable 5 1ad00f8ef2 Add Phase 3 install integration review (T-301~T-303)
Code-level audit of the verified install chain: hash-before-parse and
single-file-handle TOCTOU defenses in verified_package.go, strict
app.json parse cross-checked against the signed Catalog, untrusted
download verified against Catalog Size/SHA256, mandatory non-bypassable
pre-extract disk/running checks, and a complete stable failure-code
enum. No security defect found; records five minor optimizations, the
top being the IsRunning TOCTOU (recheck before the current->backup
rename).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:44:57 +08:00
ila ae3f64c407 Add install preflight safeguards (T-303) 2026-07-18 18:14:25 +08:00
ila 449b183ca3 Define failure handling task (T-303) 2026-07-18 18:04:01 +08:00
ila 14589abb31 Integrate verified installation flow (T-302) 2026-07-18 17:55:01 +08:00
ila 6575c9ad9b Define installation integration task (T-302) 2026-07-18 17:35:11 +08:00
ilaandClaude Fable 5 a72e7b04dc Add T-606~T-614 remediation review; ignore editor workspace
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Verify at code level that the Phase 1/2 review findings were actually
closed (not just self-reported): icon cache concurrency + LRU + bounded
fetch (T-606), UI-thread icon delivery (T-607), install durability with
Windows FlushFileBuffers / POSIX dir sync (T-613), ZIP central-directory
preflight (T-612), and catalog signature cross-impl vectors (T-614).
All confirmed real. Records three residuals (R1 real power-loss
validation, R2 per-file fsync cost, R3 singleflight ctx caveat).

Also gitignore *.code-workspace (per review recommendation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:05:00 +08:00
ila 0c1b7662c6 Freeze catalog signature vectors (T-614) 2026-07-18 16:46:17 +08:00
ila 9e5f3f4840 Define catalog signature vector task (T-614) 2026-07-18 16:36:31 +08:00
ila 20596a7de4 Fence installation transaction durability (T-613) 2026-07-18 16:28:49 +08:00
ila 84befee70f Define installation durability task (T-613) 2026-07-18 16:14:12 +08:00
ila 0f69fa330e Preflight ZIP central directory metadata (T-612) 2026-07-18 15:40:44 +08:00
ila 65ff7a3f23 Define ZIP central directory preflight task (T-612) 2026-07-18 15:29:39 +08:00
ila 320b83d929 Implement unsafe icon cache diagnostics (T-611) 2026-07-18 15:18:29 +08:00
ila 6455fec811 Define unsafe icon cache diagnostics task (T-611) 2026-07-18 14:31:53 +08:00
ila e9386d26e7 Split Gio shell responsibilities (T-610) 2026-07-18 14:23:02 +08:00
ila 75b1803564 Define Gio shell responsibility split task (T-610) 2026-07-17 18:21:05 +08:00
ila 171572973b Stabilize visible item snapshots (T-609) 2026-07-17 17:31:46 +08:00
ila ed9ded2110 Define visible snapshot lifecycle task (T-609) 2026-07-17 10:49:58 +08:00
ila 1b7f72e658 Add adapter interaction contracts (T-608)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
2026-07-17 10:41:40 +08:00
ila 0705948d74 Define adapter interaction contract task (T-608) 2026-07-17 10:34:14 +08:00
ila 0945fe93dc Route icon results through UI events (T-607) 2026-07-17 10:21:44 +08:00
ila fba672381e Define icon UI event delivery task (T-607) 2026-07-17 09:54:47 +08:00
ila f1cc7308db Harden icon cache concurrency (T-606) 2026-07-17 09:38:25 +08:00
ila 8873a5261d Define icon cache hardening task (T-606) 2026-07-17 09:10:20 +08:00
ila 6fd19d0f43 Harden Windows package paths (T-605) 2026-07-16 23:56:19 +08:00
ila f7a803d944 Define Windows path hardening task (T-605) 2026-07-16 23:36:39 +08:00
ila 7efcab5dfe Isolate modern and Win7 workspaces (T-604) 2026-07-16 21:27:52 +08:00
ila e4a9295cbf Define workspace isolation task (T-604) 2026-07-16 21:15:40 +08:00
ilaandClaude Fable 5 a036ce9a8e Add Phase 0 skeleton review with cross-check ruling
Record the full-stack review of the Phase 0 skeleton (T-001~T-004),
Codex's second-pass rebuttal, and the cross-checked final ruling:
confirmed facts (with evidence), accept/correct notes, and a
finalized action order. P1 (root go.work Win7 version bleed) is the
only structural fix flagged for near-term handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:09:25 +08:00
ila 90dc09d13d Use single-agent workflow 2026-07-16 20:42:32 +08:00
ila 8dad40f934 Implement resumable download queue (T-301) 2026-07-16 19:49:06 +08:00
ila 2d302b731a Define multi-agent download workflow (T-301) 2026-07-16 18:48:15 +08:00
ila a52acbf926 Add app details and icon cache (T-204)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
2026-07-16 17:22:37 +08:00
ila db8d93e843 Build virtualized software list (T-203) 2026-07-16 17:10:42 +08:00
ila 819b1cdf88 Recognize local app states (T-202) 2026-07-16 17:00:06 +08:00
ila 2e21c9f327 Integrate validated catalog loading (T-201) 2026-07-16 16:52:46 +08:00
ila 9da72caa01 Prototype atomic install recovery (T-103) 2026-07-16 16:32:58 +08:00
ila 0ffeff63e1 Prototype secure ZIP extraction (T-102) 2026-07-16 16:22:45 +08:00
ila 0d6ed05d7e Prototype signed catalog fallback (T-101) 2026-07-16 16:13:18 +08:00
ila 45c242ecec Add Phase 0 build verification gate (T-004) 2026-07-16 15:51:34 +08:00
ila 6f920eb457 Add Gio app shells and platform stubs (T-003) 2026-07-16 15:43:03 +08:00
ila 0e20dd76b2 Add domain states and event runtime (T-002) 2026-07-16 15:31:41 +08:00
ila cf9fc01c68 Initialize Phase 0 monorepo skeleton (T-001) 2026-07-16 15:28:13 +08:00
237 changed files with 32687 additions and 138 deletions
+23
View File
@@ -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
+3
View File
@@ -30,3 +30,6 @@ gitea.env.*
# Python 本地校验缓存 # Python 本地校验缓存
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# 本地编辑器 workspace 配置(个人,不入库)
*.code-workspace
+9
View File
@@ -45,6 +45,15 @@
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。 - 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。 - 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
## Agent 执行模式
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
## 验证 ## 验证
```bash ```bash
+1
View File
@@ -44,6 +44,7 @@
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 | | [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 | | [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 | | [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 | | [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 | | [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) | | [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
@@ -0,0 +1,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)
}
}
+111
View File
@@ -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
}
+26
View File
@@ -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
}
+63
View File
@@ -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")
}
}
+9
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides modern Windows platform adapters and non-Windows
// stubs for package-level tests.
package windows
+38
View File
@@ -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
}
+56
View File
@@ -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
}
+49
View File
@@ -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) }
+64
View File
@@ -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
}
}
}
+270
View File
@@ -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
}
+46
View File
@@ -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 "目录状态:未知"
}
}
+72
View File
@@ -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)
}
}
+5
View File
@@ -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
+114
View File
@@ -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
}
+422
View File
@@ -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")
}
}
+192
View File
@@ -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("")
}
}
+340
View File
@@ -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,
)
})
}),
)
})
}
+246
View File
@@ -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
}
}
+152
View File
@@ -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)
}),
)
}
+154
View File
@@ -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
}
}
+133
View File
@@ -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)
}
}
+111
View File
@@ -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
}
+26
View File
@@ -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
}
+63
View File
@@ -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
}
+14
View File
@@ -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")
}
}
+9
View File
@@ -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
)
+8
View File
@@ -0,0 +1,8 @@
go 1.20
use (
.
../core
)
replace softbox.local/core v0.0.0 => ../core
+11
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides Win7-compatible platform adapters and non-Windows
// stubs for package-level tests.
package windows
+38
View File
@@ -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)
}
}
+167
View File
@@ -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
}
+56
View File
@@ -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
}
+49
View File
@@ -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) }
+64
View File
@@ -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
}
}
}
+270
View File
@@ -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
}
+46
View File
@@ -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 "目录状态:未知"
}
}
+72
View File
@@ -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)
}
}
+5
View File
@@ -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
+114
View File
@@ -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
}
+422
View File
@@ -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")
}
}
+196
View File
@@ -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("")
}
}
+313
View File
@@ -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,
)
})
}),
)
})
}
+217
View File
@@ -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
}
}
+124
View File
@@ -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)
}
+154
View File
@@ -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
}
}
+131
View File
@@ -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),
}
}
+183
View File
@@ -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
}
+112
View File
@@ -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 }
+251
View File
@@ -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
}
+260
View File
@@ -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] == &current[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])
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package application coordinates SoftBox use cases through injected ports.
package application
+45
View File
@@ -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
}
+57
View File
@@ -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
}
+138
View File
@@ -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()
}
}
}
+208
View File
@@ -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")
}
}
+33
View File
@@ -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")
}
}
+270
View File
@@ -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
}
}
+126
View File
@@ -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)
}
}
}
+414
View File
@@ -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
}
+902
View File
@@ -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)
}
+319
View File
@@ -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
}
}
+288
View File
@@ -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
}
+72
View File
@@ -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)
})
}
+66
View File
@@ -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)
}
}
+322
View File
@@ -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
}
}
+435
View File
@@ -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
}
+241
View File
@@ -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
}
+171
View File
@@ -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
}
+48
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+105
View File
@@ -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"
}
+49
View File
@@ -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")
}
}
+58
View File
@@ -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]
}
+80
View File
@@ -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)
}
}
+105
View File
@@ -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
}
+82
View File
@@ -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)
}
})
}
+432
View File
@@ -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
}
+411
View File
@@ -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")
}
+340
View File
@@ -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()
}
+179
View File
@@ -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