Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d43cdf16 | ||
|
|
63dc40275f | ||
|
|
1bc4504913 | ||
|
|
5db6031e4b | ||
|
|
76f6108496 | ||
|
|
b4453130de | ||
|
|
cd76f7f900 | ||
|
|
c51ff30c54 | ||
|
|
8d81276fd9 | ||
|
|
94ea334d1c | ||
|
|
24e9862192 | ||
|
|
ea1d6c8ee4 | ||
|
|
a2cda73deb | ||
|
|
2c561894fd | ||
|
|
06234e11bf | ||
|
|
13004218cf | ||
|
|
2900deba4f | ||
|
|
339beaa9b3 | ||
|
|
fda52a57ca | ||
|
|
87083c387f | ||
|
|
d0cf333394 | ||
|
|
df6c243b21 | ||
|
|
d1603c52b6 | ||
|
|
09f56478d3 | ||
|
|
1ad00f8ef2 | ||
|
|
ae3f64c407 | ||
|
|
449b183ca3 | ||
|
|
14589abb31 | ||
|
|
6575c9ad9b | ||
|
|
a72e7b04dc | ||
|
|
0c1b7662c6 | ||
|
|
9e5f3f4840 | ||
|
|
20596a7de4 | ||
|
|
84befee70f | ||
|
|
0f69fa330e | ||
|
|
65ff7a3f23 | ||
|
|
320b83d929 | ||
|
|
6455fec811 | ||
|
|
e9386d26e7 | ||
|
|
75b1803564 | ||
|
|
171572973b | ||
|
|
ed9ded2110 | ||
|
|
1b7f72e658 | ||
|
|
0705948d74 | ||
|
|
0945fe93dc | ||
|
|
fba672381e | ||
|
|
f1cc7308db | ||
|
|
8873a5261d | ||
|
|
6fd19d0f43 | ||
|
|
f7a803d944 | ||
|
|
7efcab5dfe | ||
|
|
e4a9295cbf | ||
|
|
a036ce9a8e | ||
|
|
90dc09d13d | ||
|
|
8dad40f934 | ||
|
|
2d302b731a | ||
|
|
a52acbf926 | ||
|
|
db8d93e843 | ||
|
|
819b1cdf88 | ||
|
|
2e21c9f327 | ||
|
|
9da72caa01 | ||
|
|
0ffeff63e1 | ||
|
|
0d6ed05d7e | ||
|
|
45c242ecec | ||
|
|
6f920eb457 | ||
|
|
0e20dd76b2 | ||
|
|
cf9fc01c68 |
@@ -0,0 +1,23 @@
|
|||||||
|
name: Phase 0 build gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Go 1.25
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25.0"
|
||||||
|
cache: false
|
||||||
|
- name: Run Phase 0 verification
|
||||||
|
run: bash scripts/verify_phase0.sh
|
||||||
@@ -30,3 +30,10 @@ gitea.env.*
|
|||||||
# Python 本地校验缓存
|
# Python 本地校验缓存
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
|
# 本地编辑器 workspace 配置(个人,不入库)
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# 本地开发运行脚本(个人便利,不入库)
|
||||||
|
run-modern.bat
|
||||||
|
run-win7.bat
|
||||||
|
|||||||
@@ -42,9 +42,20 @@
|
|||||||
- 一次只领取一个任务(`docs/tasks/` 中 `status: TODO` 且依赖全 `DONE`、编号最靠前的),按 `docs/tasks/README.md` 约定流转状态。
|
- 一次只领取一个任务(`docs/tasks/` 中 `status: TODO` 且依赖全 `DONE`、编号最靠前的),按 `docs/tasks/README.md` 约定流转状态。
|
||||||
- 执行记录写进该任务文件的 `## 执行记录`;项目现实变化(启动/验证路径、目录、blocker)覆盖更新 `docs/current-state.md`。
|
- 执行记录写进该任务文件的 `## 执行记录`;项目现实变化(启动/验证路径、目录、blocker)覆盖更新 `docs/current-state.md`。
|
||||||
- 需求变化先改文档再改代码;不在代码里发明文档没有的接口、字段和状态。
|
- 需求变化先改文档再改代码;不在代码里发明文档没有的接口、字段和状态。
|
||||||
|
- **发现需求冲突必须暂停**:实现中若发现需求之间、或需求与已冻结协议/架构之间无法同时满足(例如需求要求某能力,而协议结构上无法表达),**立即停止该方向的实现**,把冲突写进当前任务文件的 `## 执行记录`(冲突双方的文档出处 + 技术原因),并向用户**请求裁定**。不得自行取舍、不得静默缩范围、不得为绕开冲突而修改已冻结协议。
|
||||||
|
- **裁定后文档先行**:拿到裁定后,先把结论同步到**所有**受影响文档(需求、验收标准、架构、协议、路线图、任务边界),再继续实现或拆出新任务。只更新架构/任务文档而不回头修订 `docs/02-requirements.md` 会造成验收标准失效——这是已发生过的漂移(见 `docs/review/phase5-review.md` 的试用冲突)。
|
||||||
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
||||||
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
||||||
|
|
||||||
|
## Agent 执行模式
|
||||||
|
|
||||||
|
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
|
||||||
|
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
|
||||||
|
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
|
||||||
|
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
|
||||||
|
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
|
||||||
|
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
||||||
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
||||||
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
||||||
|
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
|
||||||
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
||||||
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
|
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
|
||||||
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
|
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||||
|
runtime := application.NewRuntime(1)
|
||||||
|
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||||
|
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||||
|
}))
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("bootstrap error = %v", err)
|
||||||
|
}
|
||||||
|
event := <-runtime.Events()
|
||||||
|
if event.Type != application.EventCatalogRefreshed {
|
||||||
|
t.Fatalf("event type = %q", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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 runWithLoaders(
|
||||||
|
application.UnconfiguredCatalogLoader{},
|
||||||
|
application.UnconfiguredAuthorizationLoader{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||||
|
return runWithLoaders(loader, application.UnconfiguredAuthorizationLoader{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithLoaders(
|
||||||
|
loader application.CatalogSnapshotLoader,
|
||||||
|
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||||
|
) error {
|
||||||
|
return runWithCompositions(loader, authorizationLoader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithCompositions(
|
||||||
|
loader application.CatalogSnapshotLoader,
|
||||||
|
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||||
|
licenseImport *application.LicenseImport,
|
||||||
|
) 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)
|
||||||
|
authorizationDone := startAuthorizationBootstrap(eventContext, runtime, authorizationLoader)
|
||||||
|
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 authorizationErr := <-authorizationDone; authorizationErr != nil &&
|
||||||
|
!errors.Is(authorizationErr, context.Canceled) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrAuthorizationSourceUnconfigured) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrRuntimeClosed) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrEventRelayClosed) {
|
||||||
|
log.Printf("%s authorization 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
|
||||||
|
importing := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
gtx := app.NewContext(&operations, event)
|
||||||
|
shell.Layout(gtx, theme)
|
||||||
|
if shell.TakeLicenseImportRequest() && licenseImport != nil {
|
||||||
|
select {
|
||||||
|
case importing <- struct{}{}:
|
||||||
|
go func() {
|
||||||
|
defer func() { <-importing }()
|
||||||
|
if importErr := licenseImport.Run(eventContext); importErr != nil &&
|
||||||
|
!errors.Is(importErr, context.Canceled) &&
|
||||||
|
!errors.Is(importErr, application.ErrRuntimeClosed) &&
|
||||||
|
!errors.Is(importErr, application.ErrEventRelayClosed) {
|
||||||
|
log.Printf("%s license import failed", core.ProductName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.Frame(gtx.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAuthorizationBootstrap(
|
||||||
|
ctx context.Context,
|
||||||
|
runtime *application.Runtime,
|
||||||
|
loader application.AuthorizationSnapshotLoader,
|
||||||
|
) <-chan error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- application.NewAuthorizationBootstrap(loader, runtime).Run(ctx)
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"softbox.local/app-modern/platform/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
|
||||||
|
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if len(arguments) != 2 {
|
||||||
|
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
|
||||||
|
}
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("locate current executable: %w", err)
|
||||||
|
}
|
||||||
|
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
|
||||||
|
return true, fmt.Errorf("acknowledge self-update health: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAcknowledgeInternalUpdateHealthRejectsMalformedArguments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
handled bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "ordinary launch", args: nil},
|
||||||
|
{name: "other internal-looking flag", args: []string{"--softbox-other"}},
|
||||||
|
{name: "missing request ID", args: []string{updater.InternalHealthFlag}, handled: true, wantErr: true},
|
||||||
|
{name: "extra argument", args: []string{updater.InternalHealthFlag, "update-1234", "extra"}, handled: true, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
handled, err := acknowledgeInternalUpdateHealth(test.args)
|
||||||
|
if handled != test.handled || (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("acknowledgeInternalUpdateHealth(%q) = handled=%t, err=%v; want handled=%t, error=%t", test.args, handled, err, test.handled, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/app-modern/platform/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(arguments []string) error {
|
||||||
|
for _, option := range []string{"--pid", "--staging", "--target"} {
|
||||||
|
if err := requireOneOption(arguments, option); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
pid := flags.Int("pid", 0, "main SoftBox PID")
|
||||||
|
staging := flags.String("staging", "", "prepared staging directory")
|
||||||
|
target := flags.String("target", "", "fixed app target directory")
|
||||||
|
if err := flags.Parse(arguments); err != nil {
|
||||||
|
return fmt.Errorf("parse updater arguments: %w", err)
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
|
||||||
|
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
|
||||||
|
}
|
||||||
|
requestID := filepath.Base(filepath.Clean(*staging))
|
||||||
|
platform := windows.New()
|
||||||
|
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
|
||||||
|
ParentExit: 2 * time.Minute,
|
||||||
|
Health: 45 * time.Second,
|
||||||
|
})
|
||||||
|
return service.Update(context.Background(), updater.Request{
|
||||||
|
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireOneOption(arguments []string, option string) error {
|
||||||
|
count := 0
|
||||||
|
for _, argument := range arguments {
|
||||||
|
if argument == option || strings.HasPrefix(argument, option+"=") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return fmt.Errorf("%s must appear exactly once", option)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
|
||||||
|
if err := run(nil); err == nil {
|
||||||
|
t.Fatal("run(nil) succeeded")
|
||||||
|
}
|
||||||
|
if err := run([]string{
|
||||||
|
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("run() accepted duplicate --pid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module softbox.local/app-modern
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.10.1
|
||||||
|
golang.org/x/sys v0.39.0
|
||||||
|
softbox.local/core v0.0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
|
||||||
|
switch minOS {
|
||||||
|
case "windows-7-sp1":
|
||||||
|
if major > 6 || (major == 6 && minor > 1) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return major == 6 && minor == 1 && servicePack >= 1, nil
|
||||||
|
case "windows-10":
|
||||||
|
return major >= 10, nil
|
||||||
|
case "windows-11":
|
||||||
|
return major >= 10 && build >= 22000, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package windows provides modern Windows platform adapters and non-Windows
|
||||||
|
// stubs for package-level tests.
|
||||||
|
package windows
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMachineFingerprintUnavailable reports that a required identifier could
|
||||||
|
// not be safely read or normalized. It intentionally contains no source value.
|
||||||
|
var ErrMachineFingerprintUnavailable = errors.New("machine fingerprint is unavailable")
|
||||||
|
|
||||||
|
type machineGUIDReader func() (string, error)
|
||||||
|
type systemVolumeSerialReader func() (uint32, error)
|
||||||
|
|
||||||
|
func machineHashFrom(readMachineGUID machineGUIDReader, readSystemVolumeSerial systemVolumeSerialReader) (string, error) {
|
||||||
|
if readMachineGUID == nil || readSystemVolumeSerial == nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
machineGUID, err := readMachineGUID()
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
systemVolumeSerial, err := readSystemVolumeSerial()
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
machineHash, err := licensing.DeriveMachineHash(machineGUID, systemVolumeSerial)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
return machineHash, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
// MachineHash is unavailable outside a Windows target.
|
||||||
|
func MachineHash() (string, error) {
|
||||||
|
return "", ErrUnsupported
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMachineHashFromUsesBothRequiredSources(t *testing.T) {
|
||||||
|
const machineGUID = "6F9619FF-8B86-D011-B42D-00C04FC964FF"
|
||||||
|
got, err := machineHashFrom(
|
||||||
|
func() (string, error) { return machineGUID, nil },
|
||||||
|
func() (uint32, error) { return 0x1a2b3c4d, nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("machineHashFrom() error = %v", err)
|
||||||
|
}
|
||||||
|
want, err := licensing.DeriveMachineHash(machineGUID, 0x1a2b3c4d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveMachineHash() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("machineHashFrom() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachineHashFromFailsClosedWithoutLeakingSourceValues(t *testing.T) {
|
||||||
|
const secret = "synthetic-machine-guid-secret"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
readGUID machineGUIDReader
|
||||||
|
readSerial systemVolumeSerialReader
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GUID reader failure",
|
||||||
|
readGUID: func() (string, error) { return "", errors.New(secret) },
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume reader failure",
|
||||||
|
readGUID: func() (string, error) { return "6f9619ff-8b86-d011-b42d-00c04fc964ff", nil },
|
||||||
|
readSerial: func() (uint32, error) { return 0, errors.New(secret) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid GUID",
|
||||||
|
readGUID: func() (string, error) { return secret, nil },
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing GUID reader",
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := machineHashFrom(test.readGUID, test.readSerial)
|
||||||
|
if !errors.Is(err, ErrMachineFingerprintUnavailable) {
|
||||||
|
t.Fatalf("machineHashFrom() error = %v, want ErrMachineFingerprintUnavailable", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("machineHashFrom() leaked a source value in %q", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineGUIDRegistryPath = `SOFTWARE\Microsoft\Cryptography`
|
||||||
|
|
||||||
|
// MachineHash reads the two required Windows identifiers only for the duration
|
||||||
|
// of this call and returns their derived licensing hash.
|
||||||
|
func MachineHash() (string, error) {
|
||||||
|
return machineHashFrom(readMachineGUID, readSystemVolumeSerial)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMachineGUID() (string, error) {
|
||||||
|
key, err := registry.OpenKey(registry.LOCAL_MACHINE, machineGUIDRegistryPath, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
value, _, err := key.GetStringValue("MachineGuid")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemVolumeSerial() (uint32, error) {
|
||||||
|
windowsDirectory, err := windows.GetWindowsDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
volumeName := filepath.VolumeName(windowsDirectory)
|
||||||
|
if len(volumeName) != 2 || volumeName[1] != ':' || !isASCIIAlpha(volumeName[0]) {
|
||||||
|
return 0, errors.New("windows directory is not on a drive volume")
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := windows.UTF16PtrFromString(volumeName + `\`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var serial uint32
|
||||||
|
if err := windows.GetVolumeInformation(root, nil, 0, &serial, nil, nil, nil, 0); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return serial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIAlpha(character byte) bool {
|
||||||
|
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z'
|
||||||
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlatformStubFailsClosed(t *testing.T) {
|
||||||
|
if _, err := MachineHash(); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("MachineHash() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
platform := New()
|
||||||
|
if _, err := platform.IsRunning("test-app", "C:/test/App.exe"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("IsRunning() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlatformStubContract(t *testing.T) {
|
||||||
|
platform := New()
|
||||||
|
if platform.OS() == "" {
|
||||||
|
t.Fatal("OS() should not be empty")
|
||||||
|
}
|
||||||
|
if platform.Edition() != EditionModern {
|
||||||
|
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionModern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "one", "App.exe")
|
||||||
|
collision := filepath.Join(root, "two", "App.exe")
|
||||||
|
|
||||||
|
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v", err)
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
|
||||||
|
})
|
||||||
|
if err != nil || !running {
|
||||||
|
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
|
||||||
|
expected := errors.New("snapshot failed")
|
||||||
|
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||||
|
return nil, expected
|
||||||
|
})
|
||||||
|
if !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
|
||||||
|
})
|
||||||
|
if !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionSupports(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
minOS string
|
||||||
|
major, minor, build, service uint32
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
|
||||||
|
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
|
||||||
|
{minOS: "windows-10", major: 10, want: true},
|
||||||
|
{minOS: "windows-11", major: 10, build: 19045, want: false},
|
||||||
|
{minOS: "windows-11", major: 10, build: 22000, want: true},
|
||||||
|
{minOS: "unknown", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
|
||||||
|
if (err != nil) != test.wantErr || got != test.want {
|
||||||
|
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForExit(t *testing.T) {
|
||||||
|
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||||
|
errSnapshot := errors.New("snapshot failed")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
running []bool
|
||||||
|
runErr error
|
||||||
|
timeout time.Duration
|
||||||
|
wantErr error
|
||||||
|
wantSleeps int
|
||||||
|
}{
|
||||||
|
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||||
|
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||||
|
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||||
|
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||||
|
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
clock := &fakeExitWaitClock{now: initial}
|
||||||
|
index := 0
|
||||||
|
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||||
|
if test.runErr != nil {
|
||||||
|
return false, test.runErr
|
||||||
|
}
|
||||||
|
if index >= len(test.running) {
|
||||||
|
return test.running[len(test.running)-1], nil
|
||||||
|
}
|
||||||
|
running := test.running[index]
|
||||||
|
index++
|
||||||
|
return running, nil
|
||||||
|
}, clock)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if clock.sleeps != test.wantSleeps {
|
||||||
|
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canceledWaitContext() context.Context {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExitWaitClock struct {
|
||||||
|
now time.Time
|
||||||
|
sleeps int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||||
|
|
||||||
|
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clock.sleeps++
|
||||||
|
clock.now = clock.now.Add(duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotItem struct {
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedSnapshot struct {
|
||||||
|
items []snapshotItem
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
|
||||||
|
if snapshot.next == len(snapshot.items) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
item := snapshot.items[snapshot.next]
|
||||||
|
snapshot.next++
|
||||||
|
return item.path, true, item.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*scriptedSnapshot) Close() error { return nil }
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
|
const seeMaskNoCloseProcess = 0x00000040
|
||||||
|
|
||||||
|
var (
|
||||||
|
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
|
||||||
|
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
|
||||||
|
)
|
||||||
|
|
||||||
|
type platform struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platform{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) OS() string {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Edition() Edition {
|
||||||
|
return EditionModern
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) IsCompatible(minOS string) (bool, error) {
|
||||||
|
version, err := currentVersion()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||||
|
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
|
||||||
|
return newToolhelpSnapshot(filepath.Base(entrypoint))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||||
|
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||||
|
return target.IsRunning(appID, entrypoint)
|
||||||
|
}, systemExitWaitClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Start(command launch.Command) (int, error) {
|
||||||
|
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||||
|
return 0, fmt.Errorf("launch command must contain absolute paths")
|
||||||
|
}
|
||||||
|
if command.RequiresAdmin {
|
||||||
|
return startElevated(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(command.Entrypoint)
|
||||||
|
cmd.Dir = command.WorkingDirectory
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return cmd.Process.Pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolhelpSnapshot struct {
|
||||||
|
handle windows.Handle
|
||||||
|
targetName string
|
||||||
|
entry windows.ProcessEntry32
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
|
||||||
|
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
var err error
|
||||||
|
if !snapshot.started {
|
||||||
|
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
|
||||||
|
err = windows.Process32First(snapshot.handle, &snapshot.entry)
|
||||||
|
snapshot.started = true
|
||||||
|
} else {
|
||||||
|
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
// ExeFile only narrows the expensive query. The identity decision below
|
||||||
|
// always uses QueryFullProcessImageName's normalized full path.
|
||||||
|
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *toolhelpSnapshot) Close() error {
|
||||||
|
return windows.CloseHandle(snapshot.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullProcessImagePath(pid uint32) (string, error) {
|
||||||
|
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(process)
|
||||||
|
|
||||||
|
for size := uint32(260); size <= 32768; size *= 2 {
|
||||||
|
buffer := make([]uint16, size)
|
||||||
|
length := size
|
||||||
|
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
|
||||||
|
if err == nil {
|
||||||
|
return windows.UTF16ToString(buffer[:length]), nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rtlOSVersionInfoEx struct {
|
||||||
|
size uint32
|
||||||
|
major uint32
|
||||||
|
minor uint32
|
||||||
|
build uint32
|
||||||
|
platformID uint32
|
||||||
|
csdVersion [128]uint16
|
||||||
|
servicePack uint16
|
||||||
|
servicePackMinor uint16
|
||||||
|
suiteMask uint16
|
||||||
|
productType byte
|
||||||
|
reserved byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentVersion() (rtlOSVersionInfoEx, error) {
|
||||||
|
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
|
||||||
|
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
|
||||||
|
if status != 0 {
|
||||||
|
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type shellExecuteInfo struct {
|
||||||
|
size uint32
|
||||||
|
mask uint32
|
||||||
|
hwnd uintptr
|
||||||
|
verb *uint16
|
||||||
|
file *uint16
|
||||||
|
parameters *uint16
|
||||||
|
directory *uint16
|
||||||
|
show int32
|
||||||
|
instance uintptr
|
||||||
|
idList uintptr
|
||||||
|
class *uint16
|
||||||
|
keyClass uintptr
|
||||||
|
hotKey uint32
|
||||||
|
icon uintptr
|
||||||
|
process windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func startElevated(command launch.Command) (int, error) {
|
||||||
|
verb, err := windows.UTF16PtrFromString("runas")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(command.Entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
info := shellExecuteInfo{
|
||||||
|
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
|
||||||
|
mask: seeMaskNoCloseProcess,
|
||||||
|
verb: verb,
|
||||||
|
file: file,
|
||||||
|
directory: directory,
|
||||||
|
show: 1,
|
||||||
|
}
|
||||||
|
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
|
||||||
|
if result == 0 {
|
||||||
|
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
|
||||||
|
}
|
||||||
|
if info.process == 0 {
|
||||||
|
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(info.process)
|
||||||
|
pid, err := windows.GetProcessId(info.process)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(pid), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processSnapshot is deliberately small so the matching invariant can be
|
||||||
|
// tested without a Windows host. NextImagePath returns more=false only after a
|
||||||
|
// successful end-of-snapshot; any enumeration or image-path failure is an
|
||||||
|
// error, rather than evidence that the target is not running.
|
||||||
|
type processSnapshot interface {
|
||||||
|
NextImagePath() (path string, more bool, err error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type processSnapshotFactory func() (processSnapshot, error)
|
||||||
|
|
||||||
|
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
|
||||||
|
target, err := canonicalProcessPath(entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := newSnapshot()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("create process snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer snapshot.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
path, more, err := snapshot.NextImagePath()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("enumerate process image path: %w", err)
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
candidate, err := canonicalProcessPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("canonicalize process image path: %w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(target, candidate) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalProcessPath(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pidWaitHandle interface {
|
||||||
|
Wait(time.Duration) (bool, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type pidOpener func(int) (pidWaitHandle, error)
|
||||||
|
|
||||||
|
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
|
||||||
|
if pid <= 0 {
|
||||||
|
return fmt.Errorf("process PID must be positive")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
return fmt.Errorf("process wait timeout must be positive")
|
||||||
|
}
|
||||||
|
handle, err := open(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open process %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
defer handle.Close()
|
||||||
|
deadline := time.NewTimer(timeout)
|
||||||
|
defer deadline.Stop()
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exited, err := handle.Wait(250 * time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for process %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
if exited {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-deadline.C:
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakePIDHandle struct {
|
||||||
|
exited bool
|
||||||
|
waitErr error
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
|
||||||
|
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
|
||||||
|
|
||||||
|
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
|
||||||
|
handle := &fakePIDHandle{exited: true}
|
||||||
|
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
|
||||||
|
if pid != 9 {
|
||||||
|
t.Fatalf("PID = %d, want 9", pid)
|
||||||
|
}
|
||||||
|
return handle, nil
|
||||||
|
})
|
||||||
|
if err != nil || !handle.closed {
|
||||||
|
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
|
||||||
|
openErr := errors.New("access denied")
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
|
||||||
|
t.Fatalf("open error = %v", err)
|
||||||
|
}
|
||||||
|
waitErr := errors.New("wait failed")
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
|
||||||
|
t.Fatalf("wait error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel error = %v", err)
|
||||||
|
}
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("timeout error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
|
||||||
|
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
|
||||||
|
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||||
|
return 0, fmt.Errorf("self-update launch paths must be absolute")
|
||||||
|
}
|
||||||
|
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
|
||||||
|
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
|
||||||
|
command.HealthRequestID == "" {
|
||||||
|
return 0, fmt.Errorf("invalid fixed self-update launch command")
|
||||||
|
}
|
||||||
|
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
|
||||||
|
commandLine.Dir = command.WorkingDirectory
|
||||||
|
if err := commandLine.Start(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return commandLine.Process.Pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) SyncDirectory(path string) error {
|
||||||
|
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode directory path: %w", err)
|
||||||
|
}
|
||||||
|
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
||||||
|
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
|
||||||
|
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open directory handle: %w", err)
|
||||||
|
}
|
||||||
|
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||||
|
_ = syscall.CloseHandle(handle)
|
||||||
|
return fmt.Errorf("flush directory handle: %w", err)
|
||||||
|
}
|
||||||
|
if err := syscall.CloseHandle(handle); err != nil {
|
||||||
|
return fmt.Errorf("close directory handle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowsPIDHandle struct{ handle windows.Handle }
|
||||||
|
|
||||||
|
func openWindowsProcess(pid int) (pidWaitHandle, error) {
|
||||||
|
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return windowsPIDHandle{handle: handle}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
|
||||||
|
milliseconds := uint32(timeout / time.Millisecond)
|
||||||
|
if milliseconds == 0 {
|
||||||
|
milliseconds = 1
|
||||||
|
}
|
||||||
|
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case windows.WAIT_OBJECT_0:
|
||||||
|
return true, nil
|
||||||
|
case uint32(windows.WAIT_TIMEOUT):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exitPollInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
type exitWaitClock interface {
|
||||||
|
Now() time.Time
|
||||||
|
Wait(context.Context, time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type systemExitWaitClock struct{}
|
||||||
|
|
||||||
|
func (systemExitWaitClock) Now() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||||
|
timer := time.NewTimer(duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return fmt.Errorf("wait context is required")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
return fmt.Errorf("exit timeout must be positive")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := clock.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
isRunning, err := running()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isRunning {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
remaining := deadline.Sub(clock.Now())
|
||||||
|
if remaining <= 0 {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
interval := exitPollInterval
|
||||||
|
if remaining < interval {
|
||||||
|
interval = remaining
|
||||||
|
}
|
||||||
|
if err := clock.Wait(ctx, interval); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/io/input"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
adapterContractViewport = image.Pt(1080, 720)
|
||||||
|
adapterContractCompactViewport = image.Pt(1080, 420)
|
||||||
|
)
|
||||||
|
|
||||||
|
const adapterContractEdition = "Modern"
|
||||||
|
|
||||||
|
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
|
||||||
|
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
|
||||||
|
shell.search.SetText("APP-TWO")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.Query(); got != "app-two" {
|
||||||
|
t.Fatalf("query after editor update = %q, want app-two", got)
|
||||||
|
}
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
if len(visible) != 1 || visible[0].ID != "app-two" {
|
||||||
|
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.Category(); got != "图像" {
|
||||||
|
t.Fatalf("category after click = %q, want 图像", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.viewUpdates.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewUpdates {
|
||||||
|
t.Fatalf("view after updates click = %q", got)
|
||||||
|
}
|
||||||
|
shell.viewInstalled.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewInstalled {
|
||||||
|
t.Fatalf("view after installed click = %q", got)
|
||||||
|
}
|
||||||
|
shell.viewAll.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewAll {
|
||||||
|
t.Fatalf("view after all click = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("missing-app")
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "显示全部软件") {
|
||||||
|
t.Fatal("filtered empty state did not expose its recovery action")
|
||||||
|
}
|
||||||
|
shell.resetFilters.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if shell.search.Text() != "" || shell.model.Query() != "" ||
|
||||||
|
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
|
||||||
|
t.Fatalf(
|
||||||
|
"reset state = editor %q, query %q, category %q, view %q",
|
||||||
|
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
|
||||||
|
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
|
||||||
|
items := adapterContractLargeCatalog(80)
|
||||||
|
shell := NewAppShell(adapterContractEdition, items...)
|
||||||
|
targetID := "app-037"
|
||||||
|
targetControl := shell.rows[targetID]
|
||||||
|
|
||||||
|
reordered := append([]application.CatalogListItem(nil), items...)
|
||||||
|
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
reordered[left], reordered[right] = reordered[right], reordered[left]
|
||||||
|
}
|
||||||
|
shell.SetItems(reordered)
|
||||||
|
if shell.rows[targetID] != targetControl {
|
||||||
|
t.Fatal("row control was recreated after catalog reorder")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("app-")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.viewInstalled.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.appList.ScrollTo(12)
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
|
||||||
|
targetControl.open.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.SelectedID(); got != targetID {
|
||||||
|
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
|
||||||
|
}
|
||||||
|
if !shell.detailRendered {
|
||||||
|
t.Fatal("row click selected the model but did not render detail")
|
||||||
|
}
|
||||||
|
queryBefore := shell.model.Query()
|
||||||
|
categoryBefore := shell.model.Category()
|
||||||
|
viewBefore := shell.model.View()
|
||||||
|
positionBefore := shell.appList.Position
|
||||||
|
|
||||||
|
shell.closeDetail.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.SelectedID(); got != "" {
|
||||||
|
t.Fatalf("selected ID after close = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if shell.detailRendered {
|
||||||
|
t.Fatal("detail remained rendered after close click")
|
||||||
|
}
|
||||||
|
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
|
||||||
|
shell.model.View() != viewBefore {
|
||||||
|
t.Fatal("closing detail changed the active list filters")
|
||||||
|
}
|
||||||
|
positionAfter := shell.appList.Position
|
||||||
|
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
|
||||||
|
t.Fatalf(
|
||||||
|
"list position after close = first %d offset %d, want first %d offset %d",
|
||||||
|
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||||
|
items := adapterContractLargeCatalog(500)
|
||||||
|
shell := NewAppShell(adapterContractEdition, items...)
|
||||||
|
retainedRow := shell.rows["app-001"]
|
||||||
|
retainedCategory := shell.categoryControls["图像"]
|
||||||
|
|
||||||
|
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||||
|
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||||
|
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
|
||||||
|
}
|
||||||
|
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
|
||||||
|
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||||
|
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
|
||||||
|
t.Fatal("filtering recreated stable app or category controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
|
||||||
|
if shell.rows["app-001"] != retainedRow {
|
||||||
|
t.Fatal("retained app lost its row control after snapshot update")
|
||||||
|
}
|
||||||
|
if shell.categoryControls["图像"] != retainedCategory {
|
||||||
|
t.Fatal("retained category lost its control after snapshot update")
|
||||||
|
}
|
||||||
|
if _, exists := shell.rows["app-000"]; exists {
|
||||||
|
t.Fatal("removed app retained its row control")
|
||||||
|
}
|
||||||
|
if _, exists := shell.categoryControls["工具"]; exists {
|
||||||
|
t.Fatal("removed category retained its control")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||||
|
emptyShell := NewAppShell(adapterContractEdition)
|
||||||
|
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
|
||||||
|
t.Fatal("empty catalog did not render the catalog-loading state")
|
||||||
|
}
|
||||||
|
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||||
|
t.Fatal("empty catalog rendered a filter recovery action")
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
filteredShell.search.SetText("missing-app")
|
||||||
|
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
|
||||||
|
t.Fatal("filtered catalog did not render the no-matches state")
|
||||||
|
}
|
||||||
|
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
|
||||||
|
t.Fatal("filtered catalog did not render its recovery action")
|
||||||
|
}
|
||||||
|
filteredShell.resetFilters.Click()
|
||||||
|
adapterContractLayout(filteredShell, adapterContractViewport)
|
||||||
|
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
|
||||||
|
t.Fatal("filter recovery did not clear editor and model query")
|
||||||
|
}
|
||||||
|
if len(filteredShell.model.VisibleItems()) == 0 {
|
||||||
|
t.Fatal("filter recovery did not restore catalog rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractItems() []application.CatalogListItem {
|
||||||
|
return []application.CatalogListItem{
|
||||||
|
{
|
||||||
|
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
|
||||||
|
Status: domain.StatusNotInstalled, Installable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
|
||||||
|
Status: domain.StatusInstalled, Installed: true, Installable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
|
||||||
|
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
|
||||||
|
items := make([]application.CatalogListItem, count)
|
||||||
|
for index := range items {
|
||||||
|
category := "工具"
|
||||||
|
if index%2 == 1 {
|
||||||
|
category = "图像"
|
||||||
|
}
|
||||||
|
items[index] = application.CatalogListItem{
|
||||||
|
ID: fmt.Sprintf("app-%03d", index),
|
||||||
|
Name: fmt.Sprintf("App %03d", index),
|
||||||
|
Version: "1.0.0",
|
||||||
|
Category: category,
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
Installable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
|
||||||
|
var operations op.Ops
|
||||||
|
var router input.Router
|
||||||
|
context := layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Source: router.Source(),
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
shell.Layout(context, NewTheme())
|
||||||
|
router.Frame(&operations)
|
||||||
|
return router.AppendSemantics(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Desc.Label == want || node.Desc.Description == want ||
|
||||||
|
adapterContractHasSemantic(node.Children, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractIDs(items []application.CatalogListItem) []string {
|
||||||
|
ids := make([]string, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
ids[index] = item.ID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import "softbox.local/core/application"
|
||||||
|
|
||||||
|
type catalogPresentationState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
catalogStateLoading catalogPresentationState = "loading"
|
||||||
|
catalogStateReady catalogPresentationState = "ready"
|
||||||
|
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||||
|
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||||
|
payload, handled, err := application.ParseCatalogEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return handled, err
|
||||||
|
}
|
||||||
|
switch payload.Type {
|
||||||
|
case application.EventCatalogRefreshed:
|
||||||
|
shell.SetItems(payload.Items)
|
||||||
|
case application.EventCatalogRejected:
|
||||||
|
switch payload.FailureCode {
|
||||||
|
case application.CatalogFailureSourceUnconfigured:
|
||||||
|
shell.catalogState = catalogStateUnconfigured
|
||||||
|
case application.CatalogFailureLoadFailed:
|
||||||
|
shell.catalogState = catalogStateLoadFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) catalogStatusText() string {
|
||||||
|
switch shell.catalogState {
|
||||||
|
case catalogStateLoading:
|
||||||
|
return "目录状态:正在加载已验证 Catalog"
|
||||||
|
case catalogStateUnconfigured:
|
||||||
|
return "目录状态:Catalog 来源尚未配置"
|
||||||
|
case catalogStateLoadFailed:
|
||||||
|
return "目录状态:Catalog 加载失败"
|
||||||
|
case catalogStateReady:
|
||||||
|
return "目录状态:已加载已验证 Catalog"
|
||||||
|
default:
|
||||||
|
return "目录状态:未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||||
|
shell := NewAppShell("Modern")
|
||||||
|
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRefreshed,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||||
|
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||||
|
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||||
|
shell := NewAppShell("Modern")
|
||||||
|
if shell.catalogState != catalogStateLoading {
|
||||||
|
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||||
|
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||||
|
t.Fatal("unconfigured state was not visible")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||||
|
t.Fatal("load-failed state was not visible")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRefreshed,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||||
|
t.Fatal("loaded-empty state was not visible")
|
||||||
|
}
|
||||||
|
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||||
|
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("bad payload error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package gio contains the modern Gio UI adapter.
|
||||||
|
//
|
||||||
|
// Layout code is rendering-only: it must not read files, access the network,
|
||||||
|
// calculate hashes, or directly mutate background application state.
|
||||||
|
package gio
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
if handled, err := shell.applyAuthorizationEvent(event); err != nil || handled {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity := iconEvent.Identity
|
||||||
|
pending, exists := shell.iconRequests[identity.AppID]
|
||||||
|
if !exists || pending != identity {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if shell.iconReferences[identity.AppID] != identity.Reference {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(shell.iconRequests, identity.AppID)
|
||||||
|
switch iconEvent.Type {
|
||||||
|
case application.EventIconReady:
|
||||||
|
shell.ApplyIcon(identity.AppID, iconEvent.Image)
|
||||||
|
shell.iconApplied[identity.AppID] = identity
|
||||||
|
case application.EventIconFailed:
|
||||||
|
applied, hasApplied := shell.iconApplied[identity.AppID]
|
||||||
|
if !hasApplied || !sameIconResource(applied, identity) {
|
||||||
|
shell.ApplyIcon(identity.AppID, nil)
|
||||||
|
}
|
||||||
|
shell.iconFailures[identity.AppID] = iconFailureState{
|
||||||
|
Identity: identity,
|
||||||
|
Code: iconEvent.ErrorCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconFailure exposes the last failure for diagnostics without raw network data.
|
||||||
|
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
|
||||||
|
failure, exists := shell.iconFailures[appID]
|
||||||
|
return failure.Code, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalIconReference(reference string) string {
|
||||||
|
canonical, err := application.NormalizeIconReference(reference)
|
||||||
|
if err != nil {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameIconResource(
|
||||||
|
left application.IconEventIdentity,
|
||||||
|
right application.IconEventIdentity,
|
||||||
|
) bool {
|
||||||
|
return left.AppID == right.AppID &&
|
||||||
|
left.Reference == right.Reference &&
|
||||||
|
left.DPI == right.DPI
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
|
||||||
|
reference := testIconReference("11")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: reference,
|
||||||
|
})
|
||||||
|
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(identity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready, err := application.NewIconReadyEvent(
|
||||||
|
identity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relay, err := application.NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
submitted := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
submitted <- relay.Submit(context.Background(), ready)
|
||||||
|
}()
|
||||||
|
if err := waitIconSubmit(submitted); err != nil {
|
||||||
|
t.Fatalf("Submit() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("background relay changed shell before UI drain")
|
||||||
|
}
|
||||||
|
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||||
|
t.Fatalf("Drain() error = %v", err)
|
||||||
|
}
|
||||||
|
icon, exists := shell.icons["app-one"]
|
||||||
|
if !exists || icon.Size() != image.Pt(24, 24) {
|
||||||
|
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
|
||||||
|
reference := testIconReference("22")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: reference,
|
||||||
|
})
|
||||||
|
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
|
||||||
|
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(oldIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ExpectIcon(newIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldReady, err := application.NewIconReadyEvent(
|
||||||
|
oldIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("stale request inserted an icon")
|
||||||
|
}
|
||||||
|
|
||||||
|
newReady, err := application.NewIconReadyEvent(
|
||||||
|
newIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
|
||||||
|
t.Fatalf("latest icon size = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(retryIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; !exists {
|
||||||
|
t.Fatal("same-resource retry discarded an already valid icon")
|
||||||
|
}
|
||||||
|
failed, err := application.NewIconFailedEvent(
|
||||||
|
retryIdentity,
|
||||||
|
application.IconFailureUnavailable,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(failed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; !exists {
|
||||||
|
t.Fatal("matching failure discarded an already valid icon")
|
||||||
|
}
|
||||||
|
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
|
||||||
|
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
|
||||||
|
if err := shell.ExpectIcon(dpiIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("different-DPI request retained an unmatching image")
|
||||||
|
}
|
||||||
|
dpiFailed, err := application.NewIconFailedEvent(
|
||||||
|
dpiIdentity,
|
||||||
|
application.IconFailureUnavailable,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(dpiFailed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("different-DPI failure restored an unmatching image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
|
||||||
|
oldReference := testIconReference("33")
|
||||||
|
newReference := testIconReference("44")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: oldReference,
|
||||||
|
})
|
||||||
|
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
|
||||||
|
if err := shell.ExpectIcon(oldIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldReady, err := application.NewIconReadyEvent(
|
||||||
|
oldIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.SetItems([]application.CatalogListItem{{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: newReference,
|
||||||
|
}})
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("IconRef change retained the previous image")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("old IconRef result was reinserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(newIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
|
||||||
|
t.Fatal("CancelIconRequest() did not cancel the latest request")
|
||||||
|
}
|
||||||
|
newReady, err := application.NewIconReadyEvent(
|
||||||
|
newIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("canceled result was applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems(nil)
|
||||||
|
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
|
||||||
|
t.Fatalf("ExpectIcon(removed app) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("removed app was reinserted by a late result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
|
||||||
|
shell := NewAppShell("Test")
|
||||||
|
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
|
||||||
|
}
|
||||||
|
err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventIconReady,
|
||||||
|
RequestID: "request",
|
||||||
|
AppID: "app-one",
|
||||||
|
Payload: "wrong",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, application.ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(shell.icons) != 0 {
|
||||||
|
t.Fatal("invalid payload polluted icon state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
|
||||||
|
reference := testIconReference("55")
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
Version: "1.0.0",
|
||||||
|
IconRef: reference,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Test", item)
|
||||||
|
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(identity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
|
||||||
|
shell.model.Select(item.ID)
|
||||||
|
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
failure := shell.iconFailures[item.ID]
|
||||||
|
for _, want := range []string{
|
||||||
|
"图标缓存安全警告",
|
||||||
|
unsafeIconCacheMessage,
|
||||||
|
unsafeIconCacheDiagnostic(failure),
|
||||||
|
} {
|
||||||
|
if !adapterContractHasSemantic(nodes, want) {
|
||||||
|
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
|
||||||
|
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(unavailable); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
|
||||||
|
nodes = adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
|
||||||
|
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
|
||||||
|
}
|
||||||
|
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||||
|
code != application.IconFailureUnavailable {
|
||||||
|
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
|
||||||
|
reference := testIconReference("66")
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
Version: "1.0.0",
|
||||||
|
IconRef: reference,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Test", item)
|
||||||
|
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
|
||||||
|
if err := shell.ExpectIcon(first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
firstFailure := applyTestIconFailure(
|
||||||
|
t,
|
||||||
|
shell,
|
||||||
|
first,
|
||||||
|
application.IconFailureUnsafe,
|
||||||
|
)
|
||||||
|
if got := shell.iconFailures[item.ID].Identity; got != first {
|
||||||
|
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{item})
|
||||||
|
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||||
|
code != application.IconFailureUnsafe {
|
||||||
|
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
|
||||||
|
if err := shell.ExpectIcon(latest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("new request retained the previous unsafe diagnostic")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(firstFailure); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("late failure restored a stale diagnostic")
|
||||||
|
}
|
||||||
|
ready, err := application.NewIconReadyEvent(
|
||||||
|
latest,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(ready); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("ready event retained an unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(dpiRequest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
|
||||||
|
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
|
||||||
|
t.Fatalf("stored failure DPI = %d, want 144", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
newReference := testIconReference("77")
|
||||||
|
changed := item
|
||||||
|
changed.IconRef = newReference
|
||||||
|
shell.SetItems([]application.CatalogListItem{changed})
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("IconRef change retained the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(canceled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
|
||||||
|
t.Fatal("CancelIconRequest() did not cancel the current request")
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("canceled failure created an unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
|
||||||
|
shell.SetItems(nil)
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("removed app retained the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTestIconFailure(
|
||||||
|
t *testing.T,
|
||||||
|
shell *AppShell,
|
||||||
|
identity application.IconEventIdentity,
|
||||||
|
code application.IconFailureCode,
|
||||||
|
) application.Event {
|
||||||
|
t.Helper()
|
||||||
|
event, err := application.NewIconFailedEvent(identity, code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(event); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIconReference(pair string) string {
|
||||||
|
return "sha256:" + strings.Repeat(pair, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIconIdentity(
|
||||||
|
t *testing.T,
|
||||||
|
requestID string,
|
||||||
|
appID string,
|
||||||
|
reference string,
|
||||||
|
dpi int,
|
||||||
|
) application.IconEventIdentity {
|
||||||
|
t.Helper()
|
||||||
|
identity, err := application.NewIconEventIdentity(
|
||||||
|
requestID,
|
||||||
|
appID,
|
||||||
|
reference,
|
||||||
|
dpi,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitIconSubmit(result <-chan error) error {
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
return err
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
return errors.New("timed out waiting for icon relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import "softbox.local/core/application"
|
||||||
|
|
||||||
|
func (shell *AppShell) applyAuthorizationEvent(event application.Event) (bool, error) {
|
||||||
|
payload, handled, err := application.ParseAuthorizationEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return handled, err
|
||||||
|
}
|
||||||
|
shell.authorization = payload.Snapshot
|
||||||
|
shell.authorizationLoaded = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthorizationViewRendersSanitizedStateAndQueuesImport(t *testing.T) {
|
||||||
|
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||||
|
State: application.AuthorizationStateGrace,
|
||||||
|
MachineHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
Products: []application.AuthorizedProduct{{
|
||||||
|
ProductID: "product-test", Kind: application.LicenseKindNonPerpetual, RebindPolicy: "support-only",
|
||||||
|
}},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.viewLicense.Click()
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !shell.showLicense || !adapterContractHasSemantic(nodes, "授权") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "导入许可证") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "product-test · 非永久许可证(License v1 未提供试用到期信息)") {
|
||||||
|
t.Fatal("authorization state did not render its accessible content")
|
||||||
|
}
|
||||||
|
shell.importLicense.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !shell.TakeLicenseImportRequest() || shell.TakeLicenseImportRequest() {
|
||||||
|
t.Fatal("import click did not produce exactly one UI-only request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||||
|
State: application.AuthorizationStateUnconfigured, Products: []application.AuthorizedProduct{},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.importLicense.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if shell.TakeLicenseImportRequest() {
|
||||||
|
t.Fatal("unconfigured authorization accepted an import request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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
|
||||||
|
viewLicense widget.Clickable
|
||||||
|
resetFilters widget.Clickable
|
||||||
|
closeDetail widget.Clickable
|
||||||
|
importLicense 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
|
||||||
|
authorization application.AuthorizationSnapshot
|
||||||
|
authorizationLoaded bool
|
||||||
|
showLicense bool
|
||||||
|
licenseImportRequested bool
|
||||||
|
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),
|
||||||
|
authorization: application.AuthorizationSnapshot{Products: []application.AuthorizedProduct{}},
|
||||||
|
}
|
||||||
|
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.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewAll)
|
||||||
|
}
|
||||||
|
for shell.viewInstalled.Clicked(gtx) {
|
||||||
|
shell.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewInstalled)
|
||||||
|
}
|
||||||
|
for shell.viewUpdates.Clicked(gtx) {
|
||||||
|
shell.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewUpdates)
|
||||||
|
}
|
||||||
|
for shell.viewLicense.Clicked(gtx) {
|
||||||
|
shell.showLicense = true
|
||||||
|
shell.model.Select("")
|
||||||
|
}
|
||||||
|
for shell.importLicense.Clicked(gtx) {
|
||||||
|
if shell.canImportLicense() {
|
||||||
|
shell.licenseImportRequested = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeLicenseImportRequest consumes one UI-only import request. Composition
|
||||||
|
// must perform any picker, file I/O and verification in a background task.
|
||||||
|
func (shell *AppShell) TakeLicenseImportRequest() bool {
|
||||||
|
if shell == nil || !shell.licenseImportRequested {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
shell.licenseImportRequested = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
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{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewLicense,
|
||||||
|
"授权",
|
||||||
|
shell.showLicense,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if shell.showLicense {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(16)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutLicensePanel(gtx, theme)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected, hasSelection := shell.model.SelectedItem()
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(16)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutCatalog(gtx, theme)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
width := gtx.Dp(unit.Dp(320))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return shell.layoutDetail(gtx, theme, selected)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutCatalog(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return shell.layoutEmptyState(gtx, theme)
|
||||||
|
}
|
||||||
|
return shell.appList.Layout(gtx, len(visible), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.lastRendered++
|
||||||
|
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppRow(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
|
||||||
|
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp(fmt.Sprintf(
|
||||||
|
"%s,版本 %s,状态 %s",
|
||||||
|
item.Name,
|
||||||
|
item.Version,
|
||||||
|
statusLabel(item.Status),
|
||||||
|
)).Add(gtx.Ops)
|
||||||
|
|
||||||
|
background := shellColors.muted
|
||||||
|
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||||
|
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||||
|
}
|
||||||
|
if shell.model.SelectedID() == item.ID {
|
||||||
|
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
background,
|
||||||
|
unit.Dp(8),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(48),
|
||||||
|
unit.Dp(8),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, item.Name).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s · %s · %s",
|
||||||
|
item.ID,
|
||||||
|
item.Version,
|
||||||
|
item.Category,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body1(theme, statusLabel(item.Status))
|
||||||
|
label.Color = statusColor(item.Status)
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, actionLabel(item))
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppIcon(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
name string,
|
||||||
|
iconSize unit.Dp,
|
||||||
|
radius unit.Dp,
|
||||||
|
) layout.Dimensions {
|
||||||
|
size := gtx.Dp(iconSize)
|
||||||
|
gtx.Constraints.Min = image.Pt(size, size)
|
||||||
|
gtx.Constraints.Max = gtx.Constraints.Min
|
||||||
|
if icon, exists := shell.icons[appID]; exists {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(2)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return widget.Image{
|
||||||
|
Src: icon,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.Center,
|
||||||
|
}.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
letter := "S"
|
||||||
|
for _, character := range name {
|
||||||
|
letter = string(character)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.primary,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(0)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.H6(theme, letter)
|
||||||
|
label.Color = shellColors.onPrimary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutEmptyState(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
title := "没有匹配的软件"
|
||||||
|
body := "尝试清除搜索词、分类或视图筛选。"
|
||||||
|
showReset := shell.model.TotalCount() > 0
|
||||||
|
if shell.model.TotalCount() == 0 {
|
||||||
|
title = "软件目录尚未加载"
|
||||||
|
body = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||||
|
switch shell.catalogState {
|
||||||
|
case catalogStateLoading:
|
||||||
|
title = "正在加载软件目录"
|
||||||
|
body = "正在等待已验证 Catalog 快照。"
|
||||||
|
case catalogStateUnconfigured:
|
||||||
|
title = "Catalog 来源尚未配置"
|
||||||
|
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||||
|
case catalogStateLoadFailed:
|
||||||
|
title = "Catalog 加载失败"
|
||||||
|
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||||
|
case catalogStateReady:
|
||||||
|
title = "Catalog 暂无可显示软件"
|
||||||
|
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, title).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, body)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showReset {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.resetFilters,
|
||||||
|
"显示全部软件",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutDetail(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.detailRendered = true
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.muted,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(16)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, "软件详情").Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.closeDetail,
|
||||||
|
"关闭",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(72),
|
||||||
|
unit.Dp(12),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, material.H6(theme, item.Name).Layout)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%s · %s", item.ID, item.Version),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return layout.Center.Layout(gtx, label.Layout)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(gtx, theme, "状态", statusLabel(item.Status))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
"标签",
|
||||||
|
fallbackText(strings.Join(item.Tags, " · "), "无"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
"简介",
|
||||||
|
fallbackText(item.Description, "暂无简介"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Reason == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Tutorial == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "教程", item.Tutorial)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Homepage == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "主页", item.Homepage)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, actionLabel(item)+";实际操作将在后续用例接入")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutUnsafeIconCacheFailure(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
failure, exists := shell.iconFailures[appID]
|
||||||
|
if !exists || failure.Code != application.IconFailureUnsafe ||
|
||||||
|
failure.Identity.AppID != appID {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return outlinedPanel(
|
||||||
|
gtx,
|
||||||
|
shellColors.destructive,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(8),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
title := material.Body1(theme, "图标缓存安全警告")
|
||||||
|
title.Color = shellColors.destructive
|
||||||
|
return title.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
|
||||||
|
diagnostic.Color = shellColors.secondary
|
||||||
|
return diagnostic.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
|
||||||
|
failure.Code,
|
||||||
|
failure.Identity.AppID,
|
||||||
|
unsafeIconCacheLocator(failure.Identity),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
|
||||||
|
digest := strings.TrimPrefix(identity.Reference, "sha256:")
|
||||||
|
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionLabel(item application.CatalogListItem) string {
|
||||||
|
if item.Reason != "" || item.Status == domain.StatusIncompatible {
|
||||||
|
return "查看不可用原因"
|
||||||
|
}
|
||||||
|
switch item.Status {
|
||||||
|
case domain.StatusInstalled:
|
||||||
|
return "查看或启动"
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return "查看更新"
|
||||||
|
case domain.StatusRunning:
|
||||||
|
return "查看运行状态"
|
||||||
|
case domain.StatusQueued,
|
||||||
|
domain.StatusDownloading,
|
||||||
|
domain.StatusVerifying,
|
||||||
|
domain.StatusExtracting,
|
||||||
|
domain.StatusInstalling:
|
||||||
|
return "查看任务"
|
||||||
|
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||||
|
return "查看恢复选项"
|
||||||
|
default:
|
||||||
|
if item.Installable {
|
||||||
|
return "查看并安装"
|
||||||
|
}
|
||||||
|
return "查看详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailField(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
labelText string,
|
||||||
|
value string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, labelText)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(material.Body2(theme, value).Layout),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackText(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonLabel(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "deprecated":
|
||||||
|
return "软件已停止发布,不能新装或更新"
|
||||||
|
case "minimum_os":
|
||||||
|
return "当前 Windows 版本低于最低要求"
|
||||||
|
case "architecture":
|
||||||
|
return "没有适用于当前系统架构的软件包"
|
||||||
|
default:
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutHeader(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H4(theme, "SoftBox").Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, "发现、安装并更新可信软件")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
border := shellColors.border
|
||||||
|
if gtx.Focused(&shell.search) {
|
||||||
|
border = shellColors.primary
|
||||||
|
}
|
||||||
|
return outlinedPanel(
|
||||||
|
gtx,
|
||||||
|
border,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(8),
|
||||||
|
layout.Inset{
|
||||||
|
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||||
|
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
|
||||||
|
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
|
||||||
|
editor.TextSize = unit.Sp(15)
|
||||||
|
return editor.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutCategories(gtx, theme)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutCategories(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
categories := append([]string{""}, shell.model.Categories()...)
|
||||||
|
height := gtx.Dp(unit.Dp(44))
|
||||||
|
gtx.Constraints.Min.Y = height
|
||||||
|
gtx.Constraints.Max.Y = height
|
||||||
|
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
category := categories[index]
|
||||||
|
label := category
|
||||||
|
if label == "" {
|
||||||
|
label = "全部分类"
|
||||||
|
}
|
||||||
|
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
shell.categoryControls[category],
|
||||||
|
label,
|
||||||
|
shell.model.Category() == category,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutViewButton(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
clickable *widget.Clickable,
|
||||||
|
label string,
|
||||||
|
view application.CatalogView,
|
||||||
|
) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
clickable,
|
||||||
|
label,
|
||||||
|
shell.model.View() == view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutFilterButton(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
clickable *widget.Clickable,
|
||||||
|
label string,
|
||||||
|
active bool,
|
||||||
|
) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||||
|
button := material.Button(theme, clickable, label)
|
||||||
|
button.CornerRadius = unit.Dp(8)
|
||||||
|
button.Inset = layout.Inset{
|
||||||
|
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||||
|
Left: unit.Dp(14), Right: unit.Dp(14),
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
button.Background = shellColors.primary
|
||||||
|
button.Color = shellColors.onPrimary
|
||||||
|
} else {
|
||||||
|
button.Background = shellColors.muted
|
||||||
|
button.Color = shellColors.foreground
|
||||||
|
}
|
||||||
|
return button.Layout(gtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutFooter(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, shell.catalogStatusText())
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gioui.org/io/semantic"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutLicensePanel(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H5(theme, "授权").Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, shell.licenseStatusText())
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if shell.authorization.MachineHash == "" {
|
||||||
|
return licenseText(gtx, theme, "机器信息:授权配置完成后显示 machine_hash")
|
||||||
|
}
|
||||||
|
return licenseText(gtx, theme, "机器信息(machine_hash):"+shell.authorization.MachineHash)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !shell.canImportLicense() {
|
||||||
|
return licenseText(gtx, theme, "许可证导入:当前构建未配置可信授权来源")
|
||||||
|
}
|
||||||
|
return shell.layoutLicenseImportButton(gtx, theme)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
|
layout.Rigid(material.H6(theme, "已授权产品").Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(shell.authorization.Products) == 0 {
|
||||||
|
return licenseText(gtx, theme, "暂无可用授权。试用功能需要服务端签发可验证的许可证,本机不会创建试用授权。")
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, licenseProductWidgets(shell.authorization.Products, theme)...)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutLicenseImportButton(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||||
|
button := material.Button(theme, &shell.importLicense, "导入许可证")
|
||||||
|
button.Background = shellColors.primary
|
||||||
|
button.Color = shellColors.onPrimary
|
||||||
|
button.Inset = layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10), Left: unit.Dp(14), Right: unit.Dp(14)}
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp("导入许可证文件,验证在后台完成").Add(gtx.Ops)
|
||||||
|
return button.Layout(gtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseProductWidgets(products []application.AuthorizedProduct, theme *material.Theme) []layout.FlexChild {
|
||||||
|
widgets := make([]layout.FlexChild, 0, len(products)*3)
|
||||||
|
for _, product := range products {
|
||||||
|
product := product
|
||||||
|
widgets = append(widgets,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, fmt.Sprintf("%s · %s", product.ProductID, licenseKindText(product.Kind)))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, "换绑/申诉:"+product.RebindPolicy+";不会在本机修改绑定")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) canImportLicense() bool {
|
||||||
|
return shell.authorizationLoaded && shell.authorization.State != application.AuthorizationStateUnconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) licenseStatusText() string {
|
||||||
|
if !shell.authorizationLoaded {
|
||||||
|
return "授权状态:正在加载"
|
||||||
|
}
|
||||||
|
switch shell.authorization.State {
|
||||||
|
case application.AuthorizationStateReady:
|
||||||
|
return "授权状态:撤销名单有效,当前授权可用"
|
||||||
|
case application.AuthorizationStateGrace:
|
||||||
|
return "授权状态:撤销名单已过期,处于 7 天离线宽限期"
|
||||||
|
case application.AuthorizationStateNoLicense:
|
||||||
|
return "授权状态:未找到当前机器的有效许可证"
|
||||||
|
case application.AuthorizationStateRevoked:
|
||||||
|
return "授权状态:许可证已撤销,不能启动受保护软件"
|
||||||
|
case application.AuthorizationStateUnavailable:
|
||||||
|
return "授权状态:撤销验证不可用,不能启动受保护软件"
|
||||||
|
case application.AuthorizationStateImportFailed:
|
||||||
|
return "授权状态:导入失败;原许可证与授权状态未被信任地替换"
|
||||||
|
case application.AuthorizationStateUnconfigured:
|
||||||
|
return "授权状态:可信授权来源尚未配置"
|
||||||
|
default:
|
||||||
|
return "授权状态:不可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseKindText(kind application.LicenseKind) string {
|
||||||
|
if kind == application.LicenseKindPerpetual {
|
||||||
|
return "正式许可证"
|
||||||
|
}
|
||||||
|
return "非永久许可证(License v1 未提供试用到期信息)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseText(gtx layout.Context, theme *material.Theme, value string) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, value)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var shellColors = struct {
|
||||||
|
background color.NRGBA
|
||||||
|
surface color.NRGBA
|
||||||
|
muted color.NRGBA
|
||||||
|
foreground color.NRGBA
|
||||||
|
secondary color.NRGBA
|
||||||
|
primary color.NRGBA
|
||||||
|
onPrimary color.NRGBA
|
||||||
|
border color.NRGBA
|
||||||
|
success color.NRGBA
|
||||||
|
warning color.NRGBA
|
||||||
|
destructive color.NRGBA
|
||||||
|
}{
|
||||||
|
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||||
|
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||||
|
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||||
|
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||||
|
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||||
|
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||||
|
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||||
|
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||||
|
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||||
|
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||||
|
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTheme creates the accessible semantic palette shared by the modern shell.
|
||||||
|
func NewTheme() *material.Theme {
|
||||||
|
theme := material.NewTheme()
|
||||||
|
theme.Palette = material.Palette{
|
||||||
|
Bg: shellColors.background,
|
||||||
|
Fg: shellColors.foreground,
|
||||||
|
ContrastBg: shellColors.primary,
|
||||||
|
ContrastFg: shellColors.onPrimary,
|
||||||
|
}
|
||||||
|
theme.FingerSize = unit.Dp(44)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
func panel(
|
||||||
|
gtx layout.Context,
|
||||||
|
background color.NRGBA,
|
||||||
|
radius unit.Dp,
|
||||||
|
inset layout.Inset,
|
||||||
|
content layout.Widget,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Background{}.Layout(
|
||||||
|
gtx,
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
paint.FillShape(
|
||||||
|
gtx.Ops,
|
||||||
|
background,
|
||||||
|
clip.UniformRRect(
|
||||||
|
image.Rectangle{Max: gtx.Constraints.Min},
|
||||||
|
gtx.Dp(radius),
|
||||||
|
).Op(gtx.Ops),
|
||||||
|
)
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return inset.Layout(gtx, content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlinedPanel(
|
||||||
|
gtx layout.Context,
|
||||||
|
border color.NRGBA,
|
||||||
|
background color.NRGBA,
|
||||||
|
radius unit.Dp,
|
||||||
|
inset layout.Inset,
|
||||||
|
content layout.Widget,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
border,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(1)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewTitle(view application.CatalogView) string {
|
||||||
|
switch view {
|
||||||
|
case application.CatalogViewInstalled:
|
||||||
|
return "已安装软件"
|
||||||
|
case application.CatalogViewUpdates:
|
||||||
|
return "可更新软件"
|
||||||
|
default:
|
||||||
|
return "全部软件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(status domain.AppStatus) string {
|
||||||
|
switch status {
|
||||||
|
case domain.StatusQueued:
|
||||||
|
return "排队中"
|
||||||
|
case domain.StatusDownloading:
|
||||||
|
return "下载中"
|
||||||
|
case domain.StatusVerifying:
|
||||||
|
return "校验中"
|
||||||
|
case domain.StatusExtracting:
|
||||||
|
return "解压中"
|
||||||
|
case domain.StatusInstalling:
|
||||||
|
return "安装中"
|
||||||
|
case domain.StatusInstalled:
|
||||||
|
return "已安装"
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return "可更新"
|
||||||
|
case domain.StatusRunning:
|
||||||
|
return "运行中"
|
||||||
|
case domain.StatusFailed:
|
||||||
|
return "失败"
|
||||||
|
case domain.StatusRollbackPending:
|
||||||
|
return "待恢复"
|
||||||
|
case domain.StatusIncompatible:
|
||||||
|
return "不兼容"
|
||||||
|
default:
|
||||||
|
return "未安装"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||||
|
switch status {
|
||||||
|
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||||
|
return shellColors.destructive
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return shellColors.warning
|
||||||
|
case domain.StatusInstalled, domain.StatusRunning:
|
||||||
|
return shellColors.success
|
||||||
|
case domain.StatusIncompatible:
|
||||||
|
return shellColors.secondary
|
||||||
|
default:
|
||||||
|
return shellColors.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppShellFillsWindow(t *testing.T) {
|
||||||
|
var operations op.Ops
|
||||||
|
size := image.Pt(1080, 720)
|
||||||
|
context := layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
|
||||||
|
dimensions := NewAppShell("Modern").Layout(context, NewTheme())
|
||||||
|
if dimensions.Size != size {
|
||||||
|
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||||
|
items := make([]application.CatalogListItem, 500)
|
||||||
|
for index := range items {
|
||||||
|
items[index] = application.CatalogListItem{
|
||||||
|
ID: fmt.Sprintf("app-%03d", index),
|
||||||
|
Name: fmt.Sprintf("软件 %03d", index),
|
||||||
|
Version: "1.0.0",
|
||||||
|
Category: "工具",
|
||||||
|
Tags: []string{"工具"},
|
||||||
|
Status: domain.StatusNotInstalled,
|
||||||
|
Installable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Modern", items...)
|
||||||
|
context := testContext(image.Pt(1080, 420))
|
||||||
|
|
||||||
|
shell.Layout(context, NewTheme())
|
||||||
|
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||||
|
t.Fatalf(
|
||||||
|
"lastRendered = %d, want visible subset of %d",
|
||||||
|
shell.lastRendered,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||||
|
items := []application.CatalogListItem{
|
||||||
|
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||||
|
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Modern", items...)
|
||||||
|
original := shell.rows["app-two"]
|
||||||
|
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
|
||||||
|
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
|
||||||
|
|
||||||
|
shell.model.SetCategory("图像")
|
||||||
|
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
|
||||||
|
if shell.rows["app-two"] != original {
|
||||||
|
t.Fatal("row controls were recreated after filtering")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{
|
||||||
|
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||||
|
})
|
||||||
|
if shell.rows["app-two"] != original {
|
||||||
|
t.Fatal("row controls were recreated after snapshot update")
|
||||||
|
}
|
||||||
|
if _, exists := shell.rows["app-one"]; exists {
|
||||||
|
t.Fatal("removed app retained row controls")
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("removed app retained prepared icon")
|
||||||
|
}
|
||||||
|
icon, exists := shell.icons["app-two"]
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("retained app lost its prepared icon")
|
||||||
|
}
|
||||||
|
if icon.Size() != image.Pt(24, 24) {
|
||||||
|
t.Fatalf("retained app icon size = %v", icon.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "json-parser",
|
||||||
|
Name: "JSON解析工具",
|
||||||
|
Description: "格式化并检查 JSON",
|
||||||
|
Version: "1.2.0",
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"JSON", "格式化"},
|
||||||
|
Homepage: "https://example.invalid/json-parser",
|
||||||
|
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Modern", item)
|
||||||
|
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||||
|
shell.ApplyIcon(item.ID, icon)
|
||||||
|
shell.model.Select(item.ID)
|
||||||
|
|
||||||
|
shell.Layout(testContext(image.Pt(1280, 800)), NewTheme())
|
||||||
|
if !shell.detailRendered {
|
||||||
|
t.Fatal("selected app detail was not rendered")
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons[item.ID]; !exists {
|
||||||
|
t.Fatal("ApplyIcon did not retain the prepared image operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.ApplyIcon(item.ID, nil)
|
||||||
|
if _, exists := shell.icons[item.ID]; exists {
|
||||||
|
t.Fatal("ApplyIcon(nil) did not remove the image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContext(size image.Point) layout.Context {
|
||||||
|
var operations op.Ops
|
||||||
|
return layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||||
|
runtime := application.NewRuntime(1)
|
||||||
|
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||||
|
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||||
|
}))
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("bootstrap error = %v", err)
|
||||||
|
}
|
||||||
|
event := <-runtime.Events()
|
||||||
|
if event.Type != application.EventCatalogRefreshed {
|
||||||
|
t.Fatalf("event type = %q", event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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 runWithLoaders(
|
||||||
|
application.UnconfiguredCatalogLoader{},
|
||||||
|
application.UnconfiguredAuthorizationLoader{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithCatalogLoader(loader application.CatalogSnapshotLoader) error {
|
||||||
|
return runWithLoaders(loader, application.UnconfiguredAuthorizationLoader{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithLoaders(
|
||||||
|
loader application.CatalogSnapshotLoader,
|
||||||
|
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||||
|
) error {
|
||||||
|
return runWithCompositions(loader, authorizationLoader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithCompositions(
|
||||||
|
loader application.CatalogSnapshotLoader,
|
||||||
|
authorizationLoader application.AuthorizationSnapshotLoader,
|
||||||
|
licenseImport *application.LicenseImport,
|
||||||
|
) 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)
|
||||||
|
authorizationDone := startAuthorizationBootstrap(eventContext, runtime, authorizationLoader)
|
||||||
|
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 authorizationErr := <-authorizationDone; authorizationErr != nil &&
|
||||||
|
!errors.Is(authorizationErr, context.Canceled) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrAuthorizationSourceUnconfigured) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrRuntimeClosed) &&
|
||||||
|
!errors.Is(authorizationErr, application.ErrEventRelayClosed) {
|
||||||
|
log.Printf("%s authorization 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
|
||||||
|
importing := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
gtx := app.NewContext(&operations, event)
|
||||||
|
shell.Layout(gtx, theme)
|
||||||
|
if shell.TakeLicenseImportRequest() && licenseImport != nil {
|
||||||
|
select {
|
||||||
|
case importing <- struct{}{}:
|
||||||
|
go func() {
|
||||||
|
defer func() { <-importing }()
|
||||||
|
if importErr := licenseImport.Run(eventContext); importErr != nil &&
|
||||||
|
!errors.Is(importErr, context.Canceled) &&
|
||||||
|
!errors.Is(importErr, application.ErrRuntimeClosed) &&
|
||||||
|
!errors.Is(importErr, application.ErrEventRelayClosed) {
|
||||||
|
log.Printf("%s license import failed", core.ProductName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.Frame(gtx.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAuthorizationBootstrap(
|
||||||
|
ctx context.Context,
|
||||||
|
runtime *application.Runtime,
|
||||||
|
loader application.AuthorizationSnapshotLoader,
|
||||||
|
) <-chan error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- application.NewAuthorizationBootstrap(loader, runtime).Run(ctx)
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"softbox.local/app-win7/platform/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
|
||||||
|
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if len(arguments) != 2 {
|
||||||
|
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
|
||||||
|
}
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Errorf("locate current executable: %w", err)
|
||||||
|
}
|
||||||
|
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
|
||||||
|
return true, fmt.Errorf("acknowledge self-update health: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAcknowledgeInternalUpdateHealthRejectsMalformedArguments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
handled bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "ordinary launch", args: nil},
|
||||||
|
{name: "other internal-looking flag", args: []string{"--softbox-other"}},
|
||||||
|
{name: "missing request ID", args: []string{updater.InternalHealthFlag}, handled: true, wantErr: true},
|
||||||
|
{name: "extra argument", args: []string{updater.InternalHealthFlag, "update-1234", "extra"}, handled: true, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
handled, err := acknowledgeInternalUpdateHealth(test.args)
|
||||||
|
if handled != test.handled || (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("acknowledgeInternalUpdateHealth(%q) = handled=%t, err=%v; want handled=%t, error=%t", test.args, handled, err, test.handled, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/app-win7/platform/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(arguments []string) error {
|
||||||
|
for _, option := range []string{"--pid", "--staging", "--target"} {
|
||||||
|
if err := requireOneOption(arguments, option); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
pid := flags.Int("pid", 0, "main SoftBox PID")
|
||||||
|
staging := flags.String("staging", "", "prepared staging directory")
|
||||||
|
target := flags.String("target", "", "fixed app target directory")
|
||||||
|
if err := flags.Parse(arguments); err != nil {
|
||||||
|
return fmt.Errorf("parse updater arguments: %w", err)
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
|
||||||
|
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
|
||||||
|
}
|
||||||
|
requestID := filepath.Base(filepath.Clean(*staging))
|
||||||
|
platform := windows.New()
|
||||||
|
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
|
||||||
|
ParentExit: 2 * time.Minute,
|
||||||
|
Health: 45 * time.Second,
|
||||||
|
})
|
||||||
|
return service.Update(context.Background(), updater.Request{
|
||||||
|
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireOneOption(arguments []string, option string) error {
|
||||||
|
count := 0
|
||||||
|
for _, argument := range arguments {
|
||||||
|
if argument == option || strings.HasPrefix(argument, option+"=") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
return fmt.Errorf("%s must appear exactly once", option)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
|
||||||
|
if err := run(nil); err == nil {
|
||||||
|
t.Fatal("run(nil) succeeded")
|
||||||
|
}
|
||||||
|
if err := run([]string{
|
||||||
|
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("run() accepted duplicate --pid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module softbox.local/app-win7
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.6.0
|
||||||
|
golang.org/x/sys v0.5.0
|
||||||
|
softbox.local/core v0.0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
go 1.20
|
||||||
|
|
||||||
|
use (
|
||||||
|
.
|
||||||
|
../core
|
||||||
|
)
|
||||||
|
|
||||||
|
replace softbox.local/core v0.0.0 => ../core
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
gioui.org v0.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI=
|
||||||
|
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
|
||||||
|
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
|
||||||
|
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
|
||||||
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
|
||||||
|
switch minOS {
|
||||||
|
case "windows-7-sp1":
|
||||||
|
if major > 6 || (major == 6 && minor > 1) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return major == 6 && minor == 1 && servicePack >= 1, nil
|
||||||
|
case "windows-10":
|
||||||
|
return major >= 10, nil
|
||||||
|
case "windows-11":
|
||||||
|
return major >= 10 && build >= 22000, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package windows provides Win7-compatible platform adapters and non-Windows
|
||||||
|
// stubs for package-level tests.
|
||||||
|
package windows
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMachineFingerprintUnavailable reports that a required identifier could
|
||||||
|
// not be safely read or normalized. It intentionally contains no source value.
|
||||||
|
var ErrMachineFingerprintUnavailable = errors.New("machine fingerprint is unavailable")
|
||||||
|
|
||||||
|
type machineGUIDReader func() (string, error)
|
||||||
|
type systemVolumeSerialReader func() (uint32, error)
|
||||||
|
|
||||||
|
func machineHashFrom(readMachineGUID machineGUIDReader, readSystemVolumeSerial systemVolumeSerialReader) (string, error) {
|
||||||
|
if readMachineGUID == nil || readSystemVolumeSerial == nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
machineGUID, err := readMachineGUID()
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
systemVolumeSerial, err := readSystemVolumeSerial()
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
machineHash, err := licensing.DeriveMachineHash(machineGUID, systemVolumeSerial)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrMachineFingerprintUnavailable
|
||||||
|
}
|
||||||
|
return machineHash, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
// MachineHash is unavailable outside a Windows target.
|
||||||
|
func MachineHash() (string, error) {
|
||||||
|
return "", ErrUnsupported
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMachineHashFromUsesBothRequiredSources(t *testing.T) {
|
||||||
|
const machineGUID = "6F9619FF-8B86-D011-B42D-00C04FC964FF"
|
||||||
|
got, err := machineHashFrom(
|
||||||
|
func() (string, error) { return machineGUID, nil },
|
||||||
|
func() (uint32, error) { return 0x1a2b3c4d, nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("machineHashFrom() error = %v", err)
|
||||||
|
}
|
||||||
|
want, err := licensing.DeriveMachineHash(machineGUID, 0x1a2b3c4d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeriveMachineHash() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("machineHashFrom() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachineHashFromFailsClosedWithoutLeakingSourceValues(t *testing.T) {
|
||||||
|
const secret = "synthetic-machine-guid-secret"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
readGUID machineGUIDReader
|
||||||
|
readSerial systemVolumeSerialReader
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GUID reader failure",
|
||||||
|
readGUID: func() (string, error) { return "", errors.New(secret) },
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume reader failure",
|
||||||
|
readGUID: func() (string, error) { return "6f9619ff-8b86-d011-b42d-00c04fc964ff", nil },
|
||||||
|
readSerial: func() (uint32, error) { return 0, errors.New(secret) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid GUID",
|
||||||
|
readGUID: func() (string, error) { return secret, nil },
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing GUID reader",
|
||||||
|
readSerial: func() (uint32, error) { return 1, nil },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := machineHashFrom(test.readGUID, test.readSerial)
|
||||||
|
if !errors.Is(err, ErrMachineFingerprintUnavailable) {
|
||||||
|
t.Fatalf("machineHashFrom() error = %v, want ErrMachineFingerprintUnavailable", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("machineHashFrom() leaked a source value in %q", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineGUIDRegistryPath = `SOFTWARE\Microsoft\Cryptography`
|
||||||
|
|
||||||
|
// MachineHash reads the two required Windows identifiers only for the duration
|
||||||
|
// of this call and returns their derived licensing hash.
|
||||||
|
func MachineHash() (string, error) {
|
||||||
|
return machineHashFrom(readMachineGUID, readSystemVolumeSerial)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMachineGUID() (string, error) {
|
||||||
|
key, err := registry.OpenKey(registry.LOCAL_MACHINE, machineGUIDRegistryPath, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
value, _, err := key.GetStringValue("MachineGuid")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemVolumeSerial() (uint32, error) {
|
||||||
|
windowsDirectory, err := windows.GetWindowsDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
volumeName := filepath.VolumeName(windowsDirectory)
|
||||||
|
if len(volumeName) != 2 || volumeName[1] != ':' || !isASCIIAlpha(volumeName[0]) {
|
||||||
|
return 0, errors.New("windows directory is not on a drive volume")
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := windows.UTF16PtrFromString(volumeName + `\`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var serial uint32
|
||||||
|
if err := windows.GetVolumeInformation(root, nil, 0, &serial, nil, nil, nil, 0); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return serial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIAlpha(character byte) bool {
|
||||||
|
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z'
|
||||||
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlatformStubFailsClosed(t *testing.T) {
|
||||||
|
if _, err := MachineHash(); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("MachineHash() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
platform := New()
|
||||||
|
if _, err := platform.IsRunning("test-app", "C:/test/App.exe"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("IsRunning() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) {
|
||||||
|
t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlatformStubContract(t *testing.T) {
|
||||||
|
platform := New()
|
||||||
|
if platform.OS() == "" {
|
||||||
|
t.Fatal("OS() should not be empty")
|
||||||
|
}
|
||||||
|
if platform.Edition() != EditionLegacy {
|
||||||
|
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "one", "App.exe")
|
||||||
|
collision := filepath.Join(root, "two", "App.exe")
|
||||||
|
|
||||||
|
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v", err)
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
|
||||||
|
})
|
||||||
|
if err != nil || !running {
|
||||||
|
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
|
||||||
|
expected := errors.New("snapshot failed")
|
||||||
|
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||||
|
return nil, expected
|
||||||
|
})
|
||||||
|
if !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
|
||||||
|
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
|
||||||
|
})
|
||||||
|
if !errors.Is(err, expected) {
|
||||||
|
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionSupports(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
minOS string
|
||||||
|
major, minor, build, service uint32
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
|
||||||
|
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
|
||||||
|
{minOS: "windows-10", major: 10, want: true},
|
||||||
|
{minOS: "windows-11", major: 10, build: 19045, want: false},
|
||||||
|
{minOS: "windows-11", major: 10, build: 22000, want: true},
|
||||||
|
{minOS: "unknown", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
|
||||||
|
if (err != nil) != test.wantErr || got != test.want {
|
||||||
|
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForExit(t *testing.T) {
|
||||||
|
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||||
|
errSnapshot := errors.New("snapshot failed")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
running []bool
|
||||||
|
runErr error
|
||||||
|
timeout time.Duration
|
||||||
|
wantErr error
|
||||||
|
wantSleeps int
|
||||||
|
}{
|
||||||
|
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||||
|
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||||
|
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||||
|
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||||
|
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
clock := &fakeExitWaitClock{now: initial}
|
||||||
|
index := 0
|
||||||
|
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||||
|
if test.runErr != nil {
|
||||||
|
return false, test.runErr
|
||||||
|
}
|
||||||
|
if index >= len(test.running) {
|
||||||
|
return test.running[len(test.running)-1], nil
|
||||||
|
}
|
||||||
|
running := test.running[index]
|
||||||
|
index++
|
||||||
|
return running, nil
|
||||||
|
}, clock)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if clock.sleeps != test.wantSleeps {
|
||||||
|
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canceledWaitContext() context.Context {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeExitWaitClock struct {
|
||||||
|
now time.Time
|
||||||
|
sleeps int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||||
|
|
||||||
|
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clock.sleeps++
|
||||||
|
clock.now = clock.now.Add(duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotItem struct {
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedSnapshot struct {
|
||||||
|
items []snapshotItem
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
|
||||||
|
if snapshot.next == len(snapshot.items) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
item := snapshot.items[snapshot.next]
|
||||||
|
snapshot.next++
|
||||||
|
return item.path, true, item.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*scriptedSnapshot) Close() error { return nil }
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"softbox.local/core/application/launch"
|
||||||
|
)
|
||||||
|
|
||||||
|
const seeMaskNoCloseProcess = 0x00000040
|
||||||
|
|
||||||
|
var (
|
||||||
|
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
|
||||||
|
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
|
||||||
|
)
|
||||||
|
|
||||||
|
type platform struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platform{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) OS() string {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Edition() Edition {
|
||||||
|
return EditionLegacy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) IsCompatible(minOS string) (bool, error) {
|
||||||
|
version, err := currentVersion()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||||
|
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
|
||||||
|
return newToolhelpSnapshot(filepath.Base(entrypoint))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||||
|
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||||
|
return target.IsRunning(appID, entrypoint)
|
||||||
|
}, systemExitWaitClock{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Start(command launch.Command) (int, error) {
|
||||||
|
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||||
|
return 0, fmt.Errorf("launch command must contain absolute paths")
|
||||||
|
}
|
||||||
|
if command.RequiresAdmin {
|
||||||
|
return startElevated(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(command.Entrypoint)
|
||||||
|
cmd.Dir = command.WorkingDirectory
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return cmd.Process.Pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolhelpSnapshot struct {
|
||||||
|
handle windows.Handle
|
||||||
|
targetName string
|
||||||
|
entry windows.ProcessEntry32
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
|
||||||
|
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
var err error
|
||||||
|
if !snapshot.started {
|
||||||
|
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
|
||||||
|
err = windows.Process32First(snapshot.handle, &snapshot.entry)
|
||||||
|
snapshot.started = true
|
||||||
|
} else {
|
||||||
|
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
// ExeFile only narrows the expensive query. The identity decision below
|
||||||
|
// always uses QueryFullProcessImageName's normalized full path.
|
||||||
|
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (snapshot *toolhelpSnapshot) Close() error {
|
||||||
|
return windows.CloseHandle(snapshot.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullProcessImagePath(pid uint32) (string, error) {
|
||||||
|
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(process)
|
||||||
|
|
||||||
|
for size := uint32(260); size <= 32768; size *= 2 {
|
||||||
|
buffer := make([]uint16, size)
|
||||||
|
length := size
|
||||||
|
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
|
||||||
|
if err == nil {
|
||||||
|
return windows.UTF16ToString(buffer[:length]), nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
|
||||||
|
}
|
||||||
|
|
||||||
|
type rtlOSVersionInfoEx struct {
|
||||||
|
size uint32
|
||||||
|
major uint32
|
||||||
|
minor uint32
|
||||||
|
build uint32
|
||||||
|
platformID uint32
|
||||||
|
csdVersion [128]uint16
|
||||||
|
servicePack uint16
|
||||||
|
servicePackMinor uint16
|
||||||
|
suiteMask uint16
|
||||||
|
productType byte
|
||||||
|
reserved byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentVersion() (rtlOSVersionInfoEx, error) {
|
||||||
|
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
|
||||||
|
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
|
||||||
|
if status != 0 {
|
||||||
|
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type shellExecuteInfo struct {
|
||||||
|
size uint32
|
||||||
|
mask uint32
|
||||||
|
hwnd uintptr
|
||||||
|
verb *uint16
|
||||||
|
file *uint16
|
||||||
|
parameters *uint16
|
||||||
|
directory *uint16
|
||||||
|
show int32
|
||||||
|
instance uintptr
|
||||||
|
idList uintptr
|
||||||
|
class *uint16
|
||||||
|
keyClass uintptr
|
||||||
|
hotKey uint32
|
||||||
|
icon uintptr
|
||||||
|
process windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func startElevated(command launch.Command) (int, error) {
|
||||||
|
verb, err := windows.UTF16PtrFromString("runas")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(command.Entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
info := shellExecuteInfo{
|
||||||
|
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
|
||||||
|
mask: seeMaskNoCloseProcess,
|
||||||
|
verb: verb,
|
||||||
|
file: file,
|
||||||
|
directory: directory,
|
||||||
|
show: 1,
|
||||||
|
}
|
||||||
|
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
|
||||||
|
if result == 0 {
|
||||||
|
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
|
||||||
|
}
|
||||||
|
if info.process == 0 {
|
||||||
|
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(info.process)
|
||||||
|
pid, err := windows.GetProcessId(info.process)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(pid), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processSnapshot is deliberately small so the matching invariant can be
|
||||||
|
// tested without a Windows host. NextImagePath returns more=false only after a
|
||||||
|
// successful end-of-snapshot; any enumeration or image-path failure is an
|
||||||
|
// error, rather than evidence that the target is not running.
|
||||||
|
type processSnapshot interface {
|
||||||
|
NextImagePath() (path string, more bool, err error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type processSnapshotFactory func() (processSnapshot, error)
|
||||||
|
|
||||||
|
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
|
||||||
|
target, err := canonicalProcessPath(entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := newSnapshot()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("create process snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer snapshot.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
path, more, err := snapshot.NextImagePath()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("enumerate process image path: %w", err)
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
candidate, err := canonicalProcessPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("canonicalize process image path: %w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(target, candidate) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalProcessPath(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pidWaitHandle interface {
|
||||||
|
Wait(time.Duration) (bool, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type pidOpener func(int) (pidWaitHandle, error)
|
||||||
|
|
||||||
|
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
|
||||||
|
if pid <= 0 {
|
||||||
|
return fmt.Errorf("process PID must be positive")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
return fmt.Errorf("process wait timeout must be positive")
|
||||||
|
}
|
||||||
|
handle, err := open(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open process %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
defer handle.Close()
|
||||||
|
deadline := time.NewTimer(timeout)
|
||||||
|
defer deadline.Stop()
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exited, err := handle.Wait(250 * time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for process %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
if exited {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-deadline.C:
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakePIDHandle struct {
|
||||||
|
exited bool
|
||||||
|
waitErr error
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
|
||||||
|
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
|
||||||
|
|
||||||
|
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
|
||||||
|
handle := &fakePIDHandle{exited: true}
|
||||||
|
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
|
||||||
|
if pid != 9 {
|
||||||
|
t.Fatalf("PID = %d, want 9", pid)
|
||||||
|
}
|
||||||
|
return handle, nil
|
||||||
|
})
|
||||||
|
if err != nil || !handle.closed {
|
||||||
|
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
|
||||||
|
openErr := errors.New("access denied")
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
|
||||||
|
t.Fatalf("open error = %v", err)
|
||||||
|
}
|
||||||
|
waitErr := errors.New("wait failed")
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
|
||||||
|
t.Fatalf("wait error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel error = %v", err)
|
||||||
|
}
|
||||||
|
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("timeout error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"softbox.local/core/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
|
||||||
|
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
|
||||||
|
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||||
|
return 0, fmt.Errorf("self-update launch paths must be absolute")
|
||||||
|
}
|
||||||
|
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
|
||||||
|
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
|
||||||
|
command.HealthRequestID == "" {
|
||||||
|
return 0, fmt.Errorf("invalid fixed self-update launch command")
|
||||||
|
}
|
||||||
|
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
|
||||||
|
commandLine.Dir = command.WorkingDirectory
|
||||||
|
if err := commandLine.Start(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return commandLine.Process.Pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) SyncDirectory(path string) error {
|
||||||
|
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode directory path: %w", err)
|
||||||
|
}
|
||||||
|
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
||||||
|
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
|
||||||
|
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open directory handle: %w", err)
|
||||||
|
}
|
||||||
|
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||||
|
_ = syscall.CloseHandle(handle)
|
||||||
|
return fmt.Errorf("flush directory handle: %w", err)
|
||||||
|
}
|
||||||
|
if err := syscall.CloseHandle(handle); err != nil {
|
||||||
|
return fmt.Errorf("close directory handle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowsPIDHandle struct{ handle windows.Handle }
|
||||||
|
|
||||||
|
func openWindowsProcess(pid int) (pidWaitHandle, error) {
|
||||||
|
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return windowsPIDHandle{handle: handle}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
|
||||||
|
milliseconds := uint32(timeout / time.Millisecond)
|
||||||
|
if milliseconds == 0 {
|
||||||
|
milliseconds = 1
|
||||||
|
}
|
||||||
|
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case windows.WAIT_OBJECT_0:
|
||||||
|
return true, nil
|
||||||
|
case uint32(windows.WAIT_TIMEOUT):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exitPollInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
type exitWaitClock interface {
|
||||||
|
Now() time.Time
|
||||||
|
Wait(context.Context, time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type systemExitWaitClock struct{}
|
||||||
|
|
||||||
|
func (systemExitWaitClock) Now() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||||
|
timer := time.NewTimer(duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return fmt.Errorf("wait context is required")
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
return fmt.Errorf("exit timeout must be positive")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := clock.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
isRunning, err := running()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isRunning {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
remaining := deadline.Sub(clock.Now())
|
||||||
|
if remaining <= 0 {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
interval := exitPollInterval
|
||||||
|
if remaining < interval {
|
||||||
|
interval = remaining
|
||||||
|
}
|
||||||
|
if err := clock.Wait(ctx, interval); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/io/input"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
adapterContractViewport = image.Pt(1024, 680)
|
||||||
|
adapterContractCompactViewport = image.Pt(1024, 380)
|
||||||
|
)
|
||||||
|
|
||||||
|
const adapterContractEdition = "Legacy"
|
||||||
|
|
||||||
|
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
|
||||||
|
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
|
||||||
|
shell.search.SetText("APP-TWO")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.Query(); got != "app-two" {
|
||||||
|
t.Fatalf("query after editor update = %q, want app-two", got)
|
||||||
|
}
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
if len(visible) != 1 || visible[0].ID != "app-two" {
|
||||||
|
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.Category(); got != "图像" {
|
||||||
|
t.Fatalf("category after click = %q, want 图像", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.viewUpdates.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewUpdates {
|
||||||
|
t.Fatalf("view after updates click = %q", got)
|
||||||
|
}
|
||||||
|
shell.viewInstalled.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewInstalled {
|
||||||
|
t.Fatalf("view after installed click = %q", got)
|
||||||
|
}
|
||||||
|
shell.viewAll.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.View(); got != application.CatalogViewAll {
|
||||||
|
t.Fatalf("view after all click = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("missing-app")
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "显示全部软件") {
|
||||||
|
t.Fatal("filtered empty state did not expose its recovery action")
|
||||||
|
}
|
||||||
|
shell.resetFilters.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if shell.search.Text() != "" || shell.model.Query() != "" ||
|
||||||
|
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
|
||||||
|
t.Fatalf(
|
||||||
|
"reset state = editor %q, query %q, category %q, view %q",
|
||||||
|
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
|
||||||
|
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
|
||||||
|
items := adapterContractLargeCatalog(80)
|
||||||
|
shell := NewAppShell(adapterContractEdition, items...)
|
||||||
|
targetID := "app-037"
|
||||||
|
targetControl := shell.rows[targetID]
|
||||||
|
|
||||||
|
reordered := append([]application.CatalogListItem(nil), items...)
|
||||||
|
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
reordered[left], reordered[right] = reordered[right], reordered[left]
|
||||||
|
}
|
||||||
|
shell.SetItems(reordered)
|
||||||
|
if shell.rows[targetID] != targetControl {
|
||||||
|
t.Fatal("row control was recreated after catalog reorder")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.search.SetText("app-")
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.viewInstalled.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
shell.appList.ScrollTo(12)
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
|
||||||
|
targetControl.open.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.SelectedID(); got != targetID {
|
||||||
|
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
|
||||||
|
}
|
||||||
|
if !shell.detailRendered {
|
||||||
|
t.Fatal("row click selected the model but did not render detail")
|
||||||
|
}
|
||||||
|
queryBefore := shell.model.Query()
|
||||||
|
categoryBefore := shell.model.Category()
|
||||||
|
viewBefore := shell.model.View()
|
||||||
|
positionBefore := shell.appList.Position
|
||||||
|
|
||||||
|
shell.closeDetail.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if got := shell.model.SelectedID(); got != "" {
|
||||||
|
t.Fatalf("selected ID after close = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if shell.detailRendered {
|
||||||
|
t.Fatal("detail remained rendered after close click")
|
||||||
|
}
|
||||||
|
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
|
||||||
|
shell.model.View() != viewBefore {
|
||||||
|
t.Fatal("closing detail changed the active list filters")
|
||||||
|
}
|
||||||
|
positionAfter := shell.appList.Position
|
||||||
|
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
|
||||||
|
t.Fatalf(
|
||||||
|
"list position after close = first %d offset %d, want first %d offset %d",
|
||||||
|
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
|
||||||
|
items := adapterContractLargeCatalog(500)
|
||||||
|
shell := NewAppShell(adapterContractEdition, items...)
|
||||||
|
retainedRow := shell.rows["app-001"]
|
||||||
|
retainedCategory := shell.categoryControls["图像"]
|
||||||
|
|
||||||
|
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||||
|
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||||
|
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
|
||||||
|
}
|
||||||
|
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
|
||||||
|
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.categoryControls["图像"].Click()
|
||||||
|
adapterContractLayout(shell, adapterContractCompactViewport)
|
||||||
|
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
|
||||||
|
t.Fatal("filtering recreated stable app or category controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
|
||||||
|
if shell.rows["app-001"] != retainedRow {
|
||||||
|
t.Fatal("retained app lost its row control after snapshot update")
|
||||||
|
}
|
||||||
|
if shell.categoryControls["图像"] != retainedCategory {
|
||||||
|
t.Fatal("retained category lost its control after snapshot update")
|
||||||
|
}
|
||||||
|
if _, exists := shell.rows["app-000"]; exists {
|
||||||
|
t.Fatal("removed app retained its row control")
|
||||||
|
}
|
||||||
|
if _, exists := shell.categoryControls["工具"]; exists {
|
||||||
|
t.Fatal("removed category retained its control")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
|
||||||
|
emptyShell := NewAppShell(adapterContractEdition)
|
||||||
|
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
|
||||||
|
t.Fatal("empty catalog did not render the catalog-loading state")
|
||||||
|
}
|
||||||
|
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
|
||||||
|
t.Fatal("empty catalog rendered a filter recovery action")
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
filteredShell.search.SetText("missing-app")
|
||||||
|
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
|
||||||
|
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
|
||||||
|
t.Fatal("filtered catalog did not render the no-matches state")
|
||||||
|
}
|
||||||
|
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
|
||||||
|
t.Fatal("filtered catalog did not render its recovery action")
|
||||||
|
}
|
||||||
|
filteredShell.resetFilters.Click()
|
||||||
|
adapterContractLayout(filteredShell, adapterContractViewport)
|
||||||
|
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
|
||||||
|
t.Fatal("filter recovery did not clear editor and model query")
|
||||||
|
}
|
||||||
|
if len(filteredShell.model.VisibleItems()) == 0 {
|
||||||
|
t.Fatal("filter recovery did not restore catalog rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractItems() []application.CatalogListItem {
|
||||||
|
return []application.CatalogListItem{
|
||||||
|
{
|
||||||
|
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
|
||||||
|
Status: domain.StatusNotInstalled, Installable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
|
||||||
|
Status: domain.StatusInstalled, Installed: true, Installable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
|
||||||
|
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
|
||||||
|
items := make([]application.CatalogListItem, count)
|
||||||
|
for index := range items {
|
||||||
|
category := "工具"
|
||||||
|
if index%2 == 1 {
|
||||||
|
category = "图像"
|
||||||
|
}
|
||||||
|
items[index] = application.CatalogListItem{
|
||||||
|
ID: fmt.Sprintf("app-%03d", index),
|
||||||
|
Name: fmt.Sprintf("App %03d", index),
|
||||||
|
Version: "1.0.0",
|
||||||
|
Category: category,
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
Installable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
|
||||||
|
var operations op.Ops
|
||||||
|
var router input.Router
|
||||||
|
context := layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Source: router.Source(),
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
shell.Layout(context, NewTheme())
|
||||||
|
router.Frame(&operations)
|
||||||
|
return router.AppendSemantics(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Desc.Label == want || node.Desc.Description == want ||
|
||||||
|
adapterContractHasSemantic(node.Children, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func adapterContractIDs(items []application.CatalogListItem) []string {
|
||||||
|
ids := make([]string, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
ids[index] = item.ID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import "softbox.local/core/application"
|
||||||
|
|
||||||
|
type catalogPresentationState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
catalogStateLoading catalogPresentationState = "loading"
|
||||||
|
catalogStateReady catalogPresentationState = "ready"
|
||||||
|
catalogStateUnconfigured catalogPresentationState = "unconfigured"
|
||||||
|
catalogStateLoadFailed catalogPresentationState = "load_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
|
||||||
|
payload, handled, err := application.ParseCatalogEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return handled, err
|
||||||
|
}
|
||||||
|
switch payload.Type {
|
||||||
|
case application.EventCatalogRefreshed:
|
||||||
|
shell.SetItems(payload.Items)
|
||||||
|
case application.EventCatalogRejected:
|
||||||
|
switch payload.FailureCode {
|
||||||
|
case application.CatalogFailureSourceUnconfigured:
|
||||||
|
shell.catalogState = catalogStateUnconfigured
|
||||||
|
case application.CatalogFailureLoadFailed:
|
||||||
|
shell.catalogState = catalogStateLoadFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) catalogStatusText() string {
|
||||||
|
switch shell.catalogState {
|
||||||
|
case catalogStateLoading:
|
||||||
|
return "目录状态:正在加载已验证 Catalog"
|
||||||
|
case catalogStateUnconfigured:
|
||||||
|
return "目录状态:Catalog 来源尚未配置"
|
||||||
|
case catalogStateLoadFailed:
|
||||||
|
return "目录状态:Catalog 加载失败"
|
||||||
|
case catalogStateReady:
|
||||||
|
return "目录状态:已加载已验证 Catalog"
|
||||||
|
default:
|
||||||
|
return "目录状态:未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
|
||||||
|
shell := NewAppShell("Legacy")
|
||||||
|
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRefreshed,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(refresh) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
|
||||||
|
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(reject) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
|
||||||
|
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
|
||||||
|
shell := NewAppShell("Legacy")
|
||||||
|
if shell.catalogState != catalogStateLoading {
|
||||||
|
t.Fatalf("initial state = %q, want loading", shell.catalogState)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
|
||||||
|
}
|
||||||
|
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
|
||||||
|
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
|
||||||
|
t.Fatal("unconfigured state was not visible")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRejected,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(load failed) error = %v", err)
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
|
||||||
|
t.Fatal("load-failed state was not visible")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventCatalogRefreshed,
|
||||||
|
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
|
||||||
|
}
|
||||||
|
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
|
||||||
|
t.Fatal("loaded-empty state was not visible")
|
||||||
|
}
|
||||||
|
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
|
||||||
|
if !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("bad payload error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package gio contains the Win7-compatible Gio UI adapter.
|
||||||
|
//
|
||||||
|
// Layout code is rendering-only: it must not read files, access the network,
|
||||||
|
// calculate hashes, or directly mutate background application state.
|
||||||
|
package gio
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
if handled, err := shell.applyAuthorizationEvent(event); err != nil || handled {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
iconEvent, handled, err := application.ParseIconEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
identity := iconEvent.Identity
|
||||||
|
pending, exists := shell.iconRequests[identity.AppID]
|
||||||
|
if !exists || pending != identity {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if shell.iconReferences[identity.AppID] != identity.Reference {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(shell.iconRequests, identity.AppID)
|
||||||
|
switch iconEvent.Type {
|
||||||
|
case application.EventIconReady:
|
||||||
|
shell.ApplyIcon(identity.AppID, iconEvent.Image)
|
||||||
|
shell.iconApplied[identity.AppID] = identity
|
||||||
|
case application.EventIconFailed:
|
||||||
|
applied, hasApplied := shell.iconApplied[identity.AppID]
|
||||||
|
if !hasApplied || !sameIconResource(applied, identity) {
|
||||||
|
shell.ApplyIcon(identity.AppID, nil)
|
||||||
|
}
|
||||||
|
shell.iconFailures[identity.AppID] = iconFailureState{
|
||||||
|
Identity: identity,
|
||||||
|
Code: iconEvent.ErrorCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconFailure exposes the last failure for diagnostics without raw network data.
|
||||||
|
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
|
||||||
|
failure, exists := shell.iconFailures[appID]
|
||||||
|
return failure.Code, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalIconReference(reference string) string {
|
||||||
|
canonical, err := application.NormalizeIconReference(reference)
|
||||||
|
if err != nil {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameIconResource(
|
||||||
|
left application.IconEventIdentity,
|
||||||
|
right application.IconEventIdentity,
|
||||||
|
) bool {
|
||||||
|
return left.AppID == right.AppID &&
|
||||||
|
left.Reference == right.Reference &&
|
||||||
|
left.DPI == right.DPI
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
|
||||||
|
reference := testIconReference("11")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: reference,
|
||||||
|
})
|
||||||
|
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(identity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ready, err := application.NewIconReadyEvent(
|
||||||
|
identity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relay, err := application.NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
submitted := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
submitted <- relay.Submit(context.Background(), ready)
|
||||||
|
}()
|
||||||
|
if err := waitIconSubmit(submitted); err != nil {
|
||||||
|
t.Fatalf("Submit() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("background relay changed shell before UI drain")
|
||||||
|
}
|
||||||
|
if err := relay.Drain(shell.ApplyEvent); err != nil {
|
||||||
|
t.Fatalf("Drain() error = %v", err)
|
||||||
|
}
|
||||||
|
icon, exists := shell.icons["app-one"]
|
||||||
|
if !exists || icon.Size() != image.Pt(24, 24) {
|
||||||
|
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
|
||||||
|
reference := testIconReference("22")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: reference,
|
||||||
|
})
|
||||||
|
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
|
||||||
|
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(oldIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ExpectIcon(newIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldReady, err := application.NewIconReadyEvent(
|
||||||
|
oldIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("stale request inserted an icon")
|
||||||
|
}
|
||||||
|
|
||||||
|
newReady, err := application.NewIconReadyEvent(
|
||||||
|
newIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
|
||||||
|
t.Fatalf("latest icon size = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
|
||||||
|
if err := shell.ExpectIcon(retryIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; !exists {
|
||||||
|
t.Fatal("same-resource retry discarded an already valid icon")
|
||||||
|
}
|
||||||
|
failed, err := application.NewIconFailedEvent(
|
||||||
|
retryIdentity,
|
||||||
|
application.IconFailureUnavailable,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(failed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; !exists {
|
||||||
|
t.Fatal("matching failure discarded an already valid icon")
|
||||||
|
}
|
||||||
|
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
|
||||||
|
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
|
||||||
|
if err := shell.ExpectIcon(dpiIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("different-DPI request retained an unmatching image")
|
||||||
|
}
|
||||||
|
dpiFailed, err := application.NewIconFailedEvent(
|
||||||
|
dpiIdentity,
|
||||||
|
application.IconFailureUnavailable,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(dpiFailed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("different-DPI failure restored an unmatching image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
|
||||||
|
oldReference := testIconReference("33")
|
||||||
|
newReference := testIconReference("44")
|
||||||
|
shell := NewAppShell("Test", application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: oldReference,
|
||||||
|
})
|
||||||
|
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
|
||||||
|
if err := shell.ExpectIcon(oldIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldReady, err := application.NewIconReadyEvent(
|
||||||
|
oldIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.SetItems([]application.CatalogListItem{{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
IconRef: newReference,
|
||||||
|
}})
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("IconRef change retained the previous image")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(oldReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("old IconRef result was reinserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(newIdentity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
|
||||||
|
t.Fatal("CancelIconRequest() did not cancel the latest request")
|
||||||
|
}
|
||||||
|
newReady, err := application.NewIconReadyEvent(
|
||||||
|
newIdentity,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("canceled result was applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems(nil)
|
||||||
|
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
|
||||||
|
t.Fatalf("ExpectIcon(removed app) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(newReady); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("removed app was reinserted by a late result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
|
||||||
|
shell := NewAppShell("Test")
|
||||||
|
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
|
||||||
|
}
|
||||||
|
err := shell.ApplyEvent(application.Event{
|
||||||
|
Type: application.EventIconReady,
|
||||||
|
RequestID: "request",
|
||||||
|
AppID: "app-one",
|
||||||
|
Payload: "wrong",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, application.ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(shell.icons) != 0 {
|
||||||
|
t.Fatal("invalid payload polluted icon state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
|
||||||
|
reference := testIconReference("55")
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
Version: "1.0.0",
|
||||||
|
IconRef: reference,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Test", item)
|
||||||
|
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(identity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
|
||||||
|
shell.model.Select(item.ID)
|
||||||
|
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
failure := shell.iconFailures[item.ID]
|
||||||
|
for _, want := range []string{
|
||||||
|
"图标缓存安全警告",
|
||||||
|
unsafeIconCacheMessage,
|
||||||
|
unsafeIconCacheDiagnostic(failure),
|
||||||
|
} {
|
||||||
|
if !adapterContractHasSemantic(nodes, want) {
|
||||||
|
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
|
||||||
|
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(unavailable); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
|
||||||
|
nodes = adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
|
||||||
|
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
|
||||||
|
}
|
||||||
|
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||||
|
code != application.IconFailureUnavailable {
|
||||||
|
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
|
||||||
|
reference := testIconReference("66")
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "app-one",
|
||||||
|
Name: "One",
|
||||||
|
Version: "1.0.0",
|
||||||
|
IconRef: reference,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Test", item)
|
||||||
|
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
|
||||||
|
if err := shell.ExpectIcon(first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
firstFailure := applyTestIconFailure(
|
||||||
|
t,
|
||||||
|
shell,
|
||||||
|
first,
|
||||||
|
application.IconFailureUnsafe,
|
||||||
|
)
|
||||||
|
if got := shell.iconFailures[item.ID].Identity; got != first {
|
||||||
|
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{item})
|
||||||
|
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||||
|
code != application.IconFailureUnsafe {
|
||||||
|
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
|
||||||
|
if err := shell.ExpectIcon(latest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("new request retained the previous unsafe diagnostic")
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(firstFailure); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("late failure restored a stale diagnostic")
|
||||||
|
}
|
||||||
|
ready, err := application.NewIconReadyEvent(
|
||||||
|
latest,
|
||||||
|
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(ready); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("ready event retained an unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
|
||||||
|
if err := shell.ExpectIcon(dpiRequest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
|
||||||
|
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
|
||||||
|
t.Fatalf("stored failure DPI = %d, want 144", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
newReference := testIconReference("77")
|
||||||
|
changed := item
|
||||||
|
changed.IconRef = newReference
|
||||||
|
shell.SetItems([]application.CatalogListItem{changed})
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("IconRef change retained the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(canceled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
|
||||||
|
t.Fatal("CancelIconRequest() did not cancel the current request")
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("canceled failure created an unsafe diagnostic")
|
||||||
|
}
|
||||||
|
|
||||||
|
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
|
||||||
|
if err := shell.ExpectIcon(final); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
|
||||||
|
shell.SetItems(nil)
|
||||||
|
if _, exists := shell.IconFailure(item.ID); exists {
|
||||||
|
t.Fatal("removed app retained the unsafe diagnostic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTestIconFailure(
|
||||||
|
t *testing.T,
|
||||||
|
shell *AppShell,
|
||||||
|
identity application.IconEventIdentity,
|
||||||
|
code application.IconFailureCode,
|
||||||
|
) application.Event {
|
||||||
|
t.Helper()
|
||||||
|
event, err := application.NewIconFailedEvent(identity, code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := shell.ApplyEvent(event); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIconReference(pair string) string {
|
||||||
|
return "sha256:" + strings.Repeat(pair, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIconIdentity(
|
||||||
|
t *testing.T,
|
||||||
|
requestID string,
|
||||||
|
appID string,
|
||||||
|
reference string,
|
||||||
|
dpi int,
|
||||||
|
) application.IconEventIdentity {
|
||||||
|
t.Helper()
|
||||||
|
identity, err := application.NewIconEventIdentity(
|
||||||
|
requestID,
|
||||||
|
appID,
|
||||||
|
reference,
|
||||||
|
dpi,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitIconSubmit(result <-chan error) error {
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
return err
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
return errors.New("timed out waiting for icon relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import "softbox.local/core/application"
|
||||||
|
|
||||||
|
func (shell *AppShell) applyAuthorizationEvent(event application.Event) (bool, error) {
|
||||||
|
payload, handled, err := application.ParseAuthorizationEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
return handled, err
|
||||||
|
}
|
||||||
|
shell.authorization = payload.Snapshot
|
||||||
|
shell.authorizationLoaded = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthorizationViewRendersSanitizedStateAndQueuesImport(t *testing.T) {
|
||||||
|
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
|
||||||
|
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||||
|
State: application.AuthorizationStateGrace,
|
||||||
|
MachineHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
Products: []application.AuthorizedProduct{{
|
||||||
|
ProductID: "product-test", Kind: application.LicenseKindNonPerpetual, RebindPolicy: "support-only",
|
||||||
|
}},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.viewLicense.Click()
|
||||||
|
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !shell.showLicense || !adapterContractHasSemantic(nodes, "授权") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "导入许可证") ||
|
||||||
|
!adapterContractHasSemantic(nodes, "product-test · 非永久许可证(License v1 未提供试用到期信息)") {
|
||||||
|
t.Fatal("authorization state did not render its accessible content")
|
||||||
|
}
|
||||||
|
shell.importLicense.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if !shell.TakeLicenseImportRequest() || shell.TakeLicenseImportRequest() {
|
||||||
|
t.Fatal("import click did not produce exactly one UI-only request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
|
||||||
|
State: application.AuthorizationStateUnconfigured, Products: []application.AuthorizedProduct{},
|
||||||
|
})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shell.importLicense.Click()
|
||||||
|
adapterContractLayout(shell, adapterContractViewport)
|
||||||
|
if shell.TakeLicenseImportRequest() {
|
||||||
|
t.Fatal("unconfigured authorization accepted an import request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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
|
||||||
|
viewLicense widget.Clickable
|
||||||
|
resetFilters widget.Clickable
|
||||||
|
closeDetail widget.Clickable
|
||||||
|
importLicense 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
|
||||||
|
authorization application.AuthorizationSnapshot
|
||||||
|
authorizationLoaded bool
|
||||||
|
showLicense bool
|
||||||
|
licenseImportRequested bool
|
||||||
|
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),
|
||||||
|
authorization: application.AuthorizationSnapshot{Products: []application.AuthorizedProduct{}},
|
||||||
|
}
|
||||||
|
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.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewAll)
|
||||||
|
}
|
||||||
|
for shell.viewInstalled.Clicked(gtx) {
|
||||||
|
shell.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewInstalled)
|
||||||
|
}
|
||||||
|
for shell.viewUpdates.Clicked(gtx) {
|
||||||
|
shell.showLicense = false
|
||||||
|
shell.model.SetView(application.CatalogViewUpdates)
|
||||||
|
}
|
||||||
|
for shell.viewLicense.Clicked(gtx) {
|
||||||
|
shell.showLicense = true
|
||||||
|
shell.model.Select("")
|
||||||
|
}
|
||||||
|
for shell.importLicense.Clicked(gtx) {
|
||||||
|
if shell.canImportLicense() {
|
||||||
|
shell.licenseImportRequested = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeLicenseImportRequest consumes one UI-only import request. Composition
|
||||||
|
// must perform any picker, file I/O and verification in a background task.
|
||||||
|
func (shell *AppShell) TakeLicenseImportRequest() bool {
|
||||||
|
if shell == nil || !shell.licenseImportRequested {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
shell.licenseImportRequested = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
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{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewLicense,
|
||||||
|
"授权",
|
||||||
|
shell.showLicense,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if shell.showLicense {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(16)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutLicensePanel(gtx, theme)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected, hasSelection := shell.model.SelectedItem()
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%d / %d 项",
|
||||||
|
len(visible),
|
||||||
|
shell.model.TotalCount(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return shell.layoutEmptyState(gtx, theme)
|
||||||
|
}
|
||||||
|
return shell.appList.Layout(gtx, len(visible), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.lastRendered++
|
||||||
|
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
width := gtx.Dp(unit.Dp(280))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return shell.layoutDetail(gtx, theme, selected)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppRow(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
|
||||||
|
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp(fmt.Sprintf(
|
||||||
|
"%s,版本 %s,状态 %s",
|
||||||
|
item.Name,
|
||||||
|
item.Version,
|
||||||
|
statusLabel(item.Status),
|
||||||
|
)).Add(gtx.Ops)
|
||||||
|
background := shellColors.muted
|
||||||
|
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||||
|
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||||
|
}
|
||||||
|
if shell.model.SelectedID() == item.ID {
|
||||||
|
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
background,
|
||||||
|
unit.Dp(5),
|
||||||
|
layout.UniformInset(unit.Dp(10)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(40),
|
||||||
|
unit.Dp(5),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.Body1(theme, item.Name).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, statusLabel(item.Status))
|
||||||
|
label.Color = statusColor(item.Status)
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppIcon(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
name string,
|
||||||
|
iconSize unit.Dp,
|
||||||
|
radius unit.Dp,
|
||||||
|
) layout.Dimensions {
|
||||||
|
size := gtx.Dp(iconSize)
|
||||||
|
gtx.Constraints.Min = image.Pt(size, size)
|
||||||
|
gtx.Constraints.Max = gtx.Constraints.Min
|
||||||
|
if icon, exists := shell.icons[appID]; exists {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(2)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return widget.Image{
|
||||||
|
Src: icon,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.Center,
|
||||||
|
}.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
letter := "S"
|
||||||
|
for _, character := range name {
|
||||||
|
letter = string(character)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.primary,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(0)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body1(theme, letter)
|
||||||
|
label.Color = shellColors.onPrimary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutEmptyState(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
title := "没有匹配的软件"
|
||||||
|
body := "清除搜索词、分类或视图筛选后重试。"
|
||||||
|
showReset := shell.model.TotalCount() > 0
|
||||||
|
if shell.model.TotalCount() == 0 {
|
||||||
|
title = "软件目录尚未加载"
|
||||||
|
body = "联网刷新或读取已验证缓存后会显示软件。"
|
||||||
|
switch shell.catalogState {
|
||||||
|
case catalogStateLoading:
|
||||||
|
title = "正在加载软件目录"
|
||||||
|
body = "正在等待已验证 Catalog 快照。"
|
||||||
|
case catalogStateUnconfigured:
|
||||||
|
title = "Catalog 来源尚未配置"
|
||||||
|
body = "此构建未装配可信发布配置,因此未显示任何软件。"
|
||||||
|
case catalogStateLoadFailed:
|
||||||
|
title = "Catalog 加载失败"
|
||||||
|
body = "未收到可验证的 Catalog;已显示的目录不会被清除。"
|
||||||
|
case catalogStateReady:
|
||||||
|
title = "Catalog 暂无可显示软件"
|
||||||
|
body = "已验证 Catalog 没有适用于当前目标的软件。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, title).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, body)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showReset {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.resetFilters,
|
||||||
|
"显示全部软件",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutDetail(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.detailRendered = true
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.muted,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.Body1(theme, "软件详情").Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.closeDetail,
|
||||||
|
"关闭",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(64),
|
||||||
|
unit.Dp(7),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, material.Body1(theme, item.Name).Layout)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%s · %s", item.ID, item.Version),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return layout.Center.Layout(gtx, label.Layout)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(gtx, theme, "状态", statusLabel(item.Status))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
"标签",
|
||||||
|
fallbackText(strings.Join(item.Tags, " · "), "无"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return detailField(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
"简介",
|
||||||
|
fallbackText(item.Description, "暂无简介"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Reason == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Tutorial == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "教程", item.Tutorial)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if item.Homepage == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return detailField(gtx, theme, "主页", item.Homepage)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, "实际安装/启动操作将在后续用例接入")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutUnsafeIconCacheFailure(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
failure, exists := shell.iconFailures[appID]
|
||||||
|
if !exists || failure.Code != application.IconFailureUnsafe ||
|
||||||
|
failure.Identity.AppID != appID {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return outlinedPanel(
|
||||||
|
gtx,
|
||||||
|
shellColors.destructive,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.UniformInset(unit.Dp(10)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
title := material.Body1(theme, "图标缓存安全警告")
|
||||||
|
title.Color = shellColors.destructive
|
||||||
|
return title.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
|
||||||
|
diagnostic.Color = shellColors.secondary
|
||||||
|
return diagnostic.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
|
||||||
|
failure.Code,
|
||||||
|
failure.Identity.AppID,
|
||||||
|
unsafeIconCacheLocator(failure.Identity),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
|
||||||
|
digest := strings.TrimPrefix(identity.Reference, "sha256:")
|
||||||
|
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailField(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
labelText string,
|
||||||
|
value string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, labelText)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(material.Caption(theme, value).Layout),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackText(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonLabel(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "deprecated":
|
||||||
|
return "软件已停止发布"
|
||||||
|
case "minimum_os":
|
||||||
|
return "Windows 版本低于最低要求"
|
||||||
|
case "architecture":
|
||||||
|
return "没有当前架构的软件包"
|
||||||
|
default:
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutHeader(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
border := shellColors.border
|
||||||
|
if gtx.Focused(&shell.search) {
|
||||||
|
border = shellColors.primary
|
||||||
|
}
|
||||||
|
return outlinedPanel(
|
||||||
|
gtx,
|
||||||
|
border,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.Inset{
|
||||||
|
Top: unit.Dp(9), Bottom: unit.Dp(9),
|
||||||
|
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
|
||||||
|
editor.TextSize = unit.Sp(14)
|
||||||
|
return editor.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
categories := append([]string{""}, shell.model.Categories()...)
|
||||||
|
height := gtx.Dp(unit.Dp(44))
|
||||||
|
gtx.Constraints.Min.Y = height
|
||||||
|
gtx.Constraints.Max.Y = height
|
||||||
|
return shell.categoryList.Layout(gtx, len(categories), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
category := categories[index]
|
||||||
|
label := category
|
||||||
|
if label == "" {
|
||||||
|
label = "全部分类"
|
||||||
|
}
|
||||||
|
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
shell.categoryControls[category],
|
||||||
|
label,
|
||||||
|
shell.model.Category() == category,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutViewButton(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
clickable *widget.Clickable,
|
||||||
|
label string,
|
||||||
|
view application.CatalogView,
|
||||||
|
) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
|
return shell.layoutFilterButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
clickable,
|
||||||
|
label,
|
||||||
|
shell.model.View() == view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutFilterButton(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
clickable *widget.Clickable,
|
||||||
|
label string,
|
||||||
|
active bool,
|
||||||
|
) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||||
|
button := material.Button(theme, clickable, label)
|
||||||
|
button.CornerRadius = unit.Dp(5)
|
||||||
|
button.Inset = layout.Inset{
|
||||||
|
Top: unit.Dp(10), Bottom: unit.Dp(10),
|
||||||
|
Left: unit.Dp(12), Right: unit.Dp(12),
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
button.Background = shellColors.primary
|
||||||
|
button.Color = shellColors.onPrimary
|
||||||
|
} else {
|
||||||
|
button.Background = shellColors.muted
|
||||||
|
button.Color = shellColors.foreground
|
||||||
|
}
|
||||||
|
return button.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gioui.org/io/semantic"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutLicensePanel(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H5(theme, "授权").Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, shell.licenseStatusText())
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if shell.authorization.MachineHash == "" {
|
||||||
|
return licenseText(gtx, theme, "机器信息:授权配置完成后显示 machine_hash")
|
||||||
|
}
|
||||||
|
return licenseText(gtx, theme, "机器信息(machine_hash):"+shell.authorization.MachineHash)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !shell.canImportLicense() {
|
||||||
|
return licenseText(gtx, theme, "许可证导入:当前构建未配置可信授权来源")
|
||||||
|
}
|
||||||
|
return shell.layoutLicenseImportButton(gtx, theme)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
|
layout.Rigid(material.H6(theme, "已授权产品").Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(shell.authorization.Products) == 0 {
|
||||||
|
return licenseText(gtx, theme, "暂无可用授权。试用功能需要服务端签发可验证的许可证,本机不会创建试用授权。")
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, licenseProductWidgets(shell.authorization.Products, theme)...)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutLicenseImportButton(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
|
||||||
|
button := material.Button(theme, &shell.importLicense, "导入许可证")
|
||||||
|
button.Background = shellColors.primary
|
||||||
|
button.Color = shellColors.onPrimary
|
||||||
|
button.Inset = layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10), Left: unit.Dp(14), Right: unit.Dp(14)}
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp("导入许可证文件,验证在后台完成").Add(gtx.Ops)
|
||||||
|
return button.Layout(gtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseProductWidgets(products []application.AuthorizedProduct, theme *material.Theme) []layout.FlexChild {
|
||||||
|
widgets := make([]layout.FlexChild, 0, len(products)*3)
|
||||||
|
for _, product := range products {
|
||||||
|
product := product
|
||||||
|
widgets = append(widgets,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, fmt.Sprintf("%s · %s", product.ProductID, licenseKindText(product.Kind)))
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return licenseText(gtx, theme, "换绑/申诉:"+product.RebindPolicy+";不会在本机修改绑定")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) canImportLicense() bool {
|
||||||
|
return shell.authorizationLoaded && shell.authorization.State != application.AuthorizationStateUnconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) licenseStatusText() string {
|
||||||
|
if !shell.authorizationLoaded {
|
||||||
|
return "授权状态:正在加载"
|
||||||
|
}
|
||||||
|
switch shell.authorization.State {
|
||||||
|
case application.AuthorizationStateReady:
|
||||||
|
return "授权状态:撤销名单有效,当前授权可用"
|
||||||
|
case application.AuthorizationStateGrace:
|
||||||
|
return "授权状态:撤销名单已过期,处于 7 天离线宽限期"
|
||||||
|
case application.AuthorizationStateNoLicense:
|
||||||
|
return "授权状态:未找到当前机器的有效许可证"
|
||||||
|
case application.AuthorizationStateRevoked:
|
||||||
|
return "授权状态:许可证已撤销,不能启动受保护软件"
|
||||||
|
case application.AuthorizationStateUnavailable:
|
||||||
|
return "授权状态:撤销验证不可用,不能启动受保护软件"
|
||||||
|
case application.AuthorizationStateImportFailed:
|
||||||
|
return "授权状态:导入失败;原许可证与授权状态未被信任地替换"
|
||||||
|
case application.AuthorizationStateUnconfigured:
|
||||||
|
return "授权状态:可信授权来源尚未配置"
|
||||||
|
default:
|
||||||
|
return "授权状态:不可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseKindText(kind application.LicenseKind) string {
|
||||||
|
if kind == application.LicenseKindPerpetual {
|
||||||
|
return "正式许可证"
|
||||||
|
}
|
||||||
|
return "非永久许可证(License v1 未提供试用到期信息)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenseText(gtx layout.Context, theme *material.Theme, value string) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, value)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var shellColors = struct {
|
||||||
|
background color.NRGBA
|
||||||
|
surface color.NRGBA
|
||||||
|
muted color.NRGBA
|
||||||
|
foreground color.NRGBA
|
||||||
|
secondary color.NRGBA
|
||||||
|
primary color.NRGBA
|
||||||
|
onPrimary color.NRGBA
|
||||||
|
border color.NRGBA
|
||||||
|
success color.NRGBA
|
||||||
|
warning color.NRGBA
|
||||||
|
destructive color.NRGBA
|
||||||
|
}{
|
||||||
|
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
|
||||||
|
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||||
|
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
|
||||||
|
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
|
||||||
|
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
|
||||||
|
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
|
||||||
|
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
|
||||||
|
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
|
||||||
|
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
|
||||||
|
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
|
||||||
|
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTheme creates the accessible palette shared by the Legacy shell.
|
||||||
|
func NewTheme() *material.Theme {
|
||||||
|
theme := material.NewTheme()
|
||||||
|
theme.Palette = material.Palette{
|
||||||
|
Bg: shellColors.background,
|
||||||
|
Fg: shellColors.foreground,
|
||||||
|
ContrastBg: shellColors.primary,
|
||||||
|
ContrastFg: shellColors.onPrimary,
|
||||||
|
}
|
||||||
|
theme.FingerSize = unit.Dp(44)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
func panel(
|
||||||
|
gtx layout.Context,
|
||||||
|
background color.NRGBA,
|
||||||
|
radius unit.Dp,
|
||||||
|
inset layout.Inset,
|
||||||
|
content layout.Widget,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Background{}.Layout(
|
||||||
|
gtx,
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
paint.FillShape(
|
||||||
|
gtx.Ops,
|
||||||
|
background,
|
||||||
|
clip.UniformRRect(
|
||||||
|
image.Rectangle{Max: gtx.Constraints.Min},
|
||||||
|
gtx.Dp(radius),
|
||||||
|
).Op(gtx.Ops),
|
||||||
|
)
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return inset.Layout(gtx, content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlinedPanel(
|
||||||
|
gtx layout.Context,
|
||||||
|
border color.NRGBA,
|
||||||
|
background color.NRGBA,
|
||||||
|
radius unit.Dp,
|
||||||
|
inset layout.Inset,
|
||||||
|
content layout.Widget,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
border,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(1)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(gtx, background, radius-unit.Dp(1), inset, content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewTitle(view application.CatalogView) string {
|
||||||
|
switch view {
|
||||||
|
case application.CatalogViewInstalled:
|
||||||
|
return "已安装软件"
|
||||||
|
case application.CatalogViewUpdates:
|
||||||
|
return "可更新软件"
|
||||||
|
default:
|
||||||
|
return "全部软件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(status domain.AppStatus) string {
|
||||||
|
switch status {
|
||||||
|
case domain.StatusQueued:
|
||||||
|
return "排队中"
|
||||||
|
case domain.StatusDownloading:
|
||||||
|
return "下载中"
|
||||||
|
case domain.StatusVerifying:
|
||||||
|
return "校验中"
|
||||||
|
case domain.StatusExtracting:
|
||||||
|
return "解压中"
|
||||||
|
case domain.StatusInstalling:
|
||||||
|
return "安装中"
|
||||||
|
case domain.StatusInstalled:
|
||||||
|
return "已安装"
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return "可更新"
|
||||||
|
case domain.StatusRunning:
|
||||||
|
return "运行中"
|
||||||
|
case domain.StatusFailed:
|
||||||
|
return "失败"
|
||||||
|
case domain.StatusRollbackPending:
|
||||||
|
return "待恢复"
|
||||||
|
case domain.StatusIncompatible:
|
||||||
|
return "不兼容"
|
||||||
|
default:
|
||||||
|
return "未安装"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusColor(status domain.AppStatus) color.NRGBA {
|
||||||
|
switch status {
|
||||||
|
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||||
|
return shellColors.destructive
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return shellColors.warning
|
||||||
|
case domain.StatusInstalled, domain.StatusRunning:
|
||||||
|
return shellColors.success
|
||||||
|
case domain.StatusIncompatible:
|
||||||
|
return shellColors.secondary
|
||||||
|
default:
|
||||||
|
return shellColors.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/unit"
|
||||||
|
|
||||||
|
"softbox.local/core/application"
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppShellFillsWindow(t *testing.T) {
|
||||||
|
var operations op.Ops
|
||||||
|
size := image.Pt(1024, 680)
|
||||||
|
context := layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
|
||||||
|
dimensions := NewAppShell("Legacy").Layout(context, NewTheme())
|
||||||
|
if dimensions.Size != size {
|
||||||
|
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
|
||||||
|
items := make([]application.CatalogListItem, 500)
|
||||||
|
for index := range items {
|
||||||
|
items[index] = application.CatalogListItem{
|
||||||
|
ID: fmt.Sprintf("app-%03d", index),
|
||||||
|
Name: fmt.Sprintf("软件 %03d", index),
|
||||||
|
Version: "1.0.0",
|
||||||
|
Category: "工具",
|
||||||
|
Tags: []string{"工具"},
|
||||||
|
Status: domain.StatusNotInstalled,
|
||||||
|
Installable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Legacy", items...)
|
||||||
|
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
|
||||||
|
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
|
||||||
|
t.Fatalf(
|
||||||
|
"lastRendered = %d, want visible subset of %d",
|
||||||
|
shell.lastRendered,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||||
|
items := []application.CatalogListItem{
|
||||||
|
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
|
||||||
|
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Legacy", items...)
|
||||||
|
original := shell.rows["app-two"]
|
||||||
|
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
|
||||||
|
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
|
||||||
|
|
||||||
|
shell.model.SetCategory("图像")
|
||||||
|
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
|
||||||
|
if shell.rows["app-two"] != original {
|
||||||
|
t.Fatal("row controls were recreated after filtering")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.SetItems([]application.CatalogListItem{
|
||||||
|
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
|
||||||
|
})
|
||||||
|
if shell.rows["app-two"] != original {
|
||||||
|
t.Fatal("row controls were recreated after snapshot update")
|
||||||
|
}
|
||||||
|
if _, exists := shell.rows["app-one"]; exists {
|
||||||
|
t.Fatal("removed app retained row controls")
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons["app-one"]; exists {
|
||||||
|
t.Fatal("removed app retained prepared icon")
|
||||||
|
}
|
||||||
|
icon, exists := shell.icons["app-two"]
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("retained app lost its prepared icon")
|
||||||
|
}
|
||||||
|
if icon.Size() != image.Pt(24, 24) {
|
||||||
|
t.Fatalf("retained app icon size = %v", icon.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "json-parser",
|
||||||
|
Name: "JSON解析工具",
|
||||||
|
Description: "格式化并检查 JSON",
|
||||||
|
Version: "1.2.0",
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"JSON", "格式化"},
|
||||||
|
Homepage: "https://example.invalid/json-parser",
|
||||||
|
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Legacy", item)
|
||||||
|
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||||
|
shell.ApplyIcon(item.ID, icon)
|
||||||
|
shell.model.Select(item.ID)
|
||||||
|
|
||||||
|
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
|
||||||
|
if !shell.detailRendered {
|
||||||
|
t.Fatal("selected app detail was not rendered")
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons[item.ID]; !exists {
|
||||||
|
t.Fatal("ApplyIcon did not retain the prepared image operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.ApplyIcon(item.ID, nil)
|
||||||
|
if _, exists := shell.icons[item.ID]; exists {
|
||||||
|
t.Fatal("ApplyIcon(nil) did not remove the image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContext(size image.Point) layout.Context {
|
||||||
|
var operations op.Ops
|
||||||
|
return layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthorizationConfig = errors.New("authorization configuration is invalid")
|
||||||
|
ErrAuthorizationSourceUnconfigured = errors.New("authorization source is unconfigured")
|
||||||
|
ErrAuthorizationUnavailable = errors.New("authorization is unavailable")
|
||||||
|
ErrAuthorizationImport = errors.New("authorization import failed")
|
||||||
|
ErrAuthorizationEventPayload = errors.New("authorization event payload is invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
var authorizationProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
|
||||||
|
// AuthorizationState is the sanitized state shown to adapters and callers.
|
||||||
|
type AuthorizationState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthorizationStateReady AuthorizationState = "ready"
|
||||||
|
AuthorizationStateGrace AuthorizationState = "grace"
|
||||||
|
AuthorizationStateNoLicense AuthorizationState = "no_license"
|
||||||
|
AuthorizationStateRevoked AuthorizationState = "revoked"
|
||||||
|
AuthorizationStateUnavailable AuthorizationState = "unavailable"
|
||||||
|
AuthorizationStateUnconfigured AuthorizationState = "unconfigured"
|
||||||
|
AuthorizationStateImportFailed AuthorizationState = "import_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseKind presents only the signed perpetual flag; it does not invent a
|
||||||
|
// trial expiration which License v1 does not carry.
|
||||||
|
type LicenseKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LicenseKindPerpetual LicenseKind = "perpetual"
|
||||||
|
LicenseKindNonPerpetual LicenseKind = "non_perpetual"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizedProduct contains no license ID, source path, signature or source
|
||||||
|
// document. The product ID is the same signed package identity used at launch.
|
||||||
|
type AuthorizedProduct struct {
|
||||||
|
ProductID string
|
||||||
|
Kind LicenseKind
|
||||||
|
RebindPolicy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationSnapshot is an IO-free UI/launcher authorization view.
|
||||||
|
type AuthorizationSnapshot struct {
|
||||||
|
State AuthorizationState
|
||||||
|
MachineHash string
|
||||||
|
Products []AuthorizedProduct
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationEvent is the typed LicenseChanged payload accepted by adapters.
|
||||||
|
type AuthorizationEvent struct {
|
||||||
|
Type EventType
|
||||||
|
Snapshot AuthorizationSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationStore is the narrow persistence boundary required by the
|
||||||
|
// authorization application service.
|
||||||
|
type AuthorizationStore interface {
|
||||||
|
Import([]byte, licensing.Verifier, string) (licensing.License, bool, error)
|
||||||
|
List(licensing.Verifier, string) ([]licensing.License, error)
|
||||||
|
StoreRevocations([]byte, licensing.RevocationVerifier) (licensing.RevocationList, error)
|
||||||
|
LoadRevocations(licensing.RevocationVerifier) (licensing.RevocationList, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseImportSource reads one user-selected document outside Gio Layout.
|
||||||
|
// Platform file dialogs and file I/O belong to its composition implementation.
|
||||||
|
type LicenseImportSource interface {
|
||||||
|
ReadLicense(context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationServiceConfig makes every trust and local dependency explicit.
|
||||||
|
type AuthorizationServiceConfig struct {
|
||||||
|
Store AuthorizationStore
|
||||||
|
LicenseVerifier licensing.Verifier
|
||||||
|
RevocationVerifier licensing.RevocationVerifier
|
||||||
|
MachineHash string
|
||||||
|
Clock func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationService revalidates cached licenses/revocations for every
|
||||||
|
// snapshot and authorization decision. It is core-only and has no Gio or
|
||||||
|
// platform imports.
|
||||||
|
type AuthorizationService struct {
|
||||||
|
store AuthorizationStore
|
||||||
|
licenseVerifier licensing.Verifier
|
||||||
|
revocationVerifier licensing.RevocationVerifier
|
||||||
|
machineHash string
|
||||||
|
clock func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthorizationService creates the configured offline authorization use
|
||||||
|
// case. Invalid verifier values fail during a use rather than falling back.
|
||||||
|
func NewAuthorizationService(config AuthorizationServiceConfig) (*AuthorizationService, error) {
|
||||||
|
if config.Store == nil || !machineHashForAuthorization(config.MachineHash) {
|
||||||
|
return nil, ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
if config.Clock == nil {
|
||||||
|
config.Clock = time.Now
|
||||||
|
}
|
||||||
|
return &AuthorizationService{
|
||||||
|
store: config.Store,
|
||||||
|
licenseVerifier: config.LicenseVerifier,
|
||||||
|
revocationVerifier: config.RevocationVerifier,
|
||||||
|
machineHash: config.MachineHash,
|
||||||
|
clock: config.Clock,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot loads a detached, sanitized authorization view.
|
||||||
|
func (service *AuthorizationService) Snapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||||
|
if service == nil || service.store == nil || service.clock == nil {
|
||||||
|
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return AuthorizationSnapshot{}, err
|
||||||
|
}
|
||||||
|
licenses, err := service.store.List(service.licenseVerifier, service.machineHash)
|
||||||
|
if err != nil {
|
||||||
|
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: cache", ErrAuthorizationUnavailable)
|
||||||
|
}
|
||||||
|
list, found, err := service.store.LoadRevocations(service.revocationVerifier)
|
||||||
|
if err != nil || !found {
|
||||||
|
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: revocation cache", ErrAuthorizationUnavailable)
|
||||||
|
}
|
||||||
|
snapshot := authorizationSnapshotFromVerified(licenses, list, service.machineHash, service.clock())
|
||||||
|
if snapshot.State == AuthorizationStateUnavailable {
|
||||||
|
return snapshot, ErrAuthorizationUnavailable
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import validates and persists one document before returning the refreshed
|
||||||
|
// authorization view. No unverified document is retained.
|
||||||
|
func (service *AuthorizationService) Import(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
|
||||||
|
if service == nil || service.store == nil {
|
||||||
|
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return AuthorizationSnapshot{}, err
|
||||||
|
}
|
||||||
|
if _, _, err := service.store.Import(document, service.licenseVerifier, service.machineHash); err != nil {
|
||||||
|
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationImport)
|
||||||
|
}
|
||||||
|
return service.Snapshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRevocations validates and persists a signed list then refreshes state.
|
||||||
|
func (service *AuthorizationService) UpdateRevocations(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
|
||||||
|
if service == nil || service.store == nil {
|
||||||
|
return AuthorizationSnapshot{}, ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return AuthorizationSnapshot{}, err
|
||||||
|
}
|
||||||
|
if _, err := service.store.StoreRevocations(document, service.revocationVerifier); err != nil {
|
||||||
|
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationUnavailable)
|
||||||
|
}
|
||||||
|
return service.Snapshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthorized implements the launch AuthorizationChecker contract for a
|
||||||
|
// package product ID. Missing/stale revocations are unavailable, never allow.
|
||||||
|
func (service *AuthorizationService) IsAuthorized(productID string) (bool, error) {
|
||||||
|
if !authorizationProductIDPattern.MatchString(productID) {
|
||||||
|
return false, ErrAuthorizationUnavailable
|
||||||
|
}
|
||||||
|
snapshot, err := service.Snapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, product := range snapshot.Products {
|
||||||
|
if product.ProductID == productID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationSnapshotFromVerified(
|
||||||
|
licenses []licensing.License,
|
||||||
|
list licensing.RevocationList,
|
||||||
|
machineHash string,
|
||||||
|
now time.Time,
|
||||||
|
) AuthorizationSnapshot {
|
||||||
|
if now.Before(list.GeneratedAt) || now.After(list.ExpiresAt.Add(licensing.RevocationGrace)) {
|
||||||
|
return unavailableAuthorizationSnapshot(machineHash)
|
||||||
|
}
|
||||||
|
products := make(map[string]AuthorizedProduct)
|
||||||
|
hasGrace := false
|
||||||
|
hasRevoked := false
|
||||||
|
for _, license := range licenses {
|
||||||
|
switch list.StateFor(license.LicenseID, now) {
|
||||||
|
case licensing.RevocationStateCurrent:
|
||||||
|
addAuthorizedProducts(products, license)
|
||||||
|
case licensing.RevocationStateGrace:
|
||||||
|
hasGrace = true
|
||||||
|
addAuthorizedProducts(products, license)
|
||||||
|
case licensing.RevocationStateRevoked:
|
||||||
|
hasRevoked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(products) == 0 {
|
||||||
|
state := AuthorizationStateNoLicense
|
||||||
|
if hasRevoked {
|
||||||
|
state = AuthorizationStateRevoked
|
||||||
|
}
|
||||||
|
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: []AuthorizedProduct{}}
|
||||||
|
}
|
||||||
|
ordered := make([]AuthorizedProduct, 0, len(products))
|
||||||
|
for _, product := range products {
|
||||||
|
ordered = append(ordered, product)
|
||||||
|
}
|
||||||
|
sort.Slice(ordered, func(left, right int) bool {
|
||||||
|
return ordered[left].ProductID < ordered[right].ProductID
|
||||||
|
})
|
||||||
|
state := AuthorizationStateReady
|
||||||
|
if hasGrace {
|
||||||
|
state = AuthorizationStateGrace
|
||||||
|
}
|
||||||
|
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: ordered}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAuthorizedProducts(products map[string]AuthorizedProduct, license licensing.License) {
|
||||||
|
kind := LicenseKindNonPerpetual
|
||||||
|
if license.Perpetual {
|
||||||
|
kind = LicenseKindPerpetual
|
||||||
|
}
|
||||||
|
for _, productID := range license.Products {
|
||||||
|
candidate := AuthorizedProduct{ProductID: productID, Kind: kind, RebindPolicy: license.RebindPolicy}
|
||||||
|
if existing, exists := products[productID]; !exists ||
|
||||||
|
(existing.Kind == LicenseKindNonPerpetual && candidate.Kind == LicenseKindPerpetual) {
|
||||||
|
products[productID] = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailableAuthorizationSnapshot(machineHash string) AuthorizationSnapshot {
|
||||||
|
return AuthorizationSnapshot{
|
||||||
|
State: AuthorizationStateUnavailable,
|
||||||
|
MachineHash: machineHash,
|
||||||
|
Products: []AuthorizedProduct{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func machineHashForAuthorization(value string) bool {
|
||||||
|
if len(value) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if !(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationSnapshotLoader supplies one background-loaded authorization
|
||||||
|
// state. It permits the explicit unconfigured default without test-key fallback.
|
||||||
|
type AuthorizationSnapshotLoader interface {
|
||||||
|
LoadAuthorizationSnapshot(context.Context) (AuthorizationSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAuthorizationSnapshot adapts AuthorizationService for bootstrap use.
|
||||||
|
func (service *AuthorizationService) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||||
|
return service.Snapshot(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnconfiguredAuthorizationLoader is the safe default until release
|
||||||
|
// composition supplies a trusted key, machine hash and revocation source.
|
||||||
|
type UnconfiguredAuthorizationLoader struct{}
|
||||||
|
|
||||||
|
func (UnconfiguredAuthorizationLoader) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return AuthorizationSnapshot{}, err
|
||||||
|
}
|
||||||
|
return AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}, ErrAuthorizationSourceUnconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizationBootstrap publishes exactly one sanitized startup state.
|
||||||
|
type AuthorizationBootstrap struct {
|
||||||
|
loader AuthorizationSnapshotLoader
|
||||||
|
publisher EventPublisher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthorizationBootstrap(loader AuthorizationSnapshotLoader, publisher EventPublisher) *AuthorizationBootstrap {
|
||||||
|
return &AuthorizationBootstrap{loader: loader, publisher: publisher}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bootstrap *AuthorizationBootstrap) Run(ctx context.Context) error {
|
||||||
|
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||||
|
return ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
snapshot, err := bootstrap.loader.LoadAuthorizationSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAuthorizationSourceUnconfigured) {
|
||||||
|
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}
|
||||||
|
} else if !validAuthorizationSnapshot(snapshot) {
|
||||||
|
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnavailable, Products: []AuthorizedProduct{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishErr := bootstrap.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
|
||||||
|
if publishErr != nil {
|
||||||
|
return errors.Join(err, publishErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseImport publishes a refreshed authorization snapshot from one
|
||||||
|
// background-only source read.
|
||||||
|
type LicenseImport struct {
|
||||||
|
service *AuthorizationService
|
||||||
|
source LicenseImportSource
|
||||||
|
publisher EventPublisher
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLicenseImport(service *AuthorizationService, source LicenseImportSource, publisher EventPublisher) *LicenseImport {
|
||||||
|
return &LicenseImport{service: service, source: source, publisher: publisher}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (useCase *LicenseImport) Run(ctx context.Context) error {
|
||||||
|
if useCase == nil || useCase.service == nil || useCase.source == nil || useCase.publisher == nil {
|
||||||
|
return ErrAuthorizationConfig
|
||||||
|
}
|
||||||
|
document, err := useCase.source.ReadLicense(ctx)
|
||||||
|
if err == nil {
|
||||||
|
_, err = useCase.service.Import(ctx, document)
|
||||||
|
}
|
||||||
|
snapshot, snapshotErr := useCase.service.Snapshot(ctx)
|
||||||
|
if snapshotErr != nil || err != nil {
|
||||||
|
snapshot = AuthorizationSnapshot{State: AuthorizationStateImportFailed, MachineHash: useCase.service.machineHash, Products: []AuthorizedProduct{}}
|
||||||
|
}
|
||||||
|
publishErr := useCase.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
|
||||||
|
return errors.Join(err, snapshotErr, publishErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthorizationEvent deep-copies a validated snapshot into LicenseChanged.
|
||||||
|
func NewAuthorizationEvent(snapshot AuthorizationSnapshot) Event {
|
||||||
|
return Event{Type: EventLicenseChanged, Payload: AuthorizationEvent{
|
||||||
|
Type: EventLicenseChanged,
|
||||||
|
Snapshot: cloneAuthorizationSnapshot(snapshot),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAuthorizationEvent validates and deep-copies a LicenseChanged payload.
|
||||||
|
func ParseAuthorizationEvent(event Event) (AuthorizationEvent, bool, error) {
|
||||||
|
if event.Type != EventLicenseChanged {
|
||||||
|
return AuthorizationEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload, ok := event.Payload.(AuthorizationEvent)
|
||||||
|
if !ok || payload.Type != EventLicenseChanged || event.RequestID != "" || event.AppID != "" ||
|
||||||
|
!validAuthorizationSnapshot(payload.Snapshot) {
|
||||||
|
return AuthorizationEvent{}, true, ErrAuthorizationEventPayload
|
||||||
|
}
|
||||||
|
payload.Snapshot = cloneAuthorizationSnapshot(payload.Snapshot)
|
||||||
|
return payload, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAuthorizationSnapshot(snapshot AuthorizationSnapshot) AuthorizationSnapshot {
|
||||||
|
products := make([]AuthorizedProduct, len(snapshot.Products))
|
||||||
|
copy(products, snapshot.Products)
|
||||||
|
snapshot.Products = products
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAuthorizationSnapshot(snapshot AuthorizationSnapshot) bool {
|
||||||
|
if !snapshot.State.valid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if snapshot.State == AuthorizationStateUnconfigured && snapshot.MachineHash != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if snapshot.State != AuthorizationStateUnconfigured && !machineHashForAuthorization(snapshot.MachineHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if snapshot.Products == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(snapshot.Products))
|
||||||
|
for _, product := range snapshot.Products {
|
||||||
|
if !authorizationProductIDPattern.MatchString(product.ProductID) ||
|
||||||
|
(product.Kind != LicenseKindPerpetual && product.Kind != LicenseKindNonPerpetual) ||
|
||||||
|
product.RebindPolicy == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, exists := seen[product.ProductID]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[product.ProductID] = struct{}{}
|
||||||
|
}
|
||||||
|
if (snapshot.State == AuthorizationStateReady || snapshot.State == AuthorizationStateGrace) && len(snapshot.Products) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace && len(snapshot.Products) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state AuthorizationState) valid() bool {
|
||||||
|
return state == AuthorizationStateReady || state == AuthorizationStateGrace ||
|
||||||
|
state == AuthorizationStateNoLicense || state == AuthorizationStateRevoked ||
|
||||||
|
state == AuthorizationStateUnavailable || state == AuthorizationStateUnconfigured ||
|
||||||
|
state == AuthorizationStateImportFailed
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/licensing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const applicationTestMachineHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func TestAuthorizationServiceUsesCurrentGraceAndRevocationStates(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
store := &authorizationStoreFake{
|
||||||
|
licenses: []licensing.License{{
|
||||||
|
LicenseID: "lic-active", Products: []string{"product-active"}, Perpetual: true, RebindPolicy: "support-only",
|
||||||
|
}, {
|
||||||
|
LicenseID: "lic-revoked", Products: []string{"product-revoked"}, RebindPolicy: "support-only",
|
||||||
|
}},
|
||||||
|
revocations: licensing.RevocationList{
|
||||||
|
GeneratedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(time.Hour), RevokedLicenseIDs: []string{"lic-revoked"},
|
||||||
|
},
|
||||||
|
found: true,
|
||||||
|
}
|
||||||
|
service := newAuthorizationServiceForTest(t, store, now)
|
||||||
|
snapshot, err := service.Snapshot(context.Background())
|
||||||
|
if err != nil || snapshot.State != AuthorizationStateReady || len(snapshot.Products) != 1 || snapshot.Products[0].ProductID != "product-active" {
|
||||||
|
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
|
||||||
|
}
|
||||||
|
if authorized, err := service.IsAuthorized("product-active"); err != nil || !authorized {
|
||||||
|
t.Fatalf("IsAuthorized(active) = (%t, %v)", authorized, err)
|
||||||
|
}
|
||||||
|
if authorized, err := service.IsAuthorized("product-revoked"); err != nil || authorized {
|
||||||
|
t.Fatalf("IsAuthorized(revoked) = (%t, %v)", authorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.revocations.ExpiresAt = now.Add(-time.Hour)
|
||||||
|
snapshot, err = service.Snapshot(context.Background())
|
||||||
|
if err != nil || snapshot.State != AuthorizationStateGrace {
|
||||||
|
t.Fatalf("Snapshot(grace) = (%#v, %v)", snapshot, err)
|
||||||
|
}
|
||||||
|
store.revocations.ExpiresAt = now.Add(-licensing.RevocationGrace - time.Second)
|
||||||
|
snapshot, err = service.Snapshot(context.Background())
|
||||||
|
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
|
||||||
|
t.Fatalf("Snapshot(stale) = (%#v, %v)", snapshot, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizationServiceFailsClosedWithoutRevocations(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
store := &authorizationStoreFake{licenses: []licensing.License{{
|
||||||
|
LicenseID: "lic-active", Products: []string{"product-active"}, RebindPolicy: "support-only",
|
||||||
|
}}}
|
||||||
|
service := newAuthorizationServiceForTest(t, store, now)
|
||||||
|
snapshot, err := service.Snapshot(context.Background())
|
||||||
|
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
|
||||||
|
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
|
||||||
|
}
|
||||||
|
if authorized, err := service.IsAuthorized("product-active"); authorized || !errors.Is(err, ErrAuthorizationUnavailable) {
|
||||||
|
t.Fatalf("IsAuthorized() = (%t, %v)", authorized, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizationEventsAndBootstrapAreSanitized(t *testing.T) {
|
||||||
|
snapshot := AuthorizationSnapshot{
|
||||||
|
State: AuthorizationStateReady, MachineHash: applicationTestMachineHash,
|
||||||
|
Products: []AuthorizedProduct{{ProductID: "product-test", Kind: LicenseKindPerpetual, RebindPolicy: "support-only"}},
|
||||||
|
}
|
||||||
|
event := NewAuthorizationEvent(snapshot)
|
||||||
|
snapshot.Products[0].ProductID = "mutated"
|
||||||
|
parsed, handled, err := ParseAuthorizationEvent(event)
|
||||||
|
if err != nil || !handled || parsed.Snapshot.Products[0].ProductID != "product-test" {
|
||||||
|
t.Fatalf("ParseAuthorizationEvent() = (%#v, %t, %v)", parsed, handled, err)
|
||||||
|
}
|
||||||
|
if _, _, err := ParseAuthorizationEvent(Event{Type: EventLicenseChanged, Payload: "raw license"}); !errors.Is(err, ErrAuthorizationEventPayload) {
|
||||||
|
t.Fatalf("ParseAuthorizationEvent(bad) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
err = NewAuthorizationBootstrap(UnconfiguredAuthorizationLoader{}, runtime).Run(context.Background())
|
||||||
|
if !errors.Is(err, ErrAuthorizationSourceUnconfigured) {
|
||||||
|
t.Fatalf("Bootstrap.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
event = <-runtime.Events()
|
||||||
|
payload, handled, err := ParseAuthorizationEvent(event)
|
||||||
|
if err != nil || !handled || payload.Snapshot.State != AuthorizationStateUnconfigured {
|
||||||
|
t.Fatalf("unconfigured event = (%#v, %t, %v)", payload, handled, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseImportPublishesSanitizedFailure(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
service := newAuthorizationServiceForTest(t, &authorizationStoreFake{}, now)
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
err := NewLicenseImport(service, licenseSourceFake{document: []byte(`{"secret":"never publish"}`)}, runtime).Run(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
payload, handled, parseErr := ParseAuthorizationEvent(<-runtime.Events())
|
||||||
|
if parseErr != nil || !handled || payload.Snapshot.State != AuthorizationStateImportFailed ||
|
||||||
|
payload.Snapshot.MachineHash != applicationTestMachineHash || len(payload.Snapshot.Products) != 0 {
|
||||||
|
t.Fatalf("import event = (%#v, %t, %v)", payload, handled, parseErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthorizationServiceForTest(t *testing.T, store *authorizationStoreFake, now time.Time) *AuthorizationService {
|
||||||
|
t.Helper()
|
||||||
|
service, err := NewAuthorizationService(AuthorizationServiceConfig{
|
||||||
|
Store: store, MachineHash: applicationTestMachineHash, Clock: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
type authorizationStoreFake struct {
|
||||||
|
licenses []licensing.License
|
||||||
|
revocations licensing.RevocationList
|
||||||
|
found bool
|
||||||
|
listErr error
|
||||||
|
revokedErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type licenseSourceFake struct {
|
||||||
|
document []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source licenseSourceFake) ReadLicense(context.Context) ([]byte, error) {
|
||||||
|
return append([]byte(nil), source.document...), source.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *authorizationStoreFake) Import(_ []byte, _ licensing.Verifier, _ string) (licensing.License, bool, error) {
|
||||||
|
return licensing.License{}, false, errors.New("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *authorizationStoreFake) List(_ licensing.Verifier, _ string) ([]licensing.License, error) {
|
||||||
|
if store.listErr != nil {
|
||||||
|
return nil, store.listErr
|
||||||
|
}
|
||||||
|
licenses := append([]licensing.License(nil), store.licenses...)
|
||||||
|
for index := range licenses {
|
||||||
|
licenses[index].Products = append([]string(nil), licenses[index].Products...)
|
||||||
|
}
|
||||||
|
return licenses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *authorizationStoreFake) StoreRevocations(_ []byte, _ licensing.RevocationVerifier) (licensing.RevocationList, error) {
|
||||||
|
return licensing.RevocationList{}, errors.New("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *authorizationStoreFake) LoadRevocations(_ licensing.RevocationVerifier) (licensing.RevocationList, bool, error) {
|
||||||
|
if store.revokedErr != nil {
|
||||||
|
return licensing.RevocationList{}, false, store.revokedErr
|
||||||
|
}
|
||||||
|
list := store.revocations
|
||||||
|
list.RevokedLicenseIDs = append([]string(nil), list.RevokedLicenseIDs...)
|
||||||
|
return list, store.found, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrCatalogSourceUnconfigured means this build has no trusted Catalog
|
||||||
|
// endpoint/key composition. It must never be replaced with test data.
|
||||||
|
ErrCatalogSourceUnconfigured = errors.New("catalog source is unconfigured")
|
||||||
|
ErrCatalogBootstrapInvalid = errors.New("catalog bootstrap is invalid")
|
||||||
|
ErrCatalogEventPayload = errors.New("catalog event payload is invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogSource identifies the verified source that produced a snapshot.
|
||||||
|
type CatalogSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatalogSourceRemote CatalogSource = "remote"
|
||||||
|
CatalogSourceCache CatalogSource = "cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogFailureCode is the public, non-sensitive reason shown by adapters.
|
||||||
|
type CatalogFailureCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatalogFailureSourceUnconfigured CatalogFailureCode = "catalog_source_unconfigured"
|
||||||
|
CatalogFailureLoadFailed CatalogFailureCode = "catalog_load_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogSnapshot is an IO-free, verified and target-filtered list prepared
|
||||||
|
// before it crosses into the UI event boundary.
|
||||||
|
type CatalogSnapshot struct {
|
||||||
|
Items []CatalogListItem
|
||||||
|
Source CatalogSource
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogSnapshotLoader prepares an already verified in-memory snapshot. Its
|
||||||
|
// implementation belongs in composition/infrastructure, never in Gio Layout.
|
||||||
|
type CatalogSnapshotLoader interface {
|
||||||
|
LoadCatalogSnapshot(context.Context) (CatalogSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogSnapshotLoaderFunc adapts a function to CatalogSnapshotLoader.
|
||||||
|
type CatalogSnapshotLoaderFunc func(context.Context) (CatalogSnapshot, error)
|
||||||
|
|
||||||
|
func (function CatalogSnapshotLoaderFunc) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||||
|
return function(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventPublisher is the narrow runtime boundary used by CatalogBootstrap.
|
||||||
|
type EventPublisher interface {
|
||||||
|
Publish(context.Context, Event) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogEvent is the typed, sanitized payload accepted by Gio adapters.
|
||||||
|
type CatalogEvent struct {
|
||||||
|
Type EventType
|
||||||
|
Items []CatalogListItem
|
||||||
|
Source CatalogSource
|
||||||
|
FailureCode CatalogFailureCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogBootstrap invokes one loader and publishes exactly one catalog result.
|
||||||
|
// It is safe to run only in a background goroutine.
|
||||||
|
type CatalogBootstrap struct {
|
||||||
|
loader CatalogSnapshotLoader
|
||||||
|
publisher EventPublisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCatalogBootstrap creates the pure-core startup bridge.
|
||||||
|
func NewCatalogBootstrap(loader CatalogSnapshotLoader, publisher EventPublisher) *CatalogBootstrap {
|
||||||
|
return &CatalogBootstrap{loader: loader, publisher: publisher}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run publishes a sanitized success or failure event. The returned error keeps
|
||||||
|
// the original loader/publisher cause for non-UI diagnostics.
|
||||||
|
func (bootstrap *CatalogBootstrap) Run(ctx context.Context) error {
|
||||||
|
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
|
||||||
|
return ErrCatalogBootstrapInvalid
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot, err := bootstrap.loader.LoadCatalogSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return bootstrap.publishFailure(ctx, err)
|
||||||
|
}
|
||||||
|
if err := validateCatalogSnapshot(snapshot); err != nil {
|
||||||
|
return bootstrap.publishFailure(ctx, err)
|
||||||
|
}
|
||||||
|
return bootstrap.publisher.Publish(ctx, Event{
|
||||||
|
Type: EventCatalogRefreshed,
|
||||||
|
Payload: CatalogEvent{
|
||||||
|
Type: EventCatalogRefreshed,
|
||||||
|
Items: cloneCatalogItems(snapshot.Items),
|
||||||
|
Source: snapshot.Source,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bootstrap *CatalogBootstrap) publishFailure(ctx context.Context, cause error) error {
|
||||||
|
publishErr := bootstrap.publisher.Publish(ctx, Event{
|
||||||
|
Type: EventCatalogRejected,
|
||||||
|
Payload: CatalogEvent{Type: EventCatalogRejected, FailureCode: catalogFailureCode(cause)},
|
||||||
|
})
|
||||||
|
if publishErr != nil {
|
||||||
|
return errors.Join(cause, publishErr)
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCatalogEvent validates and deep-copies the payload before an adapter
|
||||||
|
// changes UI state. Non-catalog events are left for other event handlers.
|
||||||
|
func ParseCatalogEvent(event Event) (CatalogEvent, bool, error) {
|
||||||
|
if event.Type != EventCatalogRefreshed && event.Type != EventCatalogRejected {
|
||||||
|
return CatalogEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload, ok := event.Payload.(CatalogEvent)
|
||||||
|
if !ok || payload.Type != event.Type || event.RequestID != "" || event.AppID != "" {
|
||||||
|
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||||
|
}
|
||||||
|
switch payload.Type {
|
||||||
|
case EventCatalogRefreshed:
|
||||||
|
if payload.FailureCode != "" || validateCatalogSnapshot(CatalogSnapshot{Items: payload.Items, Source: payload.Source}) != nil {
|
||||||
|
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||||
|
}
|
||||||
|
payload.Items = cloneCatalogItems(payload.Items)
|
||||||
|
return payload, true, nil
|
||||||
|
case EventCatalogRejected:
|
||||||
|
if len(payload.Items) != 0 || payload.Source != "" || !payload.FailureCode.valid() {
|
||||||
|
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||||
|
}
|
||||||
|
return payload, true, nil
|
||||||
|
default:
|
||||||
|
return CatalogEvent{}, true, ErrCatalogEventPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnconfiguredCatalogLoader is the fail-closed default for builds where the
|
||||||
|
// trusted endpoint and public key have not been provisioned.
|
||||||
|
type UnconfiguredCatalogLoader struct{}
|
||||||
|
|
||||||
|
func (UnconfiguredCatalogLoader) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return CatalogSnapshot{}, err
|
||||||
|
}
|
||||||
|
return CatalogSnapshot{}, ErrCatalogSourceUnconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCatalogSnapshot(snapshot CatalogSnapshot) error {
|
||||||
|
if !snapshot.Source.valid() {
|
||||||
|
return fmt.Errorf("%w: unrecognized catalog source", ErrCatalogBootstrapInvalid)
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(snapshot.Items))
|
||||||
|
for _, item := range snapshot.Items {
|
||||||
|
if item.ID == "" || item.Name == "" || item.Version == "" {
|
||||||
|
return fmt.Errorf("%w: incomplete catalog item", ErrCatalogBootstrapInvalid)
|
||||||
|
}
|
||||||
|
if _, exists := seen[item.ID]; exists {
|
||||||
|
return fmt.Errorf("%w: duplicate catalog item %q", ErrCatalogBootstrapInvalid, item.ID)
|
||||||
|
}
|
||||||
|
seen[item.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogFailureCode(err error) CatalogFailureCode {
|
||||||
|
if errors.Is(err, ErrCatalogSourceUnconfigured) {
|
||||||
|
return CatalogFailureSourceUnconfigured
|
||||||
|
}
|
||||||
|
return CatalogFailureLoadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source CatalogSource) valid() bool {
|
||||||
|
return source == CatalogSourceRemote || source == CatalogSourceCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (code CatalogFailureCode) valid() bool {
|
||||||
|
return code == CatalogFailureSourceUnconfigured || code == CatalogFailureLoadFailed
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogBootstrapPublishesIndependentVerifiedSnapshot(t *testing.T) {
|
||||||
|
items := []CatalogListItem{{
|
||||||
|
ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Tags: []string{"json"}, Status: domain.StatusNotInstalled,
|
||||||
|
}}
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||||
|
return CatalogSnapshot{Items: items, Source: CatalogSourceCache}, nil
|
||||||
|
}), runtime)
|
||||||
|
if err := bootstrap.Run(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
items[0].Name = "mutated"
|
||||||
|
items[0].Tags[0] = "mutated"
|
||||||
|
event := <-runtime.Events()
|
||||||
|
payload, handled, err := ParseCatalogEvent(event)
|
||||||
|
if err != nil || !handled {
|
||||||
|
t.Fatalf("ParseCatalogEvent() = %#v, %v, %v", payload, handled, err)
|
||||||
|
}
|
||||||
|
if payload.Source != CatalogSourceCache || payload.Items[0].Name != "JSON Tool" || payload.Items[0].Tags[0] != "json" {
|
||||||
|
t.Fatalf("payload = %#v, want independent verified snapshot", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBootstrapPublishesStableFailureCode(t *testing.T) {
|
||||||
|
loadErr := errors.New("network endpoint details must not reach UI")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
code CatalogFailureCode
|
||||||
|
}{
|
||||||
|
{name: "unconfigured", err: ErrCatalogSourceUnconfigured, code: CatalogFailureSourceUnconfigured},
|
||||||
|
{name: "load failed", err: loadErr, code: CatalogFailureLoadFailed},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||||
|
return CatalogSnapshot{}, test.err
|
||||||
|
}), runtime)
|
||||||
|
err := bootstrap.Run(context.Background())
|
||||||
|
if !errors.Is(err, test.err) {
|
||||||
|
t.Fatalf("Run() error = %v, want original loader error", err)
|
||||||
|
}
|
||||||
|
payload, handled, parseErr := ParseCatalogEvent(<-runtime.Events())
|
||||||
|
if parseErr != nil || !handled || payload.FailureCode != test.code {
|
||||||
|
t.Fatalf("failure payload = %#v, handled=%v, error=%v", payload, handled, parseErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBootstrapFailsClosedForInvalidDependenciesAndPayload(t *testing.T) {
|
||||||
|
if err := (*CatalogBootstrap)(nil).Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||||
|
t.Fatalf("nil bootstrap error = %v", err)
|
||||||
|
}
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||||
|
return CatalogSnapshot{Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}, nil
|
||||||
|
}), runtime)
|
||||||
|
if err := bootstrap.Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
|
||||||
|
t.Fatalf("invalid snapshot error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRejected, Payload: CatalogEvent{Type: EventCatalogRejected}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("invalid rejection payload error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRefreshed, Payload: CatalogEvent{Type: EventCatalogRefreshed, Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}}); !errors.Is(err, ErrCatalogEventPayload) {
|
||||||
|
t.Fatalf("invalid refresh payload error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnconfiguredCatalogLoaderHonorsCanceledContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := (UnconfiguredCatalogLoader{}).LoadCatalogSnapshot(ctx); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("LoadCatalogSnapshot() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBootstrapHonorsCancellationAndPreservesPublisherFailure(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||||
|
called = true
|
||||||
|
return CatalogSnapshot{}, nil
|
||||||
|
}), NewRuntime(1))
|
||||||
|
if err := bootstrap.Run(ctx); !errors.Is(err, context.Canceled) || called {
|
||||||
|
t.Fatalf("canceled Run() = %v, loader called=%v", err, called)
|
||||||
|
}
|
||||||
|
publishErr := errors.New("runtime publish unavailable")
|
||||||
|
loaderErr := errors.New("loader failed")
|
||||||
|
bootstrap = NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
|
||||||
|
return CatalogSnapshot{}, loaderErr
|
||||||
|
}), catalogFailPublisher{err: publishErr})
|
||||||
|
err := bootstrap.Run(context.Background())
|
||||||
|
if !errors.Is(err, loaderErr) || !errors.Is(err, publishErr) {
|
||||||
|
t.Fatalf("combined failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type catalogFailPublisher struct{ err error }
|
||||||
|
|
||||||
|
func (publisher catalogFailPublisher) Publish(context.Context, Event) error { return publisher.err }
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogView is one primary software-list scope.
|
||||||
|
type CatalogView string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatalogViewAll CatalogView = "all"
|
||||||
|
CatalogViewInstalled CatalogView = "installed"
|
||||||
|
CatalogViewUpdates CatalogView = "updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CatalogListItem is the IO-free data consumed by Gio list adapters.
|
||||||
|
type CatalogListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Version string
|
||||||
|
Category string
|
||||||
|
Tags []string
|
||||||
|
IconRef string
|
||||||
|
Homepage string
|
||||||
|
Tutorial string
|
||||||
|
Status domain.AppStatus
|
||||||
|
Installed bool
|
||||||
|
Installable bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogListModel owns source items and composable list filters.
|
||||||
|
type CatalogListModel struct {
|
||||||
|
items []CatalogListItem
|
||||||
|
visible []CatalogListItem
|
||||||
|
categories []string
|
||||||
|
query string
|
||||||
|
category string
|
||||||
|
view CatalogView
|
||||||
|
selectedID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCatalogListModel copies items and initializes the all view.
|
||||||
|
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
|
||||||
|
model := &CatalogListModel{view: CatalogViewAll}
|
||||||
|
model.SetItems(items)
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItems replaces the source snapshot and recomputes categories/visibility.
|
||||||
|
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
|
||||||
|
model.items = cloneCatalogItems(items)
|
||||||
|
model.categories = collectCategories(model.items)
|
||||||
|
if model.category != "" && !containsString(model.categories, model.category) {
|
||||||
|
model.category = ""
|
||||||
|
}
|
||||||
|
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
|
||||||
|
model.selectedID = ""
|
||||||
|
}
|
||||||
|
model.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuery applies a case-insensitive name/ID/tag search.
|
||||||
|
func (model *CatalogListModel) SetQuery(query string) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if model.query == normalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.query = normalized
|
||||||
|
model.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCategory selects one exact category. Empty means every category.
|
||||||
|
func (model *CatalogListModel) SetCategory(category string) {
|
||||||
|
if category != "" && !containsString(model.categories, category) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if model.category == category {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.category = category
|
||||||
|
model.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetView selects the all, installed or updates scope.
|
||||||
|
func (model *CatalogListModel) SetView(view CatalogView) {
|
||||||
|
if !view.Valid() || model.view == view {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.view = view
|
||||||
|
model.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select records a stable app ID for row interaction state.
|
||||||
|
func (model *CatalogListModel) Select(appID string) {
|
||||||
|
if appID == "" || containsItemID(model.items, appID) {
|
||||||
|
model.selectedID = appID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetFilters restores the full list while retaining source data.
|
||||||
|
func (model *CatalogListModel) ResetFilters() {
|
||||||
|
model.query = ""
|
||||||
|
model.category = ""
|
||||||
|
model.view = CatalogViewAll
|
||||||
|
model.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisibleItems returns the current read-only snapshot generation without copying.
|
||||||
|
// The snapshot remains stable after later model changes. Callers must not modify
|
||||||
|
// its elements, nested Tags, or capacity; CatalogListModel is single-owner and
|
||||||
|
// does not support concurrent reads and writes.
|
||||||
|
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
|
||||||
|
return model.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories returns the stable first-seen category order.
|
||||||
|
func (model *CatalogListModel) Categories() []string {
|
||||||
|
return model.categories
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalCount returns the unfiltered source count.
|
||||||
|
func (model *CatalogListModel) TotalCount() int {
|
||||||
|
return len(model.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query returns the normalized active query.
|
||||||
|
func (model *CatalogListModel) Query() string {
|
||||||
|
return model.query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category returns the active exact category, or empty for all.
|
||||||
|
func (model *CatalogListModel) Category() string {
|
||||||
|
return model.category
|
||||||
|
}
|
||||||
|
|
||||||
|
// View returns the active primary scope.
|
||||||
|
func (model *CatalogListModel) View() CatalogView {
|
||||||
|
return model.view
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedID returns the selected stable software ID.
|
||||||
|
func (model *CatalogListModel) SelectedID() string {
|
||||||
|
return model.selectedID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedItem returns the selected source item without changing filters.
|
||||||
|
func (model *CatalogListModel) SelectedItem() (CatalogListItem, bool) {
|
||||||
|
for _, item := range model.items {
|
||||||
|
if item.ID == model.selectedID {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CatalogListItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether view is supported by the MVP list.
|
||||||
|
func (view CatalogView) Valid() bool {
|
||||||
|
return view == CatalogViewAll ||
|
||||||
|
view == CatalogViewInstalled ||
|
||||||
|
view == CatalogViewUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
func (model *CatalogListModel) refilter() {
|
||||||
|
visible := make([]CatalogListItem, 0, len(model.items))
|
||||||
|
for _, item := range model.items {
|
||||||
|
if model.category != "" && item.Category != model.category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesView(item, model.view) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if model.query != "" && !matchesQuery(item, model.query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visible = append(visible, item)
|
||||||
|
}
|
||||||
|
model.visible = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||||
|
switch view {
|
||||||
|
case CatalogViewAll:
|
||||||
|
return true
|
||||||
|
case CatalogViewInstalled:
|
||||||
|
return item.Installed
|
||||||
|
case CatalogViewUpdates:
|
||||||
|
return item.Status == domain.StatusUpdateAvailable
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesQuery(item CatalogListItem, query string) bool {
|
||||||
|
if strings.Contains(strings.ToLower(item.Name), query) ||
|
||||||
|
strings.Contains(strings.ToLower(item.ID), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, tag := range item.Tags {
|
||||||
|
if strings.Contains(strings.ToLower(tag), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectCategories(items []CatalogListItem) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
categories := make([]string, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Category == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[item.Category]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Category] = struct{}{}
|
||||||
|
categories = append(categories, item.Category)
|
||||||
|
}
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
|
||||||
|
cloned := make([]CatalogListItem, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
cloned[index] = item
|
||||||
|
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, value string) bool {
|
||||||
|
for _, candidate := range values {
|
||||||
|
if candidate == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItemID(items []CatalogListItem, appID string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == appID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var visibleSnapshotSink []CatalogListItem
|
||||||
|
|
||||||
|
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
|
||||||
|
model := NewCatalogListModel([]CatalogListItem{
|
||||||
|
{
|
||||||
|
ID: "json-parser",
|
||||||
|
Name: "JSON解析工具",
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"JSON", "格式化"},
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "image-tool",
|
||||||
|
Name: "Image Tool",
|
||||||
|
Category: "图像",
|
||||||
|
Tags: []string{"PNG", "压缩"},
|
||||||
|
Status: domain.StatusUpdateAvailable,
|
||||||
|
Installed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "log-viewer",
|
||||||
|
Name: "日志查看器",
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"LOG", "诊断"},
|
||||||
|
Status: domain.StatusNotInstalled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
model.SetQuery(" png ")
|
||||||
|
assertVisibleIDs(t, model, "image-tool")
|
||||||
|
|
||||||
|
model.SetQuery("")
|
||||||
|
model.SetCategory("开发工具")
|
||||||
|
assertVisibleIDs(t, model, "json-parser", "log-viewer")
|
||||||
|
|
||||||
|
model.SetView(CatalogViewInstalled)
|
||||||
|
assertVisibleIDs(t, model, "json-parser")
|
||||||
|
|
||||||
|
model.SetCategory("")
|
||||||
|
model.SetView(CatalogViewUpdates)
|
||||||
|
assertVisibleIDs(t, model, "image-tool")
|
||||||
|
|
||||||
|
model.SetQuery("IMAGE-")
|
||||||
|
assertVisibleIDs(t, model, "image-tool")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||||
|
items := []CatalogListItem{
|
||||||
|
{ID: "app-b", Name: "B", Category: "工具"},
|
||||||
|
{ID: "app-a", Name: "A", Category: "工具"},
|
||||||
|
}
|
||||||
|
model := NewCatalogListModel(items)
|
||||||
|
model.Select("app-a")
|
||||||
|
model.SetQuery("app")
|
||||||
|
|
||||||
|
assertVisibleIDs(t, model, "app-b", "app-a")
|
||||||
|
if model.SelectedID() != "app-a" {
|
||||||
|
t.Fatalf("SelectedID = %q", model.SelectedID())
|
||||||
|
}
|
||||||
|
selected, ok := model.SelectedItem()
|
||||||
|
if !ok || selected.ID != "app-a" {
|
||||||
|
t.Fatalf("SelectedItem() = %#v, %t", selected, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
|
||||||
|
if model.SelectedID() != "" {
|
||||||
|
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
|
||||||
|
model := NewCatalogListModel([]CatalogListItem{
|
||||||
|
{ID: "one", Category: "开发"},
|
||||||
|
{ID: "two", Category: "图像"},
|
||||||
|
{ID: "three", Category: "开发"},
|
||||||
|
})
|
||||||
|
categories := model.Categories()
|
||||||
|
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
|
||||||
|
t.Fatalf("Categories = %#v", categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.SetQuery("missing")
|
||||||
|
model.SetCategory("开发")
|
||||||
|
model.SetView(CatalogViewInstalled)
|
||||||
|
model.ResetFilters()
|
||||||
|
if model.Query() != "" ||
|
||||||
|
model.Category() != "" ||
|
||||||
|
model.View() != CatalogViewAll ||
|
||||||
|
len(model.VisibleItems()) != 3 {
|
||||||
|
t.Fatalf("model was not reset: %#v", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepare func(*CatalogListModel)
|
||||||
|
mutate func(*CatalogListModel)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
mutate: func(model *CatalogListModel) { model.SetQuery("image") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "category",
|
||||||
|
mutate: func(model *CatalogListModel) { model.SetCategory("图像") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "view",
|
||||||
|
mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reset",
|
||||||
|
prepare: func(model *CatalogListModel) {
|
||||||
|
model.SetQuery("image")
|
||||||
|
},
|
||||||
|
mutate: func(model *CatalogListModel) { model.ResetFilters() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "items",
|
||||||
|
mutate: func(model *CatalogListModel) {
|
||||||
|
model.SetItems([]CatalogListItem{
|
||||||
|
{ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||||
|
if test.prepare != nil {
|
||||||
|
test.prepare(model)
|
||||||
|
}
|
||||||
|
previous := model.VisibleItems()
|
||||||
|
if len(previous) == 0 {
|
||||||
|
t.Fatal("test setup produced an empty previous generation")
|
||||||
|
}
|
||||||
|
wantPrevious := cloneSnapshotForTest(previous)
|
||||||
|
|
||||||
|
test.mutate(model)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(previous, wantPrevious) {
|
||||||
|
t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious)
|
||||||
|
}
|
||||||
|
current := model.VisibleItems()
|
||||||
|
if len(current) == 0 {
|
||||||
|
t.Fatal("test mutation produced an empty current generation")
|
||||||
|
}
|
||||||
|
if &previous[0] == ¤t[0] {
|
||||||
|
t.Fatal("current generation reused the previous backing array")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) {
|
||||||
|
model := NewCatalogListModel(catalogSnapshotFixture())
|
||||||
|
first := model.VisibleItems()
|
||||||
|
second := model.VisibleItems()
|
||||||
|
if len(first) == 0 || len(second) == 0 {
|
||||||
|
t.Fatal("test setup produced an empty generation")
|
||||||
|
}
|
||||||
|
if &first[0] != &second[0] {
|
||||||
|
t.Fatal("repeated VisibleItems calls copied the current generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
model.SetQuery(" ")
|
||||||
|
unchanged := model.VisibleItems()
|
||||||
|
if &first[0] != &unchanged[0] {
|
||||||
|
t.Fatal("no-op model update published a new generation")
|
||||||
|
}
|
||||||
|
if allocations := testing.AllocsPerRun(100, func() {
|
||||||
|
visibleSnapshotSink = model.VisibleItems()
|
||||||
|
}); allocations != 0 {
|
||||||
|
t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.SetQuery("image")
|
||||||
|
changed := model.VisibleItems()
|
||||||
|
if len(changed) == 0 {
|
||||||
|
t.Fatal("changed generation is empty")
|
||||||
|
}
|
||||||
|
if &first[0] == &changed[0] {
|
||||||
|
t.Fatal("actual model update did not publish a new generation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) {
|
||||||
|
model := NewCatalogListModel(nil)
|
||||||
|
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||||
|
t.Fatalf("empty catalog visible items = %#v", visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.SetItems(catalogSnapshotFixture())
|
||||||
|
full := model.VisibleItems()
|
||||||
|
wantFull := cloneSnapshotForTest(full)
|
||||||
|
model.SetQuery("missing-app")
|
||||||
|
if visible := model.VisibleItems(); len(visible) != 0 {
|
||||||
|
t.Fatalf("no-match visible items = %#v", visible)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(full, wantFull) {
|
||||||
|
t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.ResetFilters()
|
||||||
|
assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer")
|
||||||
|
if !reflect.DeepEqual(full, wantFull) {
|
||||||
|
t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogSnapshotFixture() []CatalogListItem {
|
||||||
|
return []CatalogListItem{
|
||||||
|
{
|
||||||
|
ID: "json-parser", Name: "JSON Parser", Category: "开发",
|
||||||
|
Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "image-tool", Name: "Image Tool", Category: "图像",
|
||||||
|
Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "log-viewer", Name: "Log Viewer", Category: "开发",
|
||||||
|
Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSnapshotForTest(items []CatalogListItem) []CatalogListItem {
|
||||||
|
cloned := make([]CatalogListItem, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
cloned[index] = item
|
||||||
|
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
visible := model.VisibleItems()
|
||||||
|
if len(visible) != len(want) {
|
||||||
|
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
|
||||||
|
}
|
||||||
|
for index, item := range visible {
|
||||||
|
if item.ID != want[index] {
|
||||||
|
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package application coordinates SoftBox use cases through injected ports.
|
||||||
|
package application
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
// DownloadStartedPayload begins one transfer attempt. A later attempt may
|
||||||
|
// reset Done when a remote entity cannot be safely resumed.
|
||||||
|
type DownloadStartedPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
Done int64
|
||||||
|
TotalKnown bool
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadProgressPayload reports monotonic progress within one attempt.
|
||||||
|
type DownloadProgressPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
Done int64
|
||||||
|
TotalKnown bool
|
||||||
|
Total int64
|
||||||
|
SpeedBytesSec int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadPausedPayload maps to domain queued while preserving pause intent.
|
||||||
|
type DownloadPausedPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
Done int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadCompletedPayload means bytes are durably downloaded but still
|
||||||
|
// untrusted. T-302 must verify the signed Catalog identity, size and hashes.
|
||||||
|
type DownloadCompletedPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
Done int64
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadFailedPayload carries a stable low-level transfer/storage code.
|
||||||
|
type DownloadFailedPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
Done int64
|
||||||
|
ErrorCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadCanceledPayload confirms cleanup and suppresses the old attempt.
|
||||||
|
type DownloadCanceledPayload struct {
|
||||||
|
Attempt uint64
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
// EventType identifies an application event consumed by UI adapters.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventCatalogRefreshed EventType = "CatalogRefreshed"
|
||||||
|
EventCatalogRejected EventType = "CatalogRejected"
|
||||||
|
EventDownloadStarted EventType = "DownloadStarted"
|
||||||
|
EventDownloadProgress EventType = "DownloadProgress"
|
||||||
|
EventDownloadPaused EventType = "DownloadPaused"
|
||||||
|
EventDownloadCompleted EventType = "DownloadCompleted"
|
||||||
|
EventDownloadFailed EventType = "DownloadFailed"
|
||||||
|
EventDownloadCanceled EventType = "DownloadCanceled"
|
||||||
|
EventInstallCompleted EventType = "InstallCompleted"
|
||||||
|
EventInstallRolledBack EventType = "InstallRolledBack"
|
||||||
|
EventAppStarted EventType = "AppStarted"
|
||||||
|
EventAppExited EventType = "AppExited"
|
||||||
|
EventLicenseChanged EventType = "LicenseChanged"
|
||||||
|
EventIconReady EventType = "IconReady"
|
||||||
|
EventIconFailed EventType = "IconFailed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validEventTypes = map[EventType]struct{}{
|
||||||
|
EventCatalogRefreshed: {},
|
||||||
|
EventCatalogRejected: {},
|
||||||
|
EventDownloadStarted: {},
|
||||||
|
EventDownloadProgress: {},
|
||||||
|
EventDownloadPaused: {},
|
||||||
|
EventDownloadCompleted: {},
|
||||||
|
EventDownloadFailed: {},
|
||||||
|
EventDownloadCanceled: {},
|
||||||
|
EventInstallCompleted: {},
|
||||||
|
EventInstallRolledBack: {},
|
||||||
|
EventAppStarted: {},
|
||||||
|
EventAppExited: {},
|
||||||
|
EventLicenseChanged: {},
|
||||||
|
EventIconReady: {},
|
||||||
|
EventIconFailed: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether eventType is part of the documented event contract.
|
||||||
|
func (eventType EventType) Valid() bool {
|
||||||
|
_, ok := validEventTypes[eventType]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is the common envelope delivered from background use cases to adapters.
|
||||||
|
//
|
||||||
|
// Payload is event-specific and will be replaced by concrete payload types as
|
||||||
|
// the corresponding use cases are implemented.
|
||||||
|
type Event struct {
|
||||||
|
Type EventType
|
||||||
|
RequestID string
|
||||||
|
AppID string
|
||||||
|
Payload any
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEventRelayClosed = errors.New("application event relay closed")
|
||||||
|
ErrEventRelayInvalid = errors.New("application event relay is invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventRelay is a bounded FIFO between background event pumps and the UI frame.
|
||||||
|
// Submit applies lossless backpressure; Drain must only run on the UI goroutine.
|
||||||
|
type EventRelay struct {
|
||||||
|
events chan Event
|
||||||
|
slots chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventRelay creates a relay with a strictly positive bounded capacity.
|
||||||
|
func NewEventRelay(capacity int) (*EventRelay, error) {
|
||||||
|
if capacity <= 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: capacity must be positive",
|
||||||
|
ErrEventRelayInvalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
relay := &EventRelay{
|
||||||
|
events: make(chan Event, capacity),
|
||||||
|
slots: make(chan struct{}, capacity),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
for index := 0; index < capacity; index++ {
|
||||||
|
relay.slots <- struct{}{}
|
||||||
|
}
|
||||||
|
return relay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit queues one event or returns when the context/relay closes.
|
||||||
|
func (relay *EventRelay) Submit(ctx context.Context, event Event) error {
|
||||||
|
if relay == nil {
|
||||||
|
return fmt.Errorf("%w: nil relay", ErrEventRelayInvalid)
|
||||||
|
}
|
||||||
|
if !event.Type.Valid() {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: unknown type %q",
|
||||||
|
ErrInvalidEvent,
|
||||||
|
event.Type,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-relay.done:
|
||||||
|
return ErrEventRelayClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-relay.slots:
|
||||||
|
}
|
||||||
|
|
||||||
|
relay.mu.Lock()
|
||||||
|
defer relay.mu.Unlock()
|
||||||
|
if relay.closed {
|
||||||
|
relay.slots <- struct{}{}
|
||||||
|
return ErrEventRelayClosed
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
relay.slots <- struct{}{}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relay.events <- event
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain applies the events present at entry without extending a UI frame forever.
|
||||||
|
func (relay *EventRelay) Drain(apply func(Event) error) error {
|
||||||
|
if relay == nil || apply == nil {
|
||||||
|
return fmt.Errorf("%w: nil relay or apply function", ErrEventRelayInvalid)
|
||||||
|
}
|
||||||
|
limit := len(relay.events)
|
||||||
|
var applyErrors []error
|
||||||
|
for index := 0; index < limit; index++ {
|
||||||
|
select {
|
||||||
|
case event := <-relay.events:
|
||||||
|
relay.slots <- struct{}{}
|
||||||
|
if err := apply(event); err != nil {
|
||||||
|
applyErrors = append(applyErrors, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.Join(applyErrors...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(applyErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close unblocks pending submissions. Queued events remain available to Drain.
|
||||||
|
func (relay *EventRelay) Close() {
|
||||||
|
if relay == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relay.closeOnce.Do(func() {
|
||||||
|
relay.mu.Lock()
|
||||||
|
relay.closed = true
|
||||||
|
close(relay.done)
|
||||||
|
relay.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PumpEvents forwards application events to a relay and requests a UI frame.
|
||||||
|
// invalidate may be called concurrently; no UI state may be mutated here.
|
||||||
|
func PumpEvents(
|
||||||
|
ctx context.Context,
|
||||||
|
events <-chan Event,
|
||||||
|
relay *EventRelay,
|
||||||
|
invalidate func(),
|
||||||
|
) error {
|
||||||
|
if events == nil || relay == nil || invalidate == nil {
|
||||||
|
return fmt.Errorf("%w: incomplete event pump", ErrEventRelayInvalid)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case event, open := <-events:
|
||||||
|
if !open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := relay.Submit(ctx, event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventRelayUsesBoundedFIFOBackpressure(t *testing.T) {
|
||||||
|
relay, err := NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
|
||||||
|
second := Event{Type: EventCatalogRejected, RequestID: "second"}
|
||||||
|
if err := relay.Submit(context.Background(), first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
started := make(chan struct{})
|
||||||
|
secondResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
close(started)
|
||||||
|
secondResult <- relay.Submit(context.Background(), second)
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
select {
|
||||||
|
case err := <-secondResult:
|
||||||
|
t.Fatalf("second Submit() completed while relay was full: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []string
|
||||||
|
if err := relay.Drain(func(event Event) error {
|
||||||
|
received = append(received, event.RequestID)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := waitRelayResult(secondResult); err != nil {
|
||||||
|
t.Fatalf("second Submit() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := relay.Drain(func(event Event) error {
|
||||||
|
received = append(received, event.RequestID)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{"first", "second"}) {
|
||||||
|
t.Fatalf("received order = %v", received)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventRelayCloseAndContextUnblockFullSubmit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unblock func(*EventRelay, context.CancelFunc)
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "close",
|
||||||
|
unblock: func(relay *EventRelay, _ context.CancelFunc) {
|
||||||
|
relay.Close()
|
||||||
|
},
|
||||||
|
wantErr: ErrEventRelayClosed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel",
|
||||||
|
unblock: func(_ *EventRelay, cancel context.CancelFunc) {
|
||||||
|
cancel()
|
||||||
|
},
|
||||||
|
wantErr: context.Canceled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
relay, err := NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := relay.Submit(
|
||||||
|
context.Background(),
|
||||||
|
Event{Type: EventCatalogRefreshed},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
started := make(chan struct{})
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
close(started)
|
||||||
|
result <- relay.Submit(ctx, Event{Type: EventCatalogRejected})
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
test.unblock(relay, cancel)
|
||||||
|
if err := waitRelayResult(result); !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Submit() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPumpEventsInvalidatesBeforeUIDrainAndStops(t *testing.T) {
|
||||||
|
relay, err := NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := make(chan Event, 2)
|
||||||
|
invalidated := make(chan struct{}, 2)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
pumpResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
pumpResult <- PumpEvents(ctx, source, relay, func() {
|
||||||
|
invalidated <- struct{}{}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
|
||||||
|
second := Event{Type: EventCatalogRejected, RequestID: "second"}
|
||||||
|
source <- first
|
||||||
|
source <- second
|
||||||
|
waitSignal(t, invalidated)
|
||||||
|
|
||||||
|
var received []string
|
||||||
|
if len(received) != 0 {
|
||||||
|
t.Fatal("background pump applied an event before UI drain")
|
||||||
|
}
|
||||||
|
if err := relay.Drain(func(event Event) error {
|
||||||
|
received = append(received, event.RequestID)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitSignal(t, invalidated)
|
||||||
|
if err := relay.Drain(func(event Event) error {
|
||||||
|
received = append(received, event.RequestID)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{"first", "second"}) {
|
||||||
|
t.Fatalf("received order = %v", received)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
if err := waitRelayResult(pumpResult); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("PumpEvents() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventRelayDrainReportsErrorsAndContinues(t *testing.T) {
|
||||||
|
relay, err := NewEventRelay(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, eventType := range []EventType{EventCatalogRefreshed, EventCatalogRejected} {
|
||||||
|
if err := relay.Submit(context.Background(), Event{Type: eventType}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantErr := errors.New("apply failed")
|
||||||
|
applied := 0
|
||||||
|
err = relay.Drain(func(Event) error {
|
||||||
|
applied++
|
||||||
|
return wantErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, wantErr) || applied != 2 {
|
||||||
|
t.Fatalf("Drain() = (%d applies, %v)", applied, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEventRelayRejectsInvalidCapacity(t *testing.T) {
|
||||||
|
if _, err := NewEventRelay(0); !errors.Is(err, ErrEventRelayInvalid) {
|
||||||
|
t.Fatalf("NewEventRelay(0) error = %v", err)
|
||||||
|
}
|
||||||
|
relay, err := NewEventRelay(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relay.Close()
|
||||||
|
if err := relay.Submit(
|
||||||
|
context.Background(),
|
||||||
|
Event{Type: EventCatalogRefreshed},
|
||||||
|
); !errors.Is(err, ErrEventRelayClosed) {
|
||||||
|
t.Fatalf("Submit(after Close) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitRelayResult(result <-chan error) error {
|
||||||
|
select {
|
||||||
|
case err := <-result:
|
||||||
|
return err
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
return errors.New("timed out waiting for relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitSignal(t *testing.T, signal <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-signal:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for signal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEventTypeValid(t *testing.T) {
|
||||||
|
eventTypes := []EventType{
|
||||||
|
EventCatalogRefreshed,
|
||||||
|
EventCatalogRejected,
|
||||||
|
EventDownloadStarted,
|
||||||
|
EventDownloadProgress,
|
||||||
|
EventDownloadPaused,
|
||||||
|
EventDownloadCompleted,
|
||||||
|
EventDownloadFailed,
|
||||||
|
EventDownloadCanceled,
|
||||||
|
EventInstallCompleted,
|
||||||
|
EventInstallRolledBack,
|
||||||
|
EventAppStarted,
|
||||||
|
EventAppExited,
|
||||||
|
EventLicenseChanged,
|
||||||
|
EventIconReady,
|
||||||
|
EventIconFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, eventType := range eventTypes {
|
||||||
|
if !eventType.Valid() {
|
||||||
|
t.Errorf("event type %q should be valid", eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if EventType("Unknown").Valid() {
|
||||||
|
t.Fatal("unknown event type should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinIconDPI = 48
|
||||||
|
MaxIconDPI = 768
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidIconEvent = errors.New("invalid icon application event")
|
||||||
|
|
||||||
|
// IconFailureCode is a stable, non-sensitive reason exposed to UI adapters.
|
||||||
|
type IconFailureCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IconFailureUnavailable IconFailureCode = "unavailable"
|
||||||
|
IconFailureInvalid IconFailureCode = "invalid_content"
|
||||||
|
IconFailureUnsafe IconFailureCode = "unsafe_cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid reports whether code is part of the documented icon event contract.
|
||||||
|
func (code IconFailureCode) Valid() bool {
|
||||||
|
switch code {
|
||||||
|
case IconFailureUnavailable, IconFailureInvalid, IconFailureUnsafe:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconEventIdentity correlates one background request with one catalog icon.
|
||||||
|
type IconEventIdentity struct {
|
||||||
|
RequestID string
|
||||||
|
AppID string
|
||||||
|
Reference string
|
||||||
|
DPI int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIconEventIdentity validates and canonicalizes an icon event identity.
|
||||||
|
func NewIconEventIdentity(
|
||||||
|
requestID string,
|
||||||
|
appID string,
|
||||||
|
reference string,
|
||||||
|
dpi int,
|
||||||
|
) (IconEventIdentity, error) {
|
||||||
|
if strings.TrimSpace(requestID) == "" {
|
||||||
|
return IconEventIdentity{}, fmt.Errorf(
|
||||||
|
"%w: empty request ID",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(appID) == "" {
|
||||||
|
return IconEventIdentity{}, fmt.Errorf(
|
||||||
|
"%w: empty app ID",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
canonicalReference, err := NormalizeIconReference(reference)
|
||||||
|
if err != nil {
|
||||||
|
return IconEventIdentity{}, err
|
||||||
|
}
|
||||||
|
if dpi < MinIconDPI || dpi > MaxIconDPI {
|
||||||
|
return IconEventIdentity{}, fmt.Errorf(
|
||||||
|
"%w: DPI %d outside %d..%d",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
dpi,
|
||||||
|
MinIconDPI,
|
||||||
|
MaxIconDPI,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return IconEventIdentity{
|
||||||
|
RequestID: requestID,
|
||||||
|
AppID: appID,
|
||||||
|
Reference: canonicalReference,
|
||||||
|
DPI: dpi,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeIconReference returns the canonical sha256:<lower-hex> form.
|
||||||
|
func NormalizeIconReference(reference string) (string, error) {
|
||||||
|
const prefix = "sha256:"
|
||||||
|
if !strings.HasPrefix(reference, prefix) {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: icon reference must use sha256",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
digest := strings.ToLower(strings.TrimPrefix(reference, prefix))
|
||||||
|
decoded, err := hex.DecodeString(digest)
|
||||||
|
if err != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: malformed icon digest",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return prefix + digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconReadyPayload contains an image decoded outside the Gio UI goroutine.
|
||||||
|
type IconReadyPayload struct {
|
||||||
|
Reference string
|
||||||
|
DPI int
|
||||||
|
Image image.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconFailedPayload contains a stable failure classification, never a raw URL.
|
||||||
|
type IconFailedPayload struct {
|
||||||
|
Reference string
|
||||||
|
DPI int
|
||||||
|
ErrorCode IconFailureCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconEvent is the validated representation consumed by UI adapters.
|
||||||
|
type IconEvent struct {
|
||||||
|
Type EventType
|
||||||
|
Identity IconEventIdentity
|
||||||
|
Image image.Image
|
||||||
|
ErrorCode IconFailureCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIconReadyEvent builds a validated ready event.
|
||||||
|
func NewIconReadyEvent(
|
||||||
|
identity IconEventIdentity,
|
||||||
|
icon image.Image,
|
||||||
|
) (Event, error) {
|
||||||
|
validated, err := NewIconEventIdentity(
|
||||||
|
identity.RequestID,
|
||||||
|
identity.AppID,
|
||||||
|
identity.Reference,
|
||||||
|
identity.DPI,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
if isNilImage(icon) {
|
||||||
|
return Event{}, fmt.Errorf("%w: nil ready image", ErrInvalidIconEvent)
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
Type: EventIconReady,
|
||||||
|
RequestID: validated.RequestID,
|
||||||
|
AppID: validated.AppID,
|
||||||
|
Payload: IconReadyPayload{
|
||||||
|
Reference: validated.Reference,
|
||||||
|
DPI: validated.DPI,
|
||||||
|
Image: icon,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIconFailedEvent builds a validated failure event.
|
||||||
|
func NewIconFailedEvent(
|
||||||
|
identity IconEventIdentity,
|
||||||
|
code IconFailureCode,
|
||||||
|
) (Event, error) {
|
||||||
|
validated, err := NewIconEventIdentity(
|
||||||
|
identity.RequestID,
|
||||||
|
identity.AppID,
|
||||||
|
identity.Reference,
|
||||||
|
identity.DPI,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
if !code.Valid() {
|
||||||
|
return Event{}, fmt.Errorf(
|
||||||
|
"%w: unknown failure code %q",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
Type: EventIconFailed,
|
||||||
|
RequestID: validated.RequestID,
|
||||||
|
AppID: validated.AppID,
|
||||||
|
Payload: IconFailedPayload{
|
||||||
|
Reference: validated.Reference,
|
||||||
|
DPI: validated.DPI,
|
||||||
|
ErrorCode: code,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIconEvent validates an icon envelope. Non-icon events return handled=false.
|
||||||
|
func ParseIconEvent(event Event) (parsed IconEvent, handled bool, err error) {
|
||||||
|
switch event.Type {
|
||||||
|
case EventIconReady:
|
||||||
|
payload, ok := event.Payload.(IconReadyPayload)
|
||||||
|
if !ok {
|
||||||
|
return IconEvent{}, true, fmt.Errorf(
|
||||||
|
"%w: ready payload has type %T",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
event.Payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
identity, identityErr := NewIconEventIdentity(
|
||||||
|
event.RequestID,
|
||||||
|
event.AppID,
|
||||||
|
payload.Reference,
|
||||||
|
payload.DPI,
|
||||||
|
)
|
||||||
|
if identityErr != nil {
|
||||||
|
return IconEvent{}, true, identityErr
|
||||||
|
}
|
||||||
|
if isNilImage(payload.Image) {
|
||||||
|
return IconEvent{}, true, fmt.Errorf(
|
||||||
|
"%w: nil ready image",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return IconEvent{
|
||||||
|
Type: event.Type,
|
||||||
|
Identity: identity,
|
||||||
|
Image: payload.Image,
|
||||||
|
}, true, nil
|
||||||
|
case EventIconFailed:
|
||||||
|
payload, ok := event.Payload.(IconFailedPayload)
|
||||||
|
if !ok {
|
||||||
|
return IconEvent{}, true, fmt.Errorf(
|
||||||
|
"%w: failed payload has type %T",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
event.Payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
identity, identityErr := NewIconEventIdentity(
|
||||||
|
event.RequestID,
|
||||||
|
event.AppID,
|
||||||
|
payload.Reference,
|
||||||
|
payload.DPI,
|
||||||
|
)
|
||||||
|
if identityErr != nil {
|
||||||
|
return IconEvent{}, true, identityErr
|
||||||
|
}
|
||||||
|
if !payload.ErrorCode.Valid() {
|
||||||
|
return IconEvent{}, true, fmt.Errorf(
|
||||||
|
"%w: unknown failure code %q",
|
||||||
|
ErrInvalidIconEvent,
|
||||||
|
payload.ErrorCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return IconEvent{
|
||||||
|
Type: event.Type,
|
||||||
|
Identity: identity,
|
||||||
|
ErrorCode: payload.ErrorCode,
|
||||||
|
}, true, nil
|
||||||
|
default:
|
||||||
|
return IconEvent{}, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNilImage(icon image.Image) bool {
|
||||||
|
if icon == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := reflect.ValueOf(icon)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
|
||||||
|
reflect.Ptr, reflect.Slice:
|
||||||
|
return value.IsNil()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIconEventRoundTrip(t *testing.T) {
|
||||||
|
reference := "sha256:" + strings.Repeat("A1", 32)
|
||||||
|
identity, err := NewIconEventIdentity("request-1", "app-one", reference, 144)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewIconEventIdentity() error = %v", err)
|
||||||
|
}
|
||||||
|
if identity.Reference != strings.ToLower(reference) {
|
||||||
|
t.Fatalf("canonical reference = %q", identity.Reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := image.NewNRGBA(image.Rect(0, 0, 24, 24))
|
||||||
|
ready, err := NewIconReadyEvent(identity, icon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewIconReadyEvent() error = %v", err)
|
||||||
|
}
|
||||||
|
parsed, handled, err := ParseIconEvent(ready)
|
||||||
|
if err != nil || !handled {
|
||||||
|
t.Fatalf("ParseIconEvent(ready) = (%+v, %t, %v)", parsed, handled, err)
|
||||||
|
}
|
||||||
|
if parsed.Type != EventIconReady || parsed.Identity != identity || parsed.Image != icon {
|
||||||
|
t.Fatalf("parsed ready event = %+v", parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, err := NewIconFailedEvent(identity, IconFailureUnsafe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewIconFailedEvent() error = %v", err)
|
||||||
|
}
|
||||||
|
parsed, handled, err = ParseIconEvent(failed)
|
||||||
|
if err != nil || !handled {
|
||||||
|
t.Fatalf("ParseIconEvent(failed) = (%+v, %t, %v)", parsed, handled, err)
|
||||||
|
}
|
||||||
|
if parsed.Type != EventIconFailed ||
|
||||||
|
parsed.Identity != identity ||
|
||||||
|
parsed.ErrorCode != IconFailureUnsafe {
|
||||||
|
t.Fatalf("parsed failed event = %+v", parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, handled, err = ParseIconEvent(Event{Type: EventCatalogRefreshed})
|
||||||
|
if err != nil || handled {
|
||||||
|
t.Fatalf("ParseIconEvent(non-icon) = (%+v, %t, %v)", parsed, handled, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconEventRejectsInvalidIdentityAndPayload(t *testing.T) {
|
||||||
|
validReference := "sha256:" + strings.Repeat("0a", 32)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requestID string
|
||||||
|
appID string
|
||||||
|
reference string
|
||||||
|
dpi int
|
||||||
|
}{
|
||||||
|
{name: "empty request", appID: "app", reference: validReference, dpi: 96},
|
||||||
|
{name: "empty app", requestID: "request", reference: validReference, dpi: 96},
|
||||||
|
{name: "bad reference", requestID: "request", appID: "app", reference: "md5:00", dpi: 96},
|
||||||
|
{name: "low DPI", requestID: "request", appID: "app", reference: validReference, dpi: 47},
|
||||||
|
{name: "high DPI", requestID: "request", appID: "app", reference: validReference, dpi: 769},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := NewIconEventIdentity(
|
||||||
|
test.requestID,
|
||||||
|
test.appID,
|
||||||
|
test.reference,
|
||||||
|
test.dpi,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("NewIconEventIdentity() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := NewIconEventIdentity("request", "app", validReference, 96)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := NewIconReadyEvent(identity, nil); !errors.Is(err, ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("NewIconReadyEvent(nil) error = %v", err)
|
||||||
|
}
|
||||||
|
var typedNil *image.NRGBA
|
||||||
|
if _, err := NewIconReadyEvent(identity, typedNil); !errors.Is(err, ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("NewIconReadyEvent(typed nil) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewIconFailedEvent(identity, IconFailureCode("raw-http-error")); !errors.Is(err, ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("NewIconFailedEvent(invalid code) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidPayloads := []Event{
|
||||||
|
{Type: EventIconReady, RequestID: "request", AppID: "app", Payload: "wrong"},
|
||||||
|
{Type: EventIconFailed, RequestID: "request", AppID: "app", Payload: "wrong"},
|
||||||
|
{
|
||||||
|
Type: EventIconReady,
|
||||||
|
RequestID: "request",
|
||||||
|
AppID: "app",
|
||||||
|
Payload: IconReadyPayload{
|
||||||
|
Reference: validReference,
|
||||||
|
DPI: 96,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: EventIconFailed,
|
||||||
|
RequestID: "request",
|
||||||
|
AppID: "app",
|
||||||
|
Payload: IconFailedPayload{
|
||||||
|
Reference: validReference,
|
||||||
|
DPI: 96,
|
||||||
|
ErrorCode: IconFailureCode("raw"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, event := range invalidPayloads {
|
||||||
|
_, handled, err := ParseIconEvent(event)
|
||||||
|
if !handled || !errors.Is(err, ErrInvalidIconEvent) {
|
||||||
|
t.Fatalf("ParseIconEvent(%+v) handled=%t error=%v", event, handled, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
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.ProductID = extracted.ProductID
|
||||||
|
record.SupportsTrial = extracted.SupportsTrial
|
||||||
|
record.RequiresAdmin = expectation.App.RequiresAdmin
|
||||||
|
|
||||||
|
var recordWriteErr error
|
||||||
|
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
|
||||||
|
if err := service.health(currentPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := service.records.Write(record); err != nil {
|
||||||
|
recordWriteErr = err
|
||||||
|
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
|
||||||
|
if err := switcher.Switch(appRoot); err != nil {
|
||||||
|
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
||||||
|
}
|
||||||
|
return InstallResult{
|
||||||
|
AppID: record.ID,
|
||||||
|
Version: record.Version,
|
||||||
|
EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint),
|
||||||
|
Recovery: recovery,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *InstallService) preSwitchCheck(
|
||||||
|
appRoot string,
|
||||||
|
appID string,
|
||||||
|
entrypoint string,
|
||||||
|
) installer.PreSwitchCheck {
|
||||||
|
return func() error {
|
||||||
|
running, err := service.targetState.IsRunning(
|
||||||
|
appID,
|
||||||
|
filepath.Join(appRoot, "current", entrypoint),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return ErrTargetRunning
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *InstallService) preExtractCheck(
|
||||||
|
appRoot string,
|
||||||
|
appID string,
|
||||||
|
) installer.PreExtractCheck {
|
||||||
|
return func(verified installer.VerifiedPackage) error {
|
||||||
|
required, err := requiredStagingBytes(verified.PayloadBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
available, err := service.diskSpace.AvailableBytes(appRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrDiskSpaceCheck, err)
|
||||||
|
}
|
||||||
|
if available < 0 {
|
||||||
|
return fmt.Errorf("%w: negative available bytes", ErrDiskSpaceCheck)
|
||||||
|
}
|
||||||
|
if available < required {
|
||||||
|
return fmt.Errorf("%w: available=%d required=%d", ErrDiskSpaceInsufficient, available, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := service.targetState.IsRunning(
|
||||||
|
appID,
|
||||||
|
filepath.Join(appRoot, "current", verified.Entrypoint),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return ErrTargetRunning
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredStagingBytes(payloadBytes int64) (int64, error) {
|
||||||
|
if payloadBytes < 0 || payloadBytes > math.MaxInt64-StagingDiskReserveBytes {
|
||||||
|
return 0, fmt.Errorf("%w: invalid payload size", ErrDiskSpaceCheck)
|
||||||
|
}
|
||||||
|
return payloadBytes + StagingDiskReserveBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) {
|
||||||
|
if request.DownloadPath == "" {
|
||||||
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||||
|
"%w: completed download path is empty",
|
||||||
|
ErrInstallRequestInvalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !request.Entry.Installable || request.Entry.Package == nil {
|
||||||
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||||
|
"%w: Catalog entry is not installable",
|
||||||
|
ErrInstallRequestInvalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 {
|
||||||
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||||
|
"%w: unsupported architecture %q",
|
||||||
|
ErrInstallRequestInvalid,
|
||||||
|
request.Architecture,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
publishedPackage, exists := request.Entry.App.Packages[request.Architecture]
|
||||||
|
if !exists || publishedPackage != *request.Entry.Package {
|
||||||
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
||||||
|
"%w: selected package does not match app architecture",
|
||||||
|
ErrInstallRequestInvalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := request.Entry.App
|
||||||
|
return installer.PackageExpectation{
|
||||||
|
Size: publishedPackage.Size,
|
||||||
|
SHA256: publishedPackage.SHA256,
|
||||||
|
App: installer.AppExpectation{
|
||||||
|
ID: app.ID,
|
||||||
|
Version: app.Version,
|
||||||
|
Channel: string(app.Channel),
|
||||||
|
MinOS: string(app.MinOS),
|
||||||
|
Architecture: string(request.Architecture),
|
||||||
|
Entrypoint: app.EntryEXE,
|
||||||
|
RequiresAdmin: app.RequiresAdmin,
|
||||||
|
},
|
||||||
|
}, storage.InstalledApp{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
ID: app.ID,
|
||||||
|
Version: app.Version,
|
||||||
|
Architecture: string(request.Architecture),
|
||||||
|
Channel: string(app.Channel),
|
||||||
|
Files: []storage.InstalledFile{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *InstallService) installError(stage InstallStage, err error) error {
|
||||||
|
return &InstallError{Stage: stage, Code: service.failureCodeFor(err), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *InstallService) failureCodeFor(err error) FailureCode {
|
||||||
|
if errors.Is(err, installer.ErrStagingOutput) && service.storageFailures.IsDiskFull(err) {
|
||||||
|
return FailureCodeDiskFull
|
||||||
|
}
|
||||||
|
return failureCodeFor(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageForPackageError(err error) InstallStage {
|
||||||
|
var packageErr *installer.PackageError
|
||||||
|
if errors.As(err, &packageErr) {
|
||||||
|
switch packageErr.Stage {
|
||||||
|
case installer.PackageStageManifest:
|
||||||
|
return InstallStageManifest
|
||||||
|
case installer.PackageStagePreflight:
|
||||||
|
return InstallStagePreflight
|
||||||
|
case installer.PackageStageExtract:
|
||||||
|
return InstallStageExtract
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InstallStageVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureCodeFor(err error) FailureCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrDiskSpaceInsufficient):
|
||||||
|
return FailureCodeDiskFull
|
||||||
|
case errors.Is(err, ErrDiskSpaceCheck):
|
||||||
|
return FailureCodeDiskCheckFailed
|
||||||
|
case errors.Is(err, ErrTargetRunning):
|
||||||
|
return FailureCodeAppRunning
|
||||||
|
case errors.Is(err, ErrTargetStateCheck):
|
||||||
|
return FailureCodeTargetStateUnavailable
|
||||||
|
case errors.Is(err, installer.ErrPackageHashMismatch),
|
||||||
|
errors.Is(err, installer.ErrArchiveSizeMismatch):
|
||||||
|
return FailureCodeHashMismatch
|
||||||
|
case errors.Is(err, installer.ErrPathEscape),
|
||||||
|
errors.Is(err, installer.ErrEntrypointInvalid):
|
||||||
|
return FailureCodeZIPPathEscape
|
||||||
|
case errors.Is(err, installer.ErrPackageExpectationInvalid),
|
||||||
|
errors.Is(err, installer.ErrAppManifestTooLarge),
|
||||||
|
errors.Is(err, installer.ErrAppManifestInvalid),
|
||||||
|
errors.Is(err, installer.ErrAppManifestMissing),
|
||||||
|
errors.Is(err, installer.ErrPackageIdentityMismatch),
|
||||||
|
errors.Is(err, installer.ErrEntrypointMissing),
|
||||||
|
errors.Is(err, installer.ErrUnexpectedEntry),
|
||||||
|
errors.Is(err, installer.ErrUnsupportedEntry),
|
||||||
|
errors.Is(err, installer.ErrEncryptedEntry):
|
||||||
|
return FailureCodePackageInvalid
|
||||||
|
case errors.Is(err, installer.ErrInvalidArchive),
|
||||||
|
errors.Is(err, installer.ErrArchiveCorrupt),
|
||||||
|
errors.Is(err, installer.ErrArchiveTooLarge),
|
||||||
|
errors.Is(err, installer.ErrCentralDirectoryTooLarge),
|
||||||
|
errors.Is(err, installer.ErrTooManyEntries),
|
||||||
|
errors.Is(err, installer.ErrExpandedTooLarge),
|
||||||
|
errors.Is(err, installer.ErrCompressionRatio),
|
||||||
|
errors.Is(err, installer.ErrDuplicateEntry):
|
||||||
|
return FailureCodeZIPCorrupt
|
||||||
|
default:
|
||||||
|
return FailureCodeInstallFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageForSwitchError(err error, recordWriteErr error) InstallStage {
|
||||||
|
if errors.Is(err, installer.ErrRollbackFailed) {
|
||||||
|
return InstallStageRollback
|
||||||
|
}
|
||||||
|
if recordWriteErr != nil {
|
||||||
|
return InstallStageRecord
|
||||||
|
}
|
||||||
|
if errors.Is(err, installer.ErrHealthCheckFailed) {
|
||||||
|
return InstallStageHealth
|
||||||
|
}
|
||||||
|
return InstallStageSwitch
|
||||||
|
}
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
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.ProductID != "test-product" ||
|
||||||
|
record.SupportsTrial || record.RequiresAdmin {
|
||||||
|
t.Fatalf("launch metadata = %#v", record)
|
||||||
|
}
|
||||||
|
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
|
||||||
|
t.Fatalf("record first file = %#v", record.Files[0])
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte("new executable"))
|
||||||
|
if record.Files[0].SHA256 != hex.EncodeToString(hash[:]) {
|
||||||
|
t.Fatalf("record first hash = %q", record.Files[0].SHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entry catalog.Entry
|
||||||
|
wantErr error
|
||||||
|
wantCode FailureCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "selected package differs from architecture package",
|
||||||
|
entry: func() catalog.Entry {
|
||||||
|
entry := installEntry(publishedPackage, "1.2.3")
|
||||||
|
forged := publishedPackage
|
||||||
|
forged.SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
entry.Package = &forged
|
||||||
|
return entry
|
||||||
|
}(),
|
||||||
|
wantErr: ErrInstallRequestInvalid,
|
||||||
|
wantCode: FailureCodeInstallFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "download hash differs from Catalog",
|
||||||
|
entry: func() catalog.Entry {
|
||||||
|
entry := installEntry(publishedPackage, "1.2.3")
|
||||||
|
entry.App.Packages[catalog.ArchitectureAMD64] = catalog.Package{
|
||||||
|
Size: publishedPackage.Size,
|
||||||
|
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
Signature: publishedPackage.Signature,
|
||||||
|
URL: publishedPackage.URL,
|
||||||
|
}
|
||||||
|
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
|
||||||
|
return entry
|
||||||
|
}(),
|
||||||
|
wantErr: installer.ErrPackageHashMismatch,
|
||||||
|
wantCode: FailureCodeHashMismatch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
service := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
|
||||||
|
_, err := service.Install(InstallRequest{
|
||||||
|
Entry: test.entry,
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if stage := installErrorStage(t, err); stage != InstallStageVerify {
|
||||||
|
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
|
||||||
|
}
|
||||||
|
if code := installErrorCode(t, err); code != test.wantCode {
|
||||||
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("rejected install left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||||
|
initial := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
if _, err := initial.Install(InstallRequest{
|
||||||
|
Entry: installEntry(oldPackage, "1.0.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: oldArchive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("initial Install() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
service func(t *testing.T) *InstallService
|
||||||
|
wantStage InstallStage
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "health failure",
|
||||||
|
service: func(t *testing.T) *InstallService {
|
||||||
|
return newInstallService(t, store, func(string) error { return errors.New("health failed") })
|
||||||
|
},
|
||||||
|
wantStage: InstallStageHealth,
|
||||||
|
wantErr: installer.ErrHealthCheckFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record write failure",
|
||||||
|
service: func(t *testing.T) *InstallService {
|
||||||
|
return newInstallService(t, &failingRecordStore{
|
||||||
|
InstalledAppStore: store,
|
||||||
|
writeErr: errors.New("record disk error"),
|
||||||
|
}, func(string) error { return nil })
|
||||||
|
},
|
||||||
|
wantStage: InstallStageRecord,
|
||||||
|
wantErr: ErrInstallRecordWrite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||||
|
_, err := test.service(t).Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if stage := installErrorStage(t, err); stage != test.wantStage {
|
||||||
|
t.Fatalf("stage = %q, want %q", stage, test.wantStage)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appsRoot, "test-app", "current", "bin", "App.exe")); got != "old executable" {
|
||||||
|
t.Fatalf("current entrypoint after failure = %q", got)
|
||||||
|
}
|
||||||
|
record, found, readErr := store.Read("test-app")
|
||||||
|
if readErr != nil || !found || record.Version != "1.0.0" {
|
||||||
|
t.Fatalf("record after failure found=%t record=%#v err=%v", found, record, readErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceRechecksTargetImmediatelyBeforeUpdateSwitch(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||||
|
initial := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
if _, err := initial.Install(InstallRequest{
|
||||||
|
Entry: installEntry(oldPackage, "1.0.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: oldArchive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("initial Install() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||||
|
appRoot := filepath.Join(appsRoot, "test-app")
|
||||||
|
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
||||||
|
targetErr := errors.New("target probe failed")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
probe func(int) (bool, error)
|
||||||
|
wantErr error
|
||||||
|
wantCode FailureCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "target starts during extraction",
|
||||||
|
probe: func(call int) (bool, error) {
|
||||||
|
return call == 2, nil
|
||||||
|
},
|
||||||
|
wantErr: ErrTargetRunning,
|
||||||
|
wantCode: FailureCodeAppRunning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target state fails at switch",
|
||||||
|
probe: func(call int) (bool, error) {
|
||||||
|
if call == 2 {
|
||||||
|
return false, targetErr
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
},
|
||||||
|
wantErr: targetErr,
|
||||||
|
wantCode: FailureCodeTargetStateUnavailable,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
service := newInstallServiceWithCheckers(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
func(string) error { return nil },
|
||||||
|
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return StagingDiskReserveBytes + 64*1024, nil
|
||||||
|
}),
|
||||||
|
targetStateCheckerFunc(func(appID, entrypoint string) (bool, error) {
|
||||||
|
calls++
|
||||||
|
if appID != "test-app" || entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
||||||
|
t.Fatalf("target check = (%q, %q)", appID, entrypoint)
|
||||||
|
}
|
||||||
|
return test.probe(calls)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if stage := installErrorStage(t, err); stage != InstallStageSwitch {
|
||||||
|
t.Fatalf("stage = %q, want %q", stage, InstallStageSwitch)
|
||||||
|
}
|
||||||
|
if code := installErrorCode(t, err); code != test.wantCode {
|
||||||
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("target check calls = %d, want 2", calls)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||||
|
t.Fatalf("current after failed update = %q", got)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
||||||
|
t.Fatal("installed-app record changed after failed update")
|
||||||
|
}
|
||||||
|
for _, path := range []string{"staging", "backup", "install-transaction.json"} {
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, path)); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("failed update left %s, stat error = %v", path, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
||||||
|
extractor, err := installer.NewExtractor(installTestLimits())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExtractor() error = %v", err)
|
||||||
|
}
|
||||||
|
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
|
||||||
|
config := InstallServiceConfig{
|
||||||
|
Extractor: extractor,
|
||||||
|
Records: store,
|
||||||
|
Health: func(string) error { return nil },
|
||||||
|
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return StagingDiskReserveBytes, nil
|
||||||
|
}),
|
||||||
|
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
modify func(*InstallServiceConfig)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disk space checker",
|
||||||
|
modify: func(config *InstallServiceConfig) {
|
||||||
|
config.DiskSpace = nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target state checker",
|
||||||
|
modify: func(config *InstallServiceConfig) {
|
||||||
|
config.TargetState = nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage failure classifier",
|
||||||
|
modify: func(config *InstallServiceConfig) {
|
||||||
|
config.StorageFailures = nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
candidate := config
|
||||||
|
test.modify(&candidate)
|
||||||
|
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
|
||||||
|
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailureCodeForPackageFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
code FailureCode
|
||||||
|
}{
|
||||||
|
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
|
||||||
|
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
|
||||||
|
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
|
||||||
|
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
|
||||||
|
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := failureCodeFor(test.err); got != test.code {
|
||||||
|
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceClassifiesStagingOutputFailures(t *testing.T) {
|
||||||
|
errDiskFull := errors.New("injected disk full")
|
||||||
|
errOutput := errors.New("injected output failure")
|
||||||
|
service := &InstallService{
|
||||||
|
storageFailures: storageFailureClassifierFunc(func(err error) bool {
|
||||||
|
return errors.Is(err, errDiskFull)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
code FailureCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "write disk full",
|
||||||
|
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sync disk full",
|
||||||
|
err: fmt.Errorf("%w: sync staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "close disk full",
|
||||||
|
err: fmt.Errorf("%w: close staging file: %w", installer.ErrStagingOutput, errDiskFull),
|
||||||
|
code: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic output I O",
|
||||||
|
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errOutput),
|
||||||
|
code: FailureCodeInstallFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ZIP input remains corrupt",
|
||||||
|
err: fmt.Errorf("%w: %w", installer.ErrArchiveCorrupt, errDiskFull),
|
||||||
|
code: FailureCodeZIPCorrupt,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
wrapped := service.installError(InstallStageExtract, test.err)
|
||||||
|
var installErr *InstallError
|
||||||
|
if !errors.As(wrapped, &installErr) {
|
||||||
|
t.Fatalf("install error = %v, want InstallError", wrapped)
|
||||||
|
}
|
||||||
|
if installErr.Code != test.code {
|
||||||
|
t.Fatalf("code = %q, want %q", installErr.Code, test.code)
|
||||||
|
}
|
||||||
|
if !errors.Is(wrapped, test.err) {
|
||||||
|
t.Fatalf("install error = %v, want preserved cause", wrapped)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||||
|
var diskRoot string
|
||||||
|
var targetAppID, targetEntrypoint string
|
||||||
|
service := newInstallServiceWithCheckers(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
func(string) error { return nil },
|
||||||
|
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
|
||||||
|
diskRoot = appRoot
|
||||||
|
return required, nil
|
||||||
|
}),
|
||||||
|
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
|
||||||
|
targetAppID = appID
|
||||||
|
targetEntrypoint = entrypointPath
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Install() error = %v", err)
|
||||||
|
}
|
||||||
|
appRoot := filepath.Join(appsRoot, "test-app")
|
||||||
|
if diskRoot != appRoot {
|
||||||
|
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
|
||||||
|
}
|
||||||
|
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
||||||
|
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
service := newInstallServiceWithCheckers(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
func(string) error { return nil },
|
||||||
|
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrDiskSpaceInsufficient) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
|
||||||
|
}
|
||||||
|
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
|
||||||
|
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
|
||||||
|
}
|
||||||
|
appRoot := filepath.Join(appsRoot, "test-app")
|
||||||
|
for _, managed := range []string{"staging", "current"} {
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||||
|
initial := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
if _, err := initial.Install(InstallRequest{
|
||||||
|
Entry: installEntry(oldPackage, "1.0.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: oldArchive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("initial Install() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||||
|
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||||
|
diskProbeErr := errors.New("disk probe unavailable")
|
||||||
|
targetProbeErr := errors.New("target state unavailable")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
disk DiskSpaceChecker
|
||||||
|
target TargetStateChecker
|
||||||
|
wantErr error
|
||||||
|
wantCode FailureCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "insufficient disk space",
|
||||||
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return required - 1, nil
|
||||||
|
}),
|
||||||
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
wantErr: ErrDiskSpaceInsufficient,
|
||||||
|
wantCode: FailureCodeDiskFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disk capacity check fails",
|
||||||
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return 0, diskProbeErr
|
||||||
|
}),
|
||||||
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
wantErr: diskProbeErr,
|
||||||
|
wantCode: FailureCodeDiskCheckFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current app is running",
|
||||||
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return required, nil
|
||||||
|
}),
|
||||||
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}),
|
||||||
|
wantErr: ErrTargetRunning,
|
||||||
|
wantCode: FailureCodeAppRunning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target state check fails",
|
||||||
|
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return required, nil
|
||||||
|
}),
|
||||||
|
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, targetProbeErr
|
||||||
|
}),
|
||||||
|
wantErr: targetProbeErr,
|
||||||
|
wantCode: FailureCodeTargetStateUnavailable,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
appRoot := filepath.Join(appsRoot, "test-app")
|
||||||
|
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
|
||||||
|
_, err := service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
|
||||||
|
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
|
||||||
|
}
|
||||||
|
if code := installErrorCode(t, err); code != test.wantCode {
|
||||||
|
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||||
|
t.Fatalf("current entrypoint after failure = %q", got)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
||||||
|
t.Fatal("installed-app record changed after preflight failure")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||||
|
initial := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
if _, err := initial.Install(InstallRequest{
|
||||||
|
Entry: installEntry(oldPackage, "1.0.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: oldArchive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("initial Install() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||||
|
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
|
||||||
|
document, err := os.ReadFile(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read corrupted archive: %v", err)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(document)
|
||||||
|
publishedPackage.Size = int64(len(document))
|
||||||
|
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
service := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
_, err = service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, installer.ErrArchiveCorrupt) {
|
||||||
|
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
|
||||||
|
}
|
||||||
|
if stage := installErrorStage(t, err); stage != InstallStageExtract {
|
||||||
|
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
|
||||||
|
}
|
||||||
|
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
|
||||||
|
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
|
||||||
|
}
|
||||||
|
appRoot := filepath.Join(appsRoot, "test-app")
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||||
|
t.Fatalf("current entrypoint after corruption = %q", got)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := storage.NewInstalledAppStore(appsRoot)
|
||||||
|
appRoot, err := store.EnsureAppRoot("test-app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureAppRoot() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(appRoot, "current"), 0o700); err != nil {
|
||||||
|
t.Fatalf("create current: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(appRoot, "staging"), 0o700); err != nil {
|
||||||
|
t.Fatalf("create stale staging: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(appRoot, "install-transaction.json"),
|
||||||
|
[]byte(`{"schema_version":1,"phase":"prepared","had_current":true}`),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||||
|
service := newInstallService(t, store, func(string) error { return nil })
|
||||||
|
result, err := service.Install(InstallRequest{
|
||||||
|
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||||
|
Architecture: catalog.ArchitectureAMD64,
|
||||||
|
DownloadPath: archivePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Install() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Recovery.Action != installer.RecoveryAborted {
|
||||||
|
t.Fatalf("recovery action = %q, want %q", result.Recovery.Action, installer.RecoveryAborted)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "new executable" {
|
||||||
|
t.Fatalf("current entrypoint = %q", got)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("staging remains, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(appRoot, "install-transaction.json")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("transaction remains, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRecordStore struct {
|
||||||
|
*storage.InstalledAppStore
|
||||||
|
writeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *failingRecordStore) Write(storage.InstalledApp) error {
|
||||||
|
return store.writeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallService(
|
||||||
|
t *testing.T,
|
||||||
|
store InstallRecordStore,
|
||||||
|
health installer.HealthCheck,
|
||||||
|
) *InstallService {
|
||||||
|
return newInstallServiceWithCheckers(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
health,
|
||||||
|
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||||
|
return StagingDiskReserveBytes + 64*1024, nil
|
||||||
|
}),
|
||||||
|
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallServiceWithCheckers(
|
||||||
|
t *testing.T,
|
||||||
|
store InstallRecordStore,
|
||||||
|
health installer.HealthCheck,
|
||||||
|
diskSpace DiskSpaceChecker,
|
||||||
|
targetState TargetStateChecker,
|
||||||
|
) *InstallService {
|
||||||
|
t.Helper()
|
||||||
|
extractor, err := installer.NewExtractor(installTestLimits())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExtractor() error = %v", err)
|
||||||
|
}
|
||||||
|
service, err := NewInstallService(InstallServiceConfig{
|
||||||
|
Extractor: extractor,
|
||||||
|
Records: store,
|
||||||
|
Health: health,
|
||||||
|
DiskSpace: diskSpace,
|
||||||
|
StorageFailures: storageFailureClassifierFunc(func(error) bool {
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
TargetState: targetState,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewInstallService() error = %v", err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
type diskSpaceCheckerFunc func(string) (int64, error)
|
||||||
|
|
||||||
|
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
|
||||||
|
return check(appRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageFailureClassifierFunc func(error) bool
|
||||||
|
|
||||||
|
func (classifier storageFailureClassifierFunc) IsDiskFull(err error) bool {
|
||||||
|
return classifier(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type targetStateCheckerFunc func(string, string) (bool, error)
|
||||||
|
|
||||||
|
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
||||||
|
return check(appID, entrypointPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installTestLimits() installer.Limits {
|
||||||
|
return installer.Limits{
|
||||||
|
MaxEntries: 20,
|
||||||
|
MaxArchiveBytes: 64 * 1024,
|
||||||
|
MaxCentralDirectoryBytes: 4 * 1024,
|
||||||
|
MaxUncompressedBytes: 64 * 1024,
|
||||||
|
MaxCompressionRatio: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installEntry(publishedPackage catalog.Package, version string) catalog.Entry {
|
||||||
|
selectedPackage := publishedPackage
|
||||||
|
return catalog.Entry{
|
||||||
|
App: catalog.App{
|
||||||
|
ID: "test-app",
|
||||||
|
Version: version,
|
||||||
|
Channel: catalog.ReleaseStable,
|
||||||
|
Status: catalog.CatalogStatusActive,
|
||||||
|
MinOS: catalog.Windows10,
|
||||||
|
Architectures: []catalog.Architecture{catalog.ArchitectureAMD64},
|
||||||
|
EntryEXE: "bin/App.exe",
|
||||||
|
Packages: map[catalog.Architecture]catalog.Package{
|
||||||
|
catalog.ArchitectureAMD64: publishedPackage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Package: &selectedPackage,
|
||||||
|
Installable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInstallPackage(t *testing.T, version, executable string) (string, catalog.Package) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "package.download")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create package: %v", err)
|
||||||
|
}
|
||||||
|
writer := zip.NewWriter(file)
|
||||||
|
entries := []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{name: "app.json", body: installManifest(version)},
|
||||||
|
{name: "payload/bin/App.exe", body: []byte(executable), mode: 0o755},
|
||||||
|
{name: "payload/readme.txt", body: []byte("readme")},
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
header := &zip.FileHeader{Name: entry.name}
|
||||||
|
mode := entry.mode
|
||||||
|
if mode == 0 {
|
||||||
|
mode = 0o600
|
||||||
|
}
|
||||||
|
header.SetMode(mode)
|
||||||
|
part, err := writer.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create ZIP entry: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(entry.body); err != nil {
|
||||||
|
t.Fatalf("write ZIP entry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("close ZIP writer: %v", err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatalf("close package: %v", err)
|
||||||
|
}
|
||||||
|
document, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read package: %v", err)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(document)
|
||||||
|
return path, catalog.Package{
|
||||||
|
URL: "https://download.invalid/test-app.zip",
|
||||||
|
Size: int64(len(document)),
|
||||||
|
SHA256: hex.EncodeToString(hash[:]),
|
||||||
|
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installManifest(version string) []byte {
|
||||||
|
return []byte(`{"schema_version":1,"id":"test-app","name":"Test App","vendor":"SoftBox","version":"` + version + `","channel":"stable","min_os":"windows-10","architecture":"amd64","entrypoint":"bin/App.exe","working_directory":".","product_id":"test-product","supports_trial":false,"requires_admin":false,"data_policy":"local-app-data","update_policy":"managed-by-softbox"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installErrorStage(t *testing.T, err error) InstallStage {
|
||||||
|
t.Helper()
|
||||||
|
var installErr *InstallError
|
||||||
|
if !errors.As(err, &installErr) {
|
||||||
|
t.Fatalf("error %v is not InstallError", err)
|
||||||
|
}
|
||||||
|
return installErr.Stage
|
||||||
|
}
|
||||||
|
|
||||||
|
func installErrorCode(t *testing.T, err error) FailureCode {
|
||||||
|
t.Helper()
|
||||||
|
var installErr *InstallError
|
||||||
|
if !errors.As(err, &installErr) {
|
||||||
|
t.Fatalf("error %v is not InstallError", err)
|
||||||
|
}
|
||||||
|
return installErr.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func corruptInstallPackageEntry(t *testing.T, archivePath, entryName string) {
|
||||||
|
t.Helper()
|
||||||
|
reader, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open ZIP for corruption: %v", err)
|
||||||
|
}
|
||||||
|
var offset int64 = -1
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.Name != entryName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset, err = file.DataOffset()
|
||||||
|
if err != nil {
|
||||||
|
_ = reader.Close()
|
||||||
|
t.Fatalf("entry data offset: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatalf("close ZIP reader: %v", err)
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
t.Fatalf("entry %s not found", entryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
document, err := os.ReadFile(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read ZIP for corruption: %v", err)
|
||||||
|
}
|
||||||
|
document[offset] ^= 0xff
|
||||||
|
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
|
||||||
|
t.Fatalf("write corrupted ZIP: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
document, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return string(document)
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// 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-]+$`)
|
||||||
|
launchProductIDPattern = 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 signed
|
||||||
|
// package product. It is a required fail-closed boundary.
|
||||||
|
type AuthorizationChecker interface {
|
||||||
|
IsAuthorized(productID 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)
|
||||||
|
}
|
||||||
|
if !launchProductIDPattern.MatchString(record.ProductID) {
|
||||||
|
return Result{}, launchError(ErrAuthorizationCheck)
|
||||||
|
}
|
||||||
|
authorized, err := service.authorization.IsAuthorized(record.ProductID)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
|
||||||
|
}
|
||||||
|
if !authorized {
|
||||||
|
return Result{}, launchError(ErrLaunchUnauthorized)
|
||||||
|
}
|
||||||
|
running, err := service.targetState.IsRunning(record.ID, command.Entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
return Result{}, launchError(ErrAppRunning)
|
||||||
|
}
|
||||||
|
pid, err := service.launcher.Start(command)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, launchError(fmt.Errorf("%w: %w", ErrProcessStart, err))
|
||||||
|
}
|
||||||
|
if pid <= 0 {
|
||||||
|
return Result{}, launchError(fmt.Errorf("%w: invalid process ID", ErrProcessStart))
|
||||||
|
}
|
||||||
|
return Result{AppID: record.ID, PID: pid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchCommand(record storage.InstalledApp, current string) (Command, error) {
|
||||||
|
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||||||
|
return Command{}, ErrLaunchMetadata
|
||||||
|
}
|
||||||
|
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||||||
|
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchMetadata, err)
|
||||||
|
}
|
||||||
|
if record.WorkingDirectory != "." {
|
||||||
|
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||||||
|
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchMetadata, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validMinOS(record.MinOS) {
|
||||||
|
return Command{}, ErrLaunchMetadata
|
||||||
|
}
|
||||||
|
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||||||
|
return Command{}, fmt.Errorf("%w: entrypoint is not in installed files", ErrLaunchMetadata)
|
||||||
|
}
|
||||||
|
if err := requireRealDirectory(current); err != nil {
|
||||||
|
return Command{}, err
|
||||||
|
}
|
||||||
|
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return Command{}, fmt.Errorf("%w: entrypoint: %v", ErrLaunchTargetUnsafe, err)
|
||||||
|
}
|
||||||
|
workingDirectory := current
|
||||||
|
if record.WorkingDirectory != "." {
|
||||||
|
workingDirectory, err = safepath.JoinUnder(current, record.WorkingDirectory)
|
||||||
|
if err != nil {
|
||||||
|
return Command{}, fmt.Errorf("%w: working directory: %v", ErrLaunchTargetUnsafe, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := requireRealDirectory(workingDirectory); err != nil {
|
||||||
|
return Command{}, err
|
||||||
|
}
|
||||||
|
if err := requireRegularFile(entrypoint); err != nil {
|
||||||
|
return Command{}, err
|
||||||
|
}
|
||||||
|
return Command{
|
||||||
|
Entrypoint: entrypoint,
|
||||||
|
WorkingDirectory: workingDirectory,
|
||||||
|
RequiresAdmin: record.RequiresAdmin,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Path == entrypoint {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMinOS(minOS string) bool {
|
||||||
|
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireRealDirectory(path string) error {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("%w: %s", ErrLaunchTargetUnsafe, path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return fmt.Errorf("%w: %s is not a real directory", ErrLaunchTargetUnsafe, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireRegularFile(path string) error {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("%w: %s", ErrEntrypointMissing, path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: inspect %s: %w", ErrLaunchTargetUnsafe, path, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%w: %s is not a regular file", ErrLaunchTargetUnsafe, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchError(err error) error {
|
||||||
|
return &Error{Code: failureCodeFor(err), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureCodeFor(err error) FailureCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrAppNotInstalled):
|
||||||
|
return FailureCodeNotInstalled
|
||||||
|
case errors.Is(err, ErrLaunchMetadata):
|
||||||
|
return FailureCodeLaunchMetadataInvalid
|
||||||
|
case errors.Is(err, ErrLaunchTargetUnsafe):
|
||||||
|
return FailureCodeLaunchTargetUnsafe
|
||||||
|
case errors.Is(err, ErrEntrypointMissing):
|
||||||
|
return FailureCodeEntrypointMissing
|
||||||
|
case errors.Is(err, ErrCompatibilityCheck):
|
||||||
|
return FailureCodeCompatibilityUnavailable
|
||||||
|
case errors.Is(err, ErrAppIncompatible):
|
||||||
|
return FailureCodeAppIncompatible
|
||||||
|
case errors.Is(err, ErrAuthorizationCheck):
|
||||||
|
return FailureCodeAuthorizationFailed
|
||||||
|
case errors.Is(err, ErrLaunchUnauthorized):
|
||||||
|
return FailureCodeLaunchUnauthorized
|
||||||
|
case errors.Is(err, ErrAppRunning):
|
||||||
|
return FailureCodeAppRunning
|
||||||
|
case errors.Is(err, ErrTargetStateCheck):
|
||||||
|
return FailureCodeTargetStateUnavailable
|
||||||
|
default:
|
||||||
|
return FailureCodeLaunchFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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)
|
||||||
|
var checkedProduct string
|
||||||
|
service.authorization = authorizationFunc(func(productID string) (bool, error) {
|
||||||
|
checkedProduct = productID
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if checkedProduct != "test-product" {
|
||||||
|
t.Fatalf("authorization product = %q, want test-product", checkedProduct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "missing legacy product metadata",
|
||||||
|
mutate: func(record *storage.InstalledApp, _ string) {
|
||||||
|
record.ProductID = ""
|
||||||
|
},
|
||||||
|
wantErr: ErrAuthorizationCheck,
|
||||||
|
wantCode: FailureCodeAuthorizationFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
ProductID: "test-product",
|
||||||
|
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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user