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 本地校验缓存
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# 本地编辑器 workspace 配置(个人,不入库)
|
||||
*.code-workspace
|
||||
|
||||
# 本地开发运行脚本(个人便利,不入库)
|
||||
run-modern.bat
|
||||
run-win7.bat
|
||||
|
||||
@@ -42,9 +42,20 @@
|
||||
- 一次只领取一个任务(`docs/tasks/` 中 `status: TODO` 且依赖全 `DONE`、编号最靠前的),按 `docs/tasks/README.md` 约定流转状态。
|
||||
- 执行记录写进该任务文件的 `## 执行记录`;项目现实变化(启动/验证路径、目录、blocker)覆盖更新 `docs/current-state.md`。
|
||||
- 需求变化先改文档再改代码;不在代码里发明文档没有的接口、字段和状态。
|
||||
- **发现需求冲突必须暂停**:实现中若发现需求之间、或需求与已冻结协议/架构之间无法同时满足(例如需求要求某能力,而协议结构上无法表达),**立即停止该方向的实现**,把冲突写进当前任务文件的 `## 执行记录`(冲突双方的文档出处 + 技术原因),并向用户**请求裁定**。不得自行取舍、不得静默缩范围、不得为绕开冲突而修改已冻结协议。
|
||||
- **裁定后文档先行**:拿到裁定后,先把结论同步到**所有**受影响文档(需求、验收标准、架构、协议、路线图、任务边界),再继续实现或拆出新任务。只更新架构/任务文档而不回头修订 `docs/02-requirements.md` 会造成验收标准失效——这是已发生过的漂移(见 `docs/review/phase5-review.md` 的试用冲突)。
|
||||
- 密钥、许可证私钥、真实注册码、真实下载 URL 一律不入库;示例只用占位符。
|
||||
- 提交信息使用英文祈使句,任务相关提交带上 `T-<编号>`。
|
||||
|
||||
## Agent 执行模式
|
||||
|
||||
- 后续任务默认且持续使用**单 Agent 串行执行**;当前 Agent 独立完成任务落文档、实现、审查、自测、状态更新和 Git 提交。
|
||||
- 不启动子 Agent,不把测试设计、安全审查或代码审查委派给其他 Agent;需要复核时由当前 Agent 分阶段自行检查。
|
||||
- 项目同一时间只保留一个活跃任务。T-301 → T-302 → T-303 这类依赖链严格按顺序完成和提交,不得提前并发实现后置任务。
|
||||
- `write_paths` 继续作为单任务修改边界,用于限制任务范围和提交内容,不再用于安排并行写入。
|
||||
- T-301 的多 Agent 执行记录保留为历史事实,不代表后续默认方式。
|
||||
- 只有用户以后再次明确要求多 Agent,才允许先修改并提交本节及相关任务文档,再启动子 Agent;对话中的临时建议不能覆盖本规则。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| [`docs/tasks/README.md`](docs/tasks/README.md) | 一任务一文件约定 |
|
||||
| [`docs/api.md`](docs/api.md) | Catalog / 软件包 / 许可证 / 事件 / CLI 协议合约 |
|
||||
| [`docs/routes.md`](docs/routes.md) | Gio 视图结构与交互约束 |
|
||||
| [`docs/troubleshooting.md`](docs/troubleshooting.md) | `unsafe_cache` 等人工故障排查与安全恢复步骤 |
|
||||
| [`docs/current-state.md`](docs/current-state.md) | 当前实现状态快照 |
|
||||
| [`docs/agent-context.md`](docs/agent-context.md) / [`docs/agent-context.json`](docs/agent-context.json) / [`docs/agent-context.schema.json`](docs/agent-context.schema.json) | 上下文路由清单及其契约 |
|
||||
| [`docs/adoption-checklist.md`](docs/adoption-checklist.md) | 已有项目接入迁移清单(备查) |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"softbox.local/core/application"
|
||||
)
|
||||
|
||||
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
|
||||
runtime := application.NewRuntime(1)
|
||||
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
|
||||
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
|
||||
}))
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bootstrap error = %v", err)
|
||||
}
|
||||
event := <-runtime.Events()
|
||||
if event.Type != application.EventCatalogRefreshed {
|
||||
t.Fatalf("event type = %q", event.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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