67 Commits
Author SHA1 Message Date
ilaandClaude Fable 5 36d43cdf16 Require pausing on requirements conflicts; ignore local run scripts
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Add a governance rule to AGENTS.md: when implementation reveals that
requirements cannot be satisfied as written (e.g. a capability the
frozen protocol cannot express), the agent must stop, record both
document sources and the technical reason in the task's execution log,
and request adjudication instead of silently narrowing scope or
altering a frozen protocol. After adjudication, every affected document
must be updated before work resumes or a new task is split off -
updating only the architecture/task docs while leaving
docs/02-requirements.md stale invalidates the acceptance criteria, which
is the drift observed in the Phase 5 trial conflict.

Also gitignore the local run-modern.bat / run-win7.bat dev scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:10:17 +08:00
ilaandClaude Fable 5 63dc40275f Move trial authorization out of MVP scope
Resolve the requirements-vs-architecture conflict found in the Phase 5
review. License v1 is frozen at nine fields with no expiry field
(perpetual is a bool; update/rebind policy are opaque labels), so it
cannot express a trial period. The implementation and architecture docs
correctly recorded that boundary, but 02-requirements still listed trial
as a P0 capability and an MVP acceptance criterion.

Decision: trial moves to V1.1.

- 02-requirements: drop trial from the user role and P0 feature list;
  acceptance now reads 'authorization state consistent between box and
  sub-apps'; add a V1.1 trial row naming the protocol prerequisite; add
  a scope row stating perpetual:false is display-only and never expires,
  and supports_trial does not grant local trial.
- 00-ai-start-here: remove trial from 'MVP does', add it to 'MVP does
  not' with the protocol reason.
- 04-architecture: development order step 5 no longer lists trial/rebind
  as MVP work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:43:21 +08:00
ilaandClaude Fable 5 1bc4504913 Add Phase 5 review cross-check ruling
Concede two errors in the original review: it cited 01-vision.md for a
'perpetual offline use' promise the vision never makes (only 'offline
verifiable'), and it described the clock-rollback check too broadly -
that check only covers same-list rollback, not an older list paired
with a correspondingly rolled-back clock.

Accept Codex's O5 (verified: StoreRevocations replaces the cache after
signature check with no cross-list monotonicity, so signed revocation
lists can be replayed) and O6, plus four precision refinements. Add the
threat-boundary rationale for O5: monotonicity does not stop a local
attacker but does stop distribution-channel replay, a ~38-day revocation
rollback window is exploitable without any clock control, and benign CDN
staleness alone silently rolls back revocation state - making the
'reject older list' check a correctness requirement, not just hardening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:28:08 +08:00
ilaandClaude Fable 5 5db6031e4b Add Phase 5 licensing review (T-501~T-503)
Code-level audit of the authorization chain: domain-separated machine
hash with no raw identifiers in errors or storage, strict License v1
verification (base64 re-encode round-trip, exact 9-field shape,
timestamp round-trip, machine-hash binding), and revocation state with
clock-rollback defense and revoked-before-expiry ordering. Snapshots to
the UI carry no license ID, signature, path or raw Windows identifiers;
defaults fail closed. Catalog/License/Revocation share one canonical
JSON implementation.

No security defect found. Records two product decisions needed: the
revocation list is a hard dependency for any authorization (missing or
>38-day-old list blocks all launches, in tension with the offline
licensing vision and lacking a distribution plan), and trial is a P0
requirement plus MVP acceptance criterion that is deliberately not
implemented, leaving 02-requirements in conflict with the architecture
and task docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:42:30 +08:00
ila 76f6108496 Implement authorization import and revocation checks (T-503) 2026-07-20 09:07:30 +08:00
ila b4453130de Define authorization integration task (T-503) 2026-07-20 08:43:24 +08:00
ila cd76f7f900 Implement offline license verification (T-502) 2026-07-20 01:29:40 +08:00
ila c51ff30c54 Define license verification task (T-502) 2026-07-20 01:12:04 +08:00
ila 8d81276fd9 Implement machine fingerprint hashing (T-501) 2026-07-20 00:19:13 +08:00
ila 94ea334d1c Define machine fingerprint contract (T-501) 2026-07-20 00:11:56 +08:00
ila 24e9862192 Harden self-update recovery (T-617) 2026-07-20 00:05:41 +08:00
ila ea1d6c8ee4 Define self-update recovery remediation (T-617) 2026-07-19 23:53:27 +08:00
ilaandClaude Fable 5 a2cda73deb Add Phase 4 launch/update/self-update review with cross-check ruling
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
Code-level audit of T-401~T-403: launch has no command-injection surface
(AppID-only request, entrypoint must be in recorded installed files,
safepath+JoinUnder+Lstat, no-arg exec.Command and parameterless
ShellExecuteExW), update never force-kills (confirm + natural-exit wait
only), and self-update is journaled, rollback-capable and health-gated
before backup deletion. Confirms Phase 3 O1 is closed by T-401's
preSwitchCheck.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:09:25 +08:00
ila 90dc09d13d Use single-agent workflow 2026-07-16 20:42:32 +08:00
ila 8dad40f934 Implement resumable download queue (T-301) 2026-07-16 19:49:06 +08:00
ila 2d302b731a Define multi-agent download workflow (T-301) 2026-07-16 18:48:15 +08:00
ila a52acbf926 Add app details and icon cache (T-204)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled
2026-07-16 17:22:37 +08:00
ila db8d93e843 Build virtualized software list (T-203) 2026-07-16 17:10:42 +08:00
ila 819b1cdf88 Recognize local app states (T-202) 2026-07-16 17:00:06 +08:00
ila 2e21c9f327 Integrate validated catalog loading (T-201) 2026-07-16 16:52:46 +08:00
ila 9da72caa01 Prototype atomic install recovery (T-103) 2026-07-16 16:32:58 +08:00
ila 0ffeff63e1 Prototype secure ZIP extraction (T-102) 2026-07-16 16:22:45 +08:00
ila 0d6ed05d7e Prototype signed catalog fallback (T-101) 2026-07-16 16:13:18 +08:00
ila 45c242ecec Add Phase 0 build verification gate (T-004) 2026-07-16 15:51:34 +08:00
ila 6f920eb457 Add Gio app shells and platform stubs (T-003) 2026-07-16 15:43:03 +08:00
ila 0e20dd76b2 Add domain states and event runtime (T-002) 2026-07-16 15:31:41 +08:00
ila cf9fc01c68 Initialize Phase 0 monorepo skeleton (T-001) 2026-07-16 15:28:13 +08:00
277 changed files with 36775 additions and 156 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Phase 0 build gate
on:
push:
pull_request:
permissions: read-all
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Go 1.25
uses: actions/setup-go@v5
with:
go-version: "1.25.0"
cache: false
- name: Run Phase 0 verification
run: bash scripts/verify_phase0.sh
+7
View File
@@ -30,3 +30,10 @@ gitea.env.*
# Python 本地校验缓存
__pycache__/
*.py[cod]
# 本地编辑器 workspace 配置(个人,不入库)
*.code-workspace
# 本地开发运行脚本(个人便利,不入库)
run-modern.bat
run-win7.bat
+11
View File
@@ -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
+1
View File
@@ -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)
}
}
+165
View File
@@ -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
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
"softbox.local/app-modern/platform/windows"
"softbox.local/core/updater"
)
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
return false, nil
}
if len(arguments) != 2 {
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
}
executable, err := os.Executable()
if err != nil {
return true, fmt.Errorf("locate current executable: %w", err)
}
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
return true, fmt.Errorf("acknowledge self-update health: %w", err)
}
return true, nil
}
@@ -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)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"softbox.local/app-modern/platform/windows"
"softbox.local/core/updater"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(arguments []string) error {
for _, option := range []string{"--pid", "--staging", "--target"} {
if err := requireOneOption(arguments, option); err != nil {
return err
}
}
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
flags.SetOutput(io.Discard)
pid := flags.Int("pid", 0, "main SoftBox PID")
staging := flags.String("staging", "", "prepared staging directory")
target := flags.String("target", "", "fixed app target directory")
if err := flags.Parse(arguments); err != nil {
return fmt.Errorf("parse updater arguments: %w", err)
}
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
}
requestID := filepath.Base(filepath.Clean(*staging))
platform := windows.New()
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
ParentExit: 2 * time.Minute,
Health: 45 * time.Second,
})
return service.Update(context.Background(), updater.Request{
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
})
}
func requireOneOption(arguments []string, option string) error {
count := 0
for _, argument := range arguments {
if argument == option || strings.HasPrefix(argument, option+"=") {
count++
}
}
if count != 1 {
return fmt.Errorf("%s must appear exactly once", option)
}
return nil
}
@@ -0,0 +1,14 @@
package main
import "testing"
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
if err := run(nil); err == nil {
t.Fatal("run(nil) succeeded")
}
if err := run([]string{
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
}); err == nil {
t.Fatal("run() accepted duplicate --pid")
}
}
+9
View File
@@ -0,0 +1,9 @@
module softbox.local/app-modern
go 1.25.0
require (
gioui.org v0.10.1
golang.org/x/sys v0.39.0
softbox.local/core v0.0.0
)
@@ -0,0 +1,19 @@
package windows
import "fmt"
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
switch minOS {
case "windows-7-sp1":
if major > 6 || (major == 6 && minor > 1) {
return true, nil
}
return major == 6 && minor == 1 && servicePack >= 1, nil
case "windows-10":
return major >= 10, nil
case "windows-11":
return major >= 10 && build >= 22000, nil
default:
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides modern Windows platform adapters and non-Windows
// stubs for package-level tests.
package windows
@@ -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'
}
+38
View File
@@ -0,0 +1,38 @@
package windows
import (
"context"
"errors"
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
// Edition identifies the application build channel shown by the UI.
type Edition string
const EditionModern Edition = "Modern"
// ErrUnsupported reports that a Windows-only capability is unavailable on the
// current host. Callers must treat it as an unknown state, never as a stopped
// process or a compatible system.
var ErrUnsupported = errors.New("windows platform capability is unsupported on this host")
// Platform is the minimal boundary for target-specific capabilities.
type Platform interface {
OS() string
Edition() Edition
IsCompatible(minOS string) (bool, error)
IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, error)
WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error
StartSelfUpdate(command updater.StartCommand) (int, error)
SyncDirectory(path string) error
}
// New returns the platform implementation selected by build tags.
func New() Platform {
return newPlatform()
}
@@ -0,0 +1,54 @@
//go:build !windows
package windows
import (
"context"
"runtime"
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
type platformStub struct{}
func newPlatform() Platform {
return platformStub{}
}
func (platformStub) OS() string {
return runtime.GOOS
}
func (platformStub) Edition() Edition {
return EditionModern
}
func (platformStub) IsCompatible(string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) IsRunning(string, string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
return ErrUnsupported
}
func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error {
return ErrUnsupported
}
func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) SyncDirectory(string) error {
return ErrUnsupported
}
@@ -0,0 +1,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
}
+56
View File
@@ -0,0 +1,56 @@
package windows
import (
"fmt"
"path/filepath"
"strings"
)
// processSnapshot is deliberately small so the matching invariant can be
// tested without a Windows host. NextImagePath returns more=false only after a
// successful end-of-snapshot; any enumeration or image-path failure is an
// error, rather than evidence that the target is not running.
type processSnapshot interface {
NextImagePath() (path string, more bool, err error)
Close() error
}
type processSnapshotFactory func() (processSnapshot, error)
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
target, err := canonicalProcessPath(entrypoint)
if err != nil {
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
}
snapshot, err := newSnapshot()
if err != nil {
return false, fmt.Errorf("create process snapshot: %w", err)
}
defer snapshot.Close()
for {
path, more, err := snapshot.NextImagePath()
if err != nil {
return false, fmt.Errorf("enumerate process image path: %w", err)
}
if !more {
return false, nil
}
candidate, err := canonicalProcessPath(path)
if err != nil {
return false, fmt.Errorf("canonicalize process image path: %w", err)
}
if strings.EqualFold(target, candidate) {
return true, nil
}
}
}
func canonicalProcessPath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
+49
View File
@@ -0,0 +1,49 @@
package windows
import (
"context"
"fmt"
"time"
)
type pidWaitHandle interface {
Wait(time.Duration) (bool, error)
Close() error
}
type pidOpener func(int) (pidWaitHandle, error)
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
if pid <= 0 {
return fmt.Errorf("process PID must be positive")
}
if timeout <= 0 {
return fmt.Errorf("process wait timeout must be positive")
}
handle, err := open(pid)
if err != nil {
return fmt.Errorf("open process %d: %w", pid, err)
}
defer handle.Close()
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
if err := ctx.Err(); err != nil {
return err
}
exited, err := handle.Wait(250 * time.Millisecond)
if err != nil {
return fmt.Errorf("wait for process %d: %w", pid, err)
}
if exited {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return context.DeadlineExceeded
default:
}
}
}
@@ -0,0 +1,49 @@
package windows
import (
"context"
"errors"
"testing"
"time"
)
type fakePIDHandle struct {
exited bool
waitErr error
closed bool
}
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
handle := &fakePIDHandle{exited: true}
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
if pid != 9 {
t.Fatalf("PID = %d, want 9", pid)
}
return handle, nil
})
if err != nil || !handle.closed {
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
}
}
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
openErr := errors.New("access denied")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
t.Fatalf("open error = %v", err)
}
waitErr := errors.New("wait failed")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
t.Fatalf("wait error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("timeout error = %v", err)
}
}
@@ -0,0 +1,88 @@
//go:build windows
package windows
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
"softbox.local/core/updater"
)
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
}
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("self-update launch paths must be absolute")
}
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
command.HealthRequestID == "" {
return 0, fmt.Errorf("invalid fixed self-update launch command")
}
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
commandLine.Dir = command.WorkingDirectory
if err := commandLine.Start(); err != nil {
return 0, err
}
return commandLine.Process.Pid, nil
}
func (platform) SyncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path: %w", err)
}
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
if err != nil {
return fmt.Errorf("open directory handle: %w", err)
}
if err := syscall.FlushFileBuffers(handle); err != nil {
_ = syscall.CloseHandle(handle)
return fmt.Errorf("flush directory handle: %w", err)
}
if err := syscall.CloseHandle(handle); err != nil {
return fmt.Errorf("close directory handle: %w", err)
}
return nil
}
type windowsPIDHandle struct{ handle windows.Handle }
func openWindowsProcess(pid int) (pidWaitHandle, error) {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
return nil, err
}
return windowsPIDHandle{handle: handle}, nil
}
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
milliseconds := uint32(timeout / time.Millisecond)
if milliseconds == 0 {
milliseconds = 1
}
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
if err != nil {
return false, err
}
switch result {
case windows.WAIT_OBJECT_0:
return true, nil
case uint32(windows.WAIT_TIMEOUT):
return false, nil
default:
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
}
}
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
+64
View File
@@ -0,0 +1,64 @@
package windows
import (
"context"
"fmt"
"time"
)
const exitPollInterval = 250 * time.Millisecond
type exitWaitClock interface {
Now() time.Time
Wait(context.Context, time.Duration) error
}
type systemExitWaitClock struct{}
func (systemExitWaitClock) Now() time.Time {
return time.Now()
}
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
if ctx == nil {
return fmt.Errorf("wait context is required")
}
if timeout <= 0 {
return fmt.Errorf("exit timeout must be positive")
}
if err := ctx.Err(); err != nil {
return err
}
deadline := clock.Now().Add(timeout)
for {
isRunning, err := running()
if err != nil {
return err
}
if !isRunning {
return nil
}
remaining := deadline.Sub(clock.Now())
if remaining <= 0 {
return context.DeadlineExceeded
}
interval := exitPollInterval
if remaining < interval {
interval = remaining
}
if err := clock.Wait(ctx, interval); err != nil {
return err
}
}
}
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1080, 720)
adapterContractCompactViewport = image.Pt(1080, 420)
)
const adapterContractEdition = "Modern"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
t.Fatal("empty catalog did not render the catalog-loading state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+46
View File
@@ -0,0 +1,46 @@
package gio
import "softbox.local/core/application"
type catalogPresentationState string
const (
catalogStateLoading catalogPresentationState = "loading"
catalogStateReady catalogPresentationState = "ready"
catalogStateUnconfigured catalogPresentationState = "unconfigured"
catalogStateLoadFailed catalogPresentationState = "load_failed"
)
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
payload, handled, err := application.ParseCatalogEvent(event)
if err != nil || !handled {
return handled, err
}
switch payload.Type {
case application.EventCatalogRefreshed:
shell.SetItems(payload.Items)
case application.EventCatalogRejected:
switch payload.FailureCode {
case application.CatalogFailureSourceUnconfigured:
shell.catalogState = catalogStateUnconfigured
case application.CatalogFailureLoadFailed:
shell.catalogState = catalogStateLoadFailed
}
}
return true, nil
}
func (shell *AppShell) catalogStatusText() string {
switch shell.catalogState {
case catalogStateLoading:
return "目录状态:正在加载已验证 Catalog"
case catalogStateUnconfigured:
return "目录状态:Catalog 来源尚未配置"
case catalogStateLoadFailed:
return "目录状态:Catalog 加载失败"
case catalogStateReady:
return "目录状态:已加载已验证 Catalog"
default:
return "目录状态:未知"
}
}
+72
View File
@@ -0,0 +1,72 @@
package gio
import (
"errors"
"testing"
"softbox.local/core/application"
)
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
shell := NewAppShell("Modern")
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
}); err != nil {
t.Fatalf("ApplyEvent(refresh) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(reject) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
}
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
shell := NewAppShell("Modern")
if shell.catalogState != catalogStateLoading {
t.Fatalf("initial state = %q, want loading", shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
}); err != nil {
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
}
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
t.Fatal("unconfigured state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(load failed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
t.Fatal("load-failed state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
}); err != nil {
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
t.Fatal("loaded-empty state was not visible")
}
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
if !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("bad payload error = %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package gio contains the modern Gio UI adapter.
//
// Layout code is rendering-only: it must not read files, access the network,
// calculate hashes, or directly mutate background application state.
package gio
+117
View File
@@ -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
}
+422
View File
@@ -0,0 +1,422 @@
package gio
import (
"context"
"errors"
"image"
"strings"
"testing"
"time"
"softbox.local/core/application"
)
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
reference := testIconReference("11")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
ready, err := application.NewIconReadyEvent(
identity,
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
)
if err != nil {
t.Fatal(err)
}
relay, err := application.NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
submitted := make(chan error, 1)
go func() {
submitted <- relay.Submit(context.Background(), ready)
}()
if err := waitIconSubmit(submitted); err != nil {
t.Fatalf("Submit() error = %v", err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("background relay changed shell before UI drain")
}
if err := relay.Drain(shell.ApplyEvent); err != nil {
t.Fatalf("Drain() error = %v", err)
}
icon, exists := shell.icons["app-one"]
if !exists || icon.Size() != image.Pt(24, 24) {
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
}
}
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
reference := testIconReference("22")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("stale request inserted an icon")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
t.Fatalf("latest icon size = %v", got)
}
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
if err := shell.ExpectIcon(retryIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("same-resource retry discarded an already valid icon")
}
failed, err := application.NewIconFailedEvent(
retryIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(failed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("matching failure discarded an already valid icon")
}
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
}
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
if err := shell.ExpectIcon(dpiIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI request retained an unmatching image")
}
dpiFailed, err := application.NewIconFailedEvent(
dpiIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(dpiFailed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI failure restored an unmatching image")
}
}
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
oldReference := testIconReference("33")
newReference := testIconReference("44")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: oldReference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
shell.SetItems([]application.CatalogListItem{{
ID: "app-one",
Name: "One",
IconRef: newReference,
}})
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("IconRef change retained the previous image")
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("old IconRef result was reinserted")
}
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the latest request")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("canceled result was applied")
}
shell.SetItems(nil)
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
t.Fatalf("ExpectIcon(removed app) error = %v", err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app was reinserted by a late result")
}
}
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
RequestID: "request",
AppID: "app-one",
Payload: "wrong",
})
if !errors.Is(err, application.ErrInvalidIconEvent) {
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
}
if len(shell.icons) != 0 {
t.Fatal("invalid payload polluted icon state")
}
}
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
reference := testIconReference("55")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
shell.model.Select(item.ID)
nodes := adapterContractLayout(shell, adapterContractViewport)
failure := shell.iconFailures[item.ID]
for _, want := range []string{
"图标缓存安全警告",
unsafeIconCacheMessage,
unsafeIconCacheDiagnostic(failure),
} {
if !adapterContractHasSemantic(nodes, want) {
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
}
}
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
}
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
if err := shell.ExpectIcon(unavailable); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
nodes = adapterContractLayout(shell, adapterContractViewport)
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
}
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
}
}
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
reference := testIconReference("66")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
if err := shell.ExpectIcon(first); err != nil {
t.Fatal(err)
}
firstFailure := applyTestIconFailure(
t,
shell,
first,
application.IconFailureUnsafe,
)
if got := shell.iconFailures[item.ID].Identity; got != first {
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
}
shell.SetItems([]application.CatalogListItem{item})
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnsafe {
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
}
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
if err := shell.ExpectIcon(latest); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("new request retained the previous unsafe diagnostic")
}
if err := shell.ApplyEvent(firstFailure); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("late failure restored a stale diagnostic")
}
ready, err := application.NewIconReadyEvent(
latest,
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(ready); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("ready event retained an unsafe diagnostic")
}
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
if err := shell.ExpectIcon(dpiRequest); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
t.Fatalf("stored failure DPI = %d, want 144", got)
}
newReference := testIconReference("77")
changed := item
changed.IconRef = newReference
shell.SetItems([]application.CatalogListItem{changed})
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("IconRef change retained the unsafe diagnostic")
}
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
if err := shell.ExpectIcon(canceled); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the current request")
}
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("canceled failure created an unsafe diagnostic")
}
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
if err := shell.ExpectIcon(final); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
shell.SetItems(nil)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("removed app retained the unsafe diagnostic")
}
}
func applyTestIconFailure(
t *testing.T,
shell *AppShell,
identity application.IconEventIdentity,
code application.IconFailureCode,
) application.Event {
t.Helper()
event, err := application.NewIconFailedEvent(identity, code)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(event); err != nil {
t.Fatal(err)
}
return event
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
func testIconIdentity(
t *testing.T,
requestID string,
appID string,
reference string,
dpi int,
) application.IconEventIdentity {
t.Helper()
identity, err := application.NewIconEventIdentity(
requestID,
appID,
reference,
dpi,
)
if err != nil {
t.Fatal(err)
}
return identity
}
func waitIconSubmit(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for icon relay")
}
}
+13
View File
@@ -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
}
+43
View File
@@ -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")
}
}
+221
View File
@@ -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
}
+362
View File
@@ -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,
)
})
}),
)
})
}
+246
View File
@@ -0,0 +1,246 @@
package gio
import (
"fmt"
"strings"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
func (shell *AppShell) layoutDetail(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
shell.detailRendered = true
return panel(
gtx,
shellColors.muted,
unit.Dp(10),
layout.UniformInset(unit.Dp(16)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.H6(theme, "软件详情").Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.closeDetail,
"关闭",
false,
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(72),
unit.Dp(12),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, material.H6(theme, item.Name).Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(
theme,
fmt.Sprintf("%s · %s", item.ID, item.Version),
)
label.Color = shellColors.secondary
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "状态", statusLabel(item.Status))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"标签",
fallbackText(strings.Join(item.Tags, " · "), "无"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"简介",
fallbackText(item.Description, "暂无简介"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Reason == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Tutorial == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "教程", item.Tutorial)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Homepage == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "主页", item.Homepage)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, actionLabel(item)+";实际操作将在后续用例接入")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
},
)
}
func (shell *AppShell) layoutUnsafeIconCacheFailure(
gtx layout.Context,
theme *material.Theme,
appID string,
) layout.Dimensions {
failure, exists := shell.iconFailures[appID]
if !exists || failure.Code != application.IconFailureUnsafe ||
failure.Identity.AppID != appID {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedPanel(
gtx,
shellColors.destructive,
shellColors.surface,
unit.Dp(8),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
title := material.Body1(theme, "图标缓存安全警告")
title.Color = shellColors.destructive
return title.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
diagnostic.Color = shellColors.secondary
return diagnostic.Layout(gtx)
}),
)
},
)
})
}
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
return fmt.Sprintf(
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
failure.Code,
failure.Identity.AppID,
unsafeIconCacheLocator(failure.Identity),
)
}
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
digest := strings.TrimPrefix(identity.Reference, "sha256:")
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
}
func actionLabel(item application.CatalogListItem) string {
if item.Reason != "" || item.Status == domain.StatusIncompatible {
return "查看不可用原因"
}
switch item.Status {
case domain.StatusInstalled:
return "查看或启动"
case domain.StatusUpdateAvailable:
return "查看更新"
case domain.StatusRunning:
return "查看运行状态"
case domain.StatusQueued,
domain.StatusDownloading,
domain.StatusVerifying,
domain.StatusExtracting,
domain.StatusInstalling:
return "查看任务"
case domain.StatusFailed, domain.StatusRollbackPending:
return "查看恢复选项"
default:
if item.Installable {
return "查看并安装"
}
return "查看详情"
}
}
func detailField(
gtx layout.Context,
theme *material.Theme,
labelText string,
value string,
) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, labelText)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(material.Body2(theme, value).Layout),
)
})
}
func fallbackText(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func reasonLabel(reason string) string {
switch reason {
case "deprecated":
return "软件已停止发布,不能新装或更新"
case "minimum_os":
return "当前 Windows 版本低于最低要求"
case "architecture":
return "没有适用于当前系统架构的软件包"
default:
return reason
}
}
+152
View File
@@ -0,0 +1,152 @@
package gio
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H4(theme, "SoftBox").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Body2(theme, "发现、安装并更新可信软件")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(48)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(8),
layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
},
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(24))
editor := material.Editor(theme, &shell.search, "搜索名称、软件 ID 或标签")
editor.TextSize = unit.Sp(15)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutCategories(gtx, theme)
}),
)
}
func (shell *AppShell) layoutCategories(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(8)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(14), Right: unit.Dp(14),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
func (shell *AppShell) layoutFooter(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, shell.catalogStatusText())
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, shell.edition+" · Windows 10/11 x64")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}
+115
View File
@@ -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)
}
+154
View File
@@ -0,0 +1,154 @@
package gio
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
// NewTheme creates the accessible semantic palette shared by the modern shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
Bg: shellColors.background,
Fg: shellColors.foreground,
ContrastBg: shellColors.primary,
ContrastFg: shellColors.onPrimary,
}
theme.FingerSize = unit.Dp(44)
return theme
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
+133
View File
@@ -0,0 +1,133 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
var operations op.Ops
size := image.Pt(1080, 720)
context := layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
dimensions := NewAppShell("Modern").Layout(context, NewTheme())
if dimensions.Size != size {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Modern", items...)
context := testContext(image.Pt(1080, 420))
shell.Layout(context, NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Modern", items...)
original := shell.rows["app-two"]
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1080, 720)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app retained prepared icon")
}
icon, exists := shell.icons["app-two"]
if !exists {
t.Fatal("retained app lost its prepared icon")
}
if icon.Size() != image.Pt(24, 24) {
t.Fatalf("retained app icon size = %v", icon.Size())
}
}
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
item := application.CatalogListItem{
ID: "json-parser",
Name: "JSON解析工具",
Description: "格式化并检查 JSON",
Version: "1.2.0",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
Status: domain.StatusInstalled,
Installed: true,
}
shell := NewAppShell("Modern", item)
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
shell.ApplyIcon(item.ID, icon)
shell.model.Select(item.ID)
shell.Layout(testContext(image.Pt(1280, 800)), NewTheme())
if !shell.detailRendered {
t.Fatal("selected app detail was not rendered")
}
if _, exists := shell.icons[item.ID]; !exists {
t.Fatal("ApplyIcon did not retain the prepared image operation")
}
shell.ApplyIcon(item.ID, nil)
if _, exists := shell.icons[item.ID]; exists {
t.Fatal("ApplyIcon(nil) did not remove the image")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
@@ -0,0 +1,22 @@
package main
import (
"context"
"testing"
"softbox.local/core/application"
)
func TestStartCatalogBootstrapPublishesThroughRuntime(t *testing.T) {
runtime := application.NewRuntime(1)
done := startCatalogBootstrap(context.Background(), runtime, application.CatalogSnapshotLoaderFunc(func(context.Context) (application.CatalogSnapshot, error) {
return application.CatalogSnapshot{Source: application.CatalogSourceRemote, Items: []application.CatalogListItem{{ID: "tool", Name: "Tool", Version: "1.0.0"}}}, nil
}))
if err := <-done; err != nil {
t.Fatalf("bootstrap error = %v", err)
}
event := <-runtime.Events()
if event.Type != application.EventCatalogRefreshed {
t.Fatalf("event type = %q", event.Type)
}
}
+165
View File
@@ -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
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
"softbox.local/app-win7/platform/windows"
"softbox.local/core/updater"
)
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
return false, nil
}
if len(arguments) != 2 {
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
}
executable, err := os.Executable()
if err != nil {
return true, fmt.Errorf("locate current executable: %w", err)
}
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
return true, fmt.Errorf("acknowledge self-update health: %w", err)
}
return true, nil
}
@@ -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)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"softbox.local/app-win7/platform/windows"
"softbox.local/core/updater"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(arguments []string) error {
for _, option := range []string{"--pid", "--staging", "--target"} {
if err := requireOneOption(arguments, option); err != nil {
return err
}
}
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
flags.SetOutput(io.Discard)
pid := flags.Int("pid", 0, "main SoftBox PID")
staging := flags.String("staging", "", "prepared staging directory")
target := flags.String("target", "", "fixed app target directory")
if err := flags.Parse(arguments); err != nil {
return fmt.Errorf("parse updater arguments: %w", err)
}
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
}
requestID := filepath.Base(filepath.Clean(*staging))
platform := windows.New()
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
ParentExit: 2 * time.Minute,
Health: 45 * time.Second,
})
return service.Update(context.Background(), updater.Request{
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
})
}
func requireOneOption(arguments []string, option string) error {
count := 0
for _, argument := range arguments {
if argument == option || strings.HasPrefix(argument, option+"=") {
count++
}
}
if count != 1 {
return fmt.Errorf("%s must appear exactly once", option)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package main
import "testing"
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
if err := run(nil); err == nil {
t.Fatal("run(nil) succeeded")
}
if err := run([]string{
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
}); err == nil {
t.Fatal("run() accepted duplicate --pid")
}
}
+9
View File
@@ -0,0 +1,9 @@
module softbox.local/app-win7
go 1.20
require (
gioui.org v0.6.0
golang.org/x/sys v0.5.0
softbox.local/core v0.0.0
)
+8
View File
@@ -0,0 +1,8 @@
go 1.20
use (
.
../core
)
replace softbox.local/core v0.0.0 => ../core
+11
View File
@@ -0,0 +1,11 @@
gioui.org v0.6.0 h1:ZSXO/AbpFZJ2L9NU69uFQfDI3BKIH+YEJElrn0B+aZI=
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
@@ -0,0 +1,19 @@
package windows
import "fmt"
func versionSupports(minOS string, major, minor, build, servicePack uint32) (bool, error) {
switch minOS {
case "windows-7-sp1":
if major > 6 || (major == 6 && minor > 1) {
return true, nil
}
return major == 6 && minor == 1 && servicePack >= 1, nil
case "windows-10":
return major >= 10, nil
case "windows-11":
return major >= 10 && build >= 22000, nil
default:
return false, fmt.Errorf("unsupported minimum Windows release %q", minOS)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package windows provides Win7-compatible platform adapters and non-Windows
// stubs for package-level tests.
package windows
+33
View File
@@ -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'
}
+38
View File
@@ -0,0 +1,38 @@
package windows
import (
"context"
"errors"
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
// Edition identifies the application build channel shown by the UI.
type Edition string
const EditionLegacy Edition = "Legacy"
// ErrUnsupported reports that a Windows-only capability is unavailable on the
// current host. Callers must treat it as an unknown state, never as a stopped
// process or a compatible system.
var ErrUnsupported = errors.New("windows platform capability is unsupported on this host")
// Platform is the minimal boundary for target-specific capabilities.
type Platform interface {
OS() string
Edition() Edition
IsCompatible(minOS string) (bool, error)
IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, error)
WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error
StartSelfUpdate(command updater.StartCommand) (int, error)
SyncDirectory(path string) error
}
// New returns the platform implementation selected by build tags.
func New() Platform {
return newPlatform()
}
@@ -0,0 +1,54 @@
//go:build !windows
package windows
import (
"context"
"runtime"
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
type platformStub struct{}
func newPlatform() Platform {
return platformStub{}
}
func (platformStub) OS() string {
return runtime.GOOS
}
func (platformStub) Edition() Edition {
return EditionLegacy
}
func (platformStub) IsCompatible(string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) IsRunning(string, string) (bool, error) {
return false, ErrUnsupported
}
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
return ErrUnsupported
}
func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error {
return ErrUnsupported
}
func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) SyncDirectory(string) error {
return ErrUnsupported
}
@@ -0,0 +1,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)
}
}
+167
View File
@@ -0,0 +1,167 @@
package windows
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
func TestPlatformStubContract(t *testing.T) {
platform := New()
if platform.OS() == "" {
t.Fatal("OS() should not be empty")
}
if platform.Edition() != EditionLegacy {
t.Fatalf("Edition() = %q, want %q", platform.Edition(), EditionLegacy)
}
}
func TestEntrypointIsRunningUsesFullPathIdentity(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "one", "App.exe")
collision := filepath.Join(root, "two", "App.exe")
running, err := entrypointIsRunning(target, func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{path: collision}}}, nil
})
if err != nil {
t.Fatalf("entrypointIsRunning() error = %v", err)
}
if running {
t.Fatal("entrypointIsRunning() matched a same-basename executable in another directory")
}
running, err = entrypointIsRunning(target, func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{path: collision}, {path: target}}}, nil
})
if err != nil || !running {
t.Fatalf("entrypointIsRunning() = (%v, %v), want (true, nil)", running, err)
}
}
func TestEntrypointIsRunningFailsClosedForSnapshotErrors(t *testing.T) {
expected := errors.New("snapshot failed")
_, err := entrypointIsRunning("App.exe", func() (processSnapshot, error) {
return nil, expected
})
if !errors.Is(err, expected) {
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
}
_, err = entrypointIsRunning("App.exe", func() (processSnapshot, error) {
return &scriptedSnapshot{items: []snapshotItem{{err: expected}}}, nil
})
if !errors.Is(err, expected) {
t.Fatalf("entrypointIsRunning() error = %v, want %v", err, expected)
}
}
func TestVersionSupports(t *testing.T) {
cases := []struct {
minOS string
major, minor, build, service uint32
want bool
wantErr bool
}{
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 1, want: true},
{minOS: "windows-7-sp1", major: 6, minor: 1, service: 0, want: false},
{minOS: "windows-10", major: 10, want: true},
{minOS: "windows-11", major: 10, build: 19045, want: false},
{minOS: "windows-11", major: 10, build: 22000, want: true},
{minOS: "unknown", wantErr: true},
}
for _, test := range cases {
got, err := versionSupports(test.minOS, test.major, test.minor, test.build, test.service)
if (err != nil) != test.wantErr || got != test.want {
t.Fatalf("versionSupports(%q, %d, %d, %d, %d) = (%v, %v), want (%v, error=%v)", test.minOS, test.major, test.minor, test.build, test.service, got, err, test.want, test.wantErr)
}
}
}
func TestWaitForExit(t *testing.T) {
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
errSnapshot := errors.New("snapshot failed")
tests := []struct {
name string
ctx context.Context
running []bool
runErr error
timeout time.Duration
wantErr error
wantSleeps int
}{
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
clock := &fakeExitWaitClock{now: initial}
index := 0
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
if test.runErr != nil {
return false, test.runErr
}
if index >= len(test.running) {
return test.running[len(test.running)-1], nil
}
running := test.running[index]
index++
return running, nil
}, clock)
if !errors.Is(err, test.wantErr) {
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
}
if clock.sleeps != test.wantSleeps {
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
}
})
}
}
func canceledWaitContext() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeExitWaitClock struct {
now time.Time
sleeps int
}
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
clock.sleeps++
clock.now = clock.now.Add(duration)
return nil
}
type snapshotItem struct {
path string
err error
}
type scriptedSnapshot struct {
items []snapshotItem
next int
}
func (snapshot *scriptedSnapshot) NextImagePath() (string, bool, error) {
if snapshot.next == len(snapshot.items) {
return "", false, nil
}
item := snapshot.items[snapshot.next]
snapshot.next++
return item.path, true, item.err
}
func (*scriptedSnapshot) Close() error { return nil }
@@ -0,0 +1,220 @@
//go:build windows
package windows
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"unsafe"
"golang.org/x/sys/windows"
"softbox.local/core/application/launch"
)
const seeMaskNoCloseProcess = 0x00000040
var (
procRtlGetVersion = windows.NewLazySystemDLL("ntdll.dll").NewProc("RtlGetVersion")
procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
)
type platform struct{}
func newPlatform() Platform {
return platform{}
}
func (platform) OS() string {
return "windows"
}
func (platform) Edition() Edition {
return EditionLegacy
}
func (platform) IsCompatible(minOS string) (bool, error) {
version, err := currentVersion()
if err != nil {
return false, err
}
return versionSupports(minOS, version.major, version.minor, version.build, uint32(version.servicePack))
}
func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
return entrypointIsRunning(entrypoint, func() (processSnapshot, error) {
return newToolhelpSnapshot(filepath.Base(entrypoint))
})
}
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
return waitForExit(ctx, timeout, func() (bool, error) {
return target.IsRunning(appID, entrypoint)
}, systemExitWaitClock{})
}
func (platform) Start(command launch.Command) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("launch command must contain absolute paths")
}
if command.RequiresAdmin {
return startElevated(command)
}
cmd := exec.Command(command.Entrypoint)
cmd.Dir = command.WorkingDirectory
if err := cmd.Start(); err != nil {
return 0, err
}
return cmd.Process.Pid, nil
}
type toolhelpSnapshot struct {
handle windows.Handle
targetName string
entry windows.ProcessEntry32
started bool
}
func newToolhelpSnapshot(targetName string) (processSnapshot, error) {
handle, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, err
}
return &toolhelpSnapshot{handle: handle, targetName: targetName}, nil
}
func (snapshot *toolhelpSnapshot) NextImagePath() (string, bool, error) {
for {
var err error
if !snapshot.started {
snapshot.entry.Size = uint32(unsafe.Sizeof(snapshot.entry))
err = windows.Process32First(snapshot.handle, &snapshot.entry)
snapshot.started = true
} else {
err = windows.Process32Next(snapshot.handle, &snapshot.entry)
}
if err != nil {
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return "", false, nil
}
return "", false, err
}
// ExeFile only narrows the expensive query. The identity decision below
// always uses QueryFullProcessImageName's normalized full path.
if !strings.EqualFold(windows.UTF16ToString(snapshot.entry.ExeFile[:]), snapshot.targetName) {
continue
}
path, err := fullProcessImagePath(snapshot.entry.ProcessID)
if err != nil {
return "", false, err
}
return path, true, nil
}
}
func (snapshot *toolhelpSnapshot) Close() error {
return windows.CloseHandle(snapshot.handle)
}
func fullProcessImagePath(pid uint32) (string, error) {
process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return "", err
}
defer windows.CloseHandle(process)
for size := uint32(260); size <= 32768; size *= 2 {
buffer := make([]uint16, size)
length := size
err = windows.QueryFullProcessImageName(process, 0, &buffer[0], &length)
if err == nil {
return windows.UTF16ToString(buffer[:length]), nil
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return "", err
}
}
return "", fmt.Errorf("process image path exceeds 32768 UTF-16 code units")
}
type rtlOSVersionInfoEx struct {
size uint32
major uint32
minor uint32
build uint32
platformID uint32
csdVersion [128]uint16
servicePack uint16
servicePackMinor uint16
suiteMask uint16
productType byte
reserved byte
}
func currentVersion() (rtlOSVersionInfoEx, error) {
version := rtlOSVersionInfoEx{size: uint32(unsafe.Sizeof(rtlOSVersionInfoEx{}))}
status, _, _ := procRtlGetVersion.Call(uintptr(unsafe.Pointer(&version)))
if status != 0 {
return rtlOSVersionInfoEx{}, fmt.Errorf("RtlGetVersion failed with status 0x%x", status)
}
return version, nil
}
type shellExecuteInfo struct {
size uint32
mask uint32
hwnd uintptr
verb *uint16
file *uint16
parameters *uint16
directory *uint16
show int32
instance uintptr
idList uintptr
class *uint16
keyClass uintptr
hotKey uint32
icon uintptr
process windows.Handle
}
func startElevated(command launch.Command) (int, error) {
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return 0, err
}
file, err := windows.UTF16PtrFromString(command.Entrypoint)
if err != nil {
return 0, err
}
directory, err := windows.UTF16PtrFromString(command.WorkingDirectory)
if err != nil {
return 0, err
}
info := shellExecuteInfo{
size: uint32(unsafe.Sizeof(shellExecuteInfo{})),
mask: seeMaskNoCloseProcess,
verb: verb,
file: file,
directory: directory,
show: 1,
}
result, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
if result == 0 {
return 0, fmt.Errorf("ShellExecuteExW failed: %w", callErr)
}
if info.process == 0 {
return 0, fmt.Errorf("ShellExecuteExW did not return a process handle")
}
defer windows.CloseHandle(info.process)
pid, err := windows.GetProcessId(info.process)
if err != nil {
return 0, err
}
return int(pid), nil
}
+56
View File
@@ -0,0 +1,56 @@
package windows
import (
"fmt"
"path/filepath"
"strings"
)
// processSnapshot is deliberately small so the matching invariant can be
// tested without a Windows host. NextImagePath returns more=false only after a
// successful end-of-snapshot; any enumeration or image-path failure is an
// error, rather than evidence that the target is not running.
type processSnapshot interface {
NextImagePath() (path string, more bool, err error)
Close() error
}
type processSnapshotFactory func() (processSnapshot, error)
func entrypointIsRunning(entrypoint string, newSnapshot processSnapshotFactory) (bool, error) {
target, err := canonicalProcessPath(entrypoint)
if err != nil {
return false, fmt.Errorf("canonicalize target entrypoint: %w", err)
}
snapshot, err := newSnapshot()
if err != nil {
return false, fmt.Errorf("create process snapshot: %w", err)
}
defer snapshot.Close()
for {
path, more, err := snapshot.NextImagePath()
if err != nil {
return false, fmt.Errorf("enumerate process image path: %w", err)
}
if !more {
return false, nil
}
candidate, err := canonicalProcessPath(path)
if err != nil {
return false, fmt.Errorf("canonicalize process image path: %w", err)
}
if strings.EqualFold(target, candidate) {
return true, nil
}
}
}
func canonicalProcessPath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
+49
View File
@@ -0,0 +1,49 @@
package windows
import (
"context"
"fmt"
"time"
)
type pidWaitHandle interface {
Wait(time.Duration) (bool, error)
Close() error
}
type pidOpener func(int) (pidWaitHandle, error)
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
if pid <= 0 {
return fmt.Errorf("process PID must be positive")
}
if timeout <= 0 {
return fmt.Errorf("process wait timeout must be positive")
}
handle, err := open(pid)
if err != nil {
return fmt.Errorf("open process %d: %w", pid, err)
}
defer handle.Close()
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
if err := ctx.Err(); err != nil {
return err
}
exited, err := handle.Wait(250 * time.Millisecond)
if err != nil {
return fmt.Errorf("wait for process %d: %w", pid, err)
}
if exited {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return context.DeadlineExceeded
default:
}
}
}
@@ -0,0 +1,49 @@
package windows
import (
"context"
"errors"
"testing"
"time"
)
type fakePIDHandle struct {
exited bool
waitErr error
closed bool
}
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
handle := &fakePIDHandle{exited: true}
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
if pid != 9 {
t.Fatalf("PID = %d, want 9", pid)
}
return handle, nil
})
if err != nil || !handle.closed {
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
}
}
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
openErr := errors.New("access denied")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
t.Fatalf("open error = %v", err)
}
waitErr := errors.New("wait failed")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
t.Fatalf("wait error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("timeout error = %v", err)
}
}
@@ -0,0 +1,88 @@
//go:build windows
package windows
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
"softbox.local/core/updater"
)
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
}
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("self-update launch paths must be absolute")
}
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
command.HealthRequestID == "" {
return 0, fmt.Errorf("invalid fixed self-update launch command")
}
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
commandLine.Dir = command.WorkingDirectory
if err := commandLine.Start(); err != nil {
return 0, err
}
return commandLine.Process.Pid, nil
}
func (platform) SyncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path: %w", err)
}
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
if err != nil {
return fmt.Errorf("open directory handle: %w", err)
}
if err := syscall.FlushFileBuffers(handle); err != nil {
_ = syscall.CloseHandle(handle)
return fmt.Errorf("flush directory handle: %w", err)
}
if err := syscall.CloseHandle(handle); err != nil {
return fmt.Errorf("close directory handle: %w", err)
}
return nil
}
type windowsPIDHandle struct{ handle windows.Handle }
func openWindowsProcess(pid int) (pidWaitHandle, error) {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
return nil, err
}
return windowsPIDHandle{handle: handle}, nil
}
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
milliseconds := uint32(timeout / time.Millisecond)
if milliseconds == 0 {
milliseconds = 1
}
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
if err != nil {
return false, err
}
switch result {
case windows.WAIT_OBJECT_0:
return true, nil
case uint32(windows.WAIT_TIMEOUT):
return false, nil
default:
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
}
}
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }
+64
View File
@@ -0,0 +1,64 @@
package windows
import (
"context"
"fmt"
"time"
)
const exitPollInterval = 250 * time.Millisecond
type exitWaitClock interface {
Now() time.Time
Wait(context.Context, time.Duration) error
}
type systemExitWaitClock struct{}
func (systemExitWaitClock) Now() time.Time {
return time.Now()
}
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
if ctx == nil {
return fmt.Errorf("wait context is required")
}
if timeout <= 0 {
return fmt.Errorf("exit timeout must be positive")
}
if err := ctx.Err(); err != nil {
return err
}
deadline := clock.Now().Add(timeout)
for {
isRunning, err := running()
if err != nil {
return err
}
if !isRunning {
return nil
}
remaining := deadline.Sub(clock.Now())
if remaining <= 0 {
return context.DeadlineExceeded
}
interval := exitPollInterval
if remaining < interval {
interval = remaining
}
if err := clock.Wait(ctx, interval); err != nil {
return err
}
}
}
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1024, 680)
adapterContractCompactViewport = image.Pt(1024, 380)
)
const adapterContractEdition = "Legacy"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "正在加载软件目录") {
t.Fatal("empty catalog did not render the catalog-loading state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+46
View File
@@ -0,0 +1,46 @@
package gio
import "softbox.local/core/application"
type catalogPresentationState string
const (
catalogStateLoading catalogPresentationState = "loading"
catalogStateReady catalogPresentationState = "ready"
catalogStateUnconfigured catalogPresentationState = "unconfigured"
catalogStateLoadFailed catalogPresentationState = "load_failed"
)
func (shell *AppShell) applyCatalogEvent(event application.Event) (bool, error) {
payload, handled, err := application.ParseCatalogEvent(event)
if err != nil || !handled {
return handled, err
}
switch payload.Type {
case application.EventCatalogRefreshed:
shell.SetItems(payload.Items)
case application.EventCatalogRejected:
switch payload.FailureCode {
case application.CatalogFailureSourceUnconfigured:
shell.catalogState = catalogStateUnconfigured
case application.CatalogFailureLoadFailed:
shell.catalogState = catalogStateLoadFailed
}
}
return true, nil
}
func (shell *AppShell) catalogStatusText() string {
switch shell.catalogState {
case catalogStateLoading:
return "目录状态:正在加载已验证 Catalog"
case catalogStateUnconfigured:
return "目录状态:Catalog 来源尚未配置"
case catalogStateLoadFailed:
return "目录状态:Catalog 加载失败"
case catalogStateReady:
return "目录状态:已加载已验证 Catalog"
default:
return "目录状态:未知"
}
}
+72
View File
@@ -0,0 +1,72 @@
package gio
import (
"errors"
"testing"
"softbox.local/core/application"
)
func TestCatalogEventsUpdateSnapshotAndRetainItOnFailure(t *testing.T) {
shell := NewAppShell("Legacy")
items := []application.CatalogListItem{{ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Category: "工具"}}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceCache, Items: items},
}); err != nil {
t.Fatalf("ApplyEvent(refresh) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateReady {
t.Fatalf("snapshot count/state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(reject) error = %v", err)
}
if shell.model.TotalCount() != 1 || shell.catalogState != catalogStateLoadFailed {
t.Fatalf("failure cleared snapshot or state = %d/%q", shell.model.TotalCount(), shell.catalogState)
}
}
func TestCatalogEventsExposeStableEmptyStatesAndRejectBadPayload(t *testing.T) {
shell := NewAppShell("Legacy")
if shell.catalogState != catalogStateLoading {
t.Fatalf("initial state = %q, want loading", shell.catalogState)
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureSourceUnconfigured},
}); err != nil {
t.Fatalf("ApplyEvent(unconfigured) error = %v", err)
}
if shell.catalogState != catalogStateUnconfigured || shell.catalogStatusText() != "目录状态:Catalog 来源尚未配置" {
t.Fatalf("unconfigured state/status = %q/%q", shell.catalogState, shell.catalogStatusText())
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 来源尚未配置") {
t.Fatal("unconfigured state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRejected,
Payload: application.CatalogEvent{Type: application.EventCatalogRejected, FailureCode: application.CatalogFailureLoadFailed},
}); err != nil {
t.Fatalf("ApplyEvent(load failed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 加载失败") {
t.Fatal("load-failed state was not visible")
}
if err := shell.ApplyEvent(application.Event{
Type: application.EventCatalogRefreshed,
Payload: application.CatalogEvent{Type: application.EventCatalogRefreshed, Source: application.CatalogSourceRemote},
}); err != nil {
t.Fatalf("ApplyEvent(empty refreshed) error = %v", err)
}
if nodes := adapterContractLayout(shell, adapterContractViewport); !adapterContractHasSemantic(nodes, "Catalog 暂无可显示软件") {
t.Fatal("loaded-empty state was not visible")
}
err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed, Payload: "raw error"})
if !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("bad payload error = %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package gio contains the Win7-compatible Gio UI adapter.
//
// Layout code is rendering-only: it must not read files, access the network,
// calculate hashes, or directly mutate background application state.
package gio
+117
View File
@@ -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
}
+422
View File
@@ -0,0 +1,422 @@
package gio
import (
"context"
"errors"
"image"
"strings"
"testing"
"time"
"softbox.local/core/application"
)
func TestIconEventRelayAppliesOnlyDuringUIDrain(t *testing.T) {
reference := testIconReference("11")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
identity := testIconIdentity(t, "request-one", "app-one", reference, 96)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
ready, err := application.NewIconReadyEvent(
identity,
image.NewNRGBA(image.Rect(0, 0, 24, 24)),
)
if err != nil {
t.Fatal(err)
}
relay, err := application.NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
submitted := make(chan error, 1)
go func() {
submitted <- relay.Submit(context.Background(), ready)
}()
if err := waitIconSubmit(submitted); err != nil {
t.Fatalf("Submit() error = %v", err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("background relay changed shell before UI drain")
}
if err := relay.Drain(shell.ApplyEvent); err != nil {
t.Fatalf("Drain() error = %v", err)
}
icon, exists := shell.icons["app-one"]
if !exists || icon.Size() != image.Pt(24, 24) {
t.Fatalf("applied icon = (%t, %v)", exists, icon.Size())
}
}
func TestAppShellAcceptsOnlyLatestIconRequest(t *testing.T) {
reference := testIconReference("22")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: reference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", reference, 96)
newIdentity := testIconIdentity(t, "request-new", "app-one", reference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 12, 12)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("stale request inserted an icon")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 30, 30)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if got := shell.icons["app-one"].Size(); got != image.Pt(30, 30) {
t.Fatalf("latest icon size = %v", got)
}
retryIdentity := testIconIdentity(t, "request-retry", "app-one", reference, 96)
if err := shell.ExpectIcon(retryIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("same-resource retry discarded an already valid icon")
}
failed, err := application.NewIconFailedEvent(
retryIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(failed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; !exists {
t.Fatal("matching failure discarded an already valid icon")
}
if failure, exists := shell.IconFailure("app-one"); !exists || failure != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", failure, exists)
}
dpiIdentity := testIconIdentity(t, "request-dpi", "app-one", reference, 144)
if err := shell.ExpectIcon(dpiIdentity); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI request retained an unmatching image")
}
dpiFailed, err := application.NewIconFailedEvent(
dpiIdentity,
application.IconFailureUnavailable,
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(dpiFailed); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("different-DPI failure restored an unmatching image")
}
}
func TestAppShellDropsChangedRemovedAndCanceledIconResults(t *testing.T) {
oldReference := testIconReference("33")
newReference := testIconReference("44")
shell := NewAppShell("Test", application.CatalogListItem{
ID: "app-one",
Name: "One",
IconRef: oldReference,
})
oldIdentity := testIconIdentity(t, "request-old", "app-one", oldReference, 96)
if err := shell.ExpectIcon(oldIdentity); err != nil {
t.Fatal(err)
}
oldReady, err := application.NewIconReadyEvent(
oldIdentity,
image.NewNRGBA(image.Rect(0, 0, 20, 20)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
shell.SetItems([]application.CatalogListItem{{
ID: "app-one",
Name: "One",
IconRef: newReference,
}})
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("IconRef change retained the previous image")
}
if err := shell.ApplyEvent(oldReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("old IconRef result was reinserted")
}
newIdentity := testIconIdentity(t, "request-new", "app-one", newReference, 96)
if err := shell.ExpectIcon(newIdentity); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest("app-one", newIdentity.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the latest request")
}
newReady, err := application.NewIconReadyEvent(
newIdentity,
image.NewNRGBA(image.Rect(0, 0, 22, 22)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("canceled result was applied")
}
shell.SetItems(nil)
if err := shell.ExpectIcon(newIdentity); !errors.Is(err, ErrIconRequestStale) {
t.Fatalf("ExpectIcon(removed app) error = %v", err)
}
if err := shell.ApplyEvent(newReady); err != nil {
t.Fatal(err)
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app was reinserted by a late result")
}
}
func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
shell := NewAppShell("Test")
if err := shell.ApplyEvent(application.Event{Type: application.EventCatalogRefreshed}); !errors.Is(err, application.ErrCatalogEventPayload) {
t.Fatalf("ApplyEvent(malformed catalog payload) error = %v", err)
}
err := shell.ApplyEvent(application.Event{
Type: application.EventIconReady,
RequestID: "request",
AppID: "app-one",
Payload: "wrong",
})
if !errors.Is(err, application.ErrInvalidIconEvent) {
t.Fatalf("ApplyEvent(invalid payload) error = %v", err)
}
if len(shell.icons) != 0 {
t.Fatal("invalid payload polluted icon state")
}
}
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
reference := testIconReference("55")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
if err := shell.ExpectIcon(identity); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
shell.model.Select(item.ID)
nodes := adapterContractLayout(shell, adapterContractViewport)
failure := shell.iconFailures[item.ID]
for _, want := range []string{
"图标缓存安全警告",
unsafeIconCacheMessage,
unsafeIconCacheDiagnostic(failure),
} {
if !adapterContractHasSemantic(nodes, want) {
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
}
}
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
}
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
if err := shell.ExpectIcon(unavailable); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
nodes = adapterContractLayout(shell, adapterContractViewport)
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
}
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnavailable {
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
}
}
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
reference := testIconReference("66")
item := application.CatalogListItem{
ID: "app-one",
Name: "One",
Version: "1.0.0",
IconRef: reference,
}
shell := NewAppShell("Test", item)
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
if err := shell.ExpectIcon(first); err != nil {
t.Fatal(err)
}
firstFailure := applyTestIconFailure(
t,
shell,
first,
application.IconFailureUnsafe,
)
if got := shell.iconFailures[item.ID].Identity; got != first {
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
}
shell.SetItems([]application.CatalogListItem{item})
if code, exists := shell.IconFailure(item.ID); !exists ||
code != application.IconFailureUnsafe {
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
}
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
if err := shell.ExpectIcon(latest); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("new request retained the previous unsafe diagnostic")
}
if err := shell.ApplyEvent(firstFailure); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("late failure restored a stale diagnostic")
}
ready, err := application.NewIconReadyEvent(
latest,
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(ready); err != nil {
t.Fatal(err)
}
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("ready event retained an unsafe diagnostic")
}
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
if err := shell.ExpectIcon(dpiRequest); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
t.Fatalf("stored failure DPI = %d, want 144", got)
}
newReference := testIconReference("77")
changed := item
changed.IconRef = newReference
shell.SetItems([]application.CatalogListItem{changed})
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("IconRef change retained the unsafe diagnostic")
}
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
if err := shell.ExpectIcon(canceled); err != nil {
t.Fatal(err)
}
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
t.Fatal("CancelIconRequest() did not cancel the current request")
}
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("canceled failure created an unsafe diagnostic")
}
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
if err := shell.ExpectIcon(final); err != nil {
t.Fatal(err)
}
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
shell.SetItems(nil)
if _, exists := shell.IconFailure(item.ID); exists {
t.Fatal("removed app retained the unsafe diagnostic")
}
}
func applyTestIconFailure(
t *testing.T,
shell *AppShell,
identity application.IconEventIdentity,
code application.IconFailureCode,
) application.Event {
t.Helper()
event, err := application.NewIconFailedEvent(identity, code)
if err != nil {
t.Fatal(err)
}
if err := shell.ApplyEvent(event); err != nil {
t.Fatal(err)
}
return event
}
func testIconReference(pair string) string {
return "sha256:" + strings.Repeat(pair, 32)
}
func testIconIdentity(
t *testing.T,
requestID string,
appID string,
reference string,
dpi int,
) application.IconEventIdentity {
t.Helper()
identity, err := application.NewIconEventIdentity(
requestID,
appID,
reference,
dpi,
)
if err != nil {
t.Fatal(err)
}
return identity
}
func waitIconSubmit(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for icon relay")
}
}
+13
View File
@@ -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
}
+43
View File
@@ -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")
}
}
+225
View File
@@ -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
}
+335
View File
@@ -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,
)
})
}),
)
})
}
+217
View File
@@ -0,0 +1,217 @@
package gio
import (
"fmt"
"strings"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
)
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
func (shell *AppShell) layoutDetail(
gtx layout.Context,
theme *material.Theme,
item application.CatalogListItem,
) layout.Dimensions {
shell.detailRendered = true
return panel(
gtx,
shellColors.muted,
unit.Dp(6),
layout.UniformInset(unit.Dp(12)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(material.Body1(theme, "软件详情").Layout),
layout.Flexed(1, layout.Spacer{}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
&shell.closeDetail,
"关闭",
false,
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutAppIcon(
gtx,
theme,
item.ID,
item.Name,
unit.Dp(64),
unit.Dp(7),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, material.Body1(theme, item.Name).Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(
theme,
fmt.Sprintf("%s · %s", item.ID, item.Version),
)
label.Color = shellColors.secondary
return layout.Center.Layout(gtx, label.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "状态", statusLabel(item.Status))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(gtx, theme, "分类", fallbackText(item.Category, "未分类"))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"标签",
fallbackText(strings.Join(item.Tags, " · "), "无"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return detailField(
gtx,
theme,
"简介",
fallbackText(item.Description, "暂无简介"),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Reason == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "不可用原因", reasonLabel(item.Reason))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Tutorial == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "教程", item.Tutorial)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if item.Homepage == "" {
return layout.Dimensions{}
}
return detailField(gtx, theme, "主页", item.Homepage)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "实际安装/启动操作将在后续用例接入")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
},
)
}
func (shell *AppShell) layoutUnsafeIconCacheFailure(
gtx layout.Context,
theme *material.Theme,
appID string,
) layout.Dimensions {
failure, exists := shell.iconFailures[appID]
if !exists || failure.Code != application.IconFailureUnsafe ||
failure.Identity.AppID != appID {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return outlinedPanel(
gtx,
shellColors.destructive,
shellColors.surface,
unit.Dp(6),
layout.UniformInset(unit.Dp(10)),
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
title := material.Body1(theme, "图标缓存安全警告")
title.Color = shellColors.destructive
return title.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
diagnostic.Color = shellColors.secondary
return diagnostic.Layout(gtx)
}),
)
},
)
})
}
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
return fmt.Sprintf(
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
failure.Code,
failure.Identity.AppID,
unsafeIconCacheLocator(failure.Identity),
)
}
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
digest := strings.TrimPrefix(identity.Reference, "sha256:")
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
}
func detailField(
gtx layout.Context,
theme *material.Theme,
labelText string,
value string,
) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, labelText)
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(theme, value).Layout),
)
})
}
func fallbackText(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func reasonLabel(reason string) string {
switch reason {
case "deprecated":
return "软件已停止发布"
case "minimum_os":
return "Windows 版本低于最低要求"
case "architecture":
return "没有当前架构的软件包"
default:
return reason
}
}
+124
View File
@@ -0,0 +1,124 @@
package gio
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"softbox.local/core/application"
)
func (shell *AppShell) layoutHeader(
gtx layout.Context,
theme *material.Theme,
) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(
gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H5(theme, "SoftBox Legacy").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := material.Caption(theme, "兼容 Windows 7 SP1 的可信软件目录")
label.Color = shellColors.secondary
return label.Layout(gtx)
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
border := shellColors.border
if gtx.Focused(&shell.search) {
border = shellColors.primary
}
return outlinedPanel(
gtx,
border,
shellColors.surface,
unit.Dp(6),
layout.Inset{
Top: unit.Dp(9), Bottom: unit.Dp(9),
Left: unit.Dp(12), Right: unit.Dp(12),
},
func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(theme, &shell.search, "搜索名称、ID 或标签")
editor.TextSize = unit.Sp(14)
return editor.Layout(gtx)
},
)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
categories := append([]string{""}, shell.model.Categories()...)
height := gtx.Dp(unit.Dp(44))
gtx.Constraints.Min.Y = height
gtx.Constraints.Max.Y = height
return shell.categoryList.Layout(gtx, len(categories), func(
gtx layout.Context,
index int,
) layout.Dimensions {
category := categories[index]
label := category
if label == "" {
label = "全部分类"
}
return layout.Inset{Right: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return shell.layoutFilterButton(
gtx,
theme,
shell.categoryControls[category],
label,
shell.model.Category() == category,
)
})
})
}),
)
}
func (shell *AppShell) layoutViewButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
view application.CatalogView,
) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
clickable,
label,
shell.model.View() == view,
)
}
func (shell *AppShell) layoutFilterButton(
gtx layout.Context,
theme *material.Theme,
clickable *widget.Clickable,
label string,
active bool,
) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, clickable, label)
button.CornerRadius = unit.Dp(5)
button.Inset = layout.Inset{
Top: unit.Dp(10), Bottom: unit.Dp(10),
Left: unit.Dp(12), Right: unit.Dp(12),
}
if active {
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
} else {
button.Background = shellColors.muted
button.Color = shellColors.foreground
}
return button.Layout(gtx)
}
+115
View File
@@ -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)
}
+154
View File
@@ -0,0 +1,154 @@
package gio
import (
"image"
"image/color"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var shellColors = struct {
background color.NRGBA
surface color.NRGBA
muted color.NRGBA
foreground color.NRGBA
secondary color.NRGBA
primary color.NRGBA
onPrimary color.NRGBA
border color.NRGBA
success color.NRGBA
warning color.NRGBA
destructive color.NRGBA
}{
background: color.NRGBA{R: 248, G: 250, B: 252, A: 255},
surface: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
muted: color.NRGBA{R: 240, G: 248, B: 246, A: 255},
foreground: color.NRGBA{R: 15, G: 23, B: 42, A: 255},
secondary: color.NRGBA{R: 71, G: 85, B: 105, A: 255},
primary: color.NRGBA{R: 5, G: 150, B: 105, A: 255},
onPrimary: color.NRGBA{R: 255, G: 255, B: 255, A: 255},
border: color.NRGBA{R: 209, G: 229, B: 223, A: 255},
success: color.NRGBA{R: 4, G: 120, B: 87, A: 255},
warning: color.NRGBA{R: 180, G: 83, B: 9, A: 255},
destructive: color.NRGBA{R: 185, G: 28, B: 28, A: 255},
}
// NewTheme creates the accessible palette shared by the Legacy shell.
func NewTheme() *material.Theme {
theme := material.NewTheme()
theme.Palette = material.Palette{
Bg: shellColors.background,
Fg: shellColors.foreground,
ContrastBg: shellColors.primary,
ContrastFg: shellColors.onPrimary,
}
theme.FingerSize = unit.Dp(44)
return theme
}
func panel(
gtx layout.Context,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return layout.Background{}.Layout(
gtx,
func(gtx layout.Context) layout.Dimensions {
paint.FillShape(
gtx.Ops,
background,
clip.UniformRRect(
image.Rectangle{Max: gtx.Constraints.Min},
gtx.Dp(radius),
).Op(gtx.Ops),
)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, content)
},
)
}
func outlinedPanel(
gtx layout.Context,
border color.NRGBA,
background color.NRGBA,
radius unit.Dp,
inset layout.Inset,
content layout.Widget,
) layout.Dimensions {
return panel(
gtx,
border,
radius,
layout.UniformInset(unit.Dp(1)),
func(gtx layout.Context) layout.Dimensions {
return panel(gtx, background, radius-unit.Dp(1), inset, content)
},
)
}
func viewTitle(view application.CatalogView) string {
switch view {
case application.CatalogViewInstalled:
return "已安装软件"
case application.CatalogViewUpdates:
return "可更新软件"
default:
return "全部软件"
}
}
func statusLabel(status domain.AppStatus) string {
switch status {
case domain.StatusQueued:
return "排队中"
case domain.StatusDownloading:
return "下载中"
case domain.StatusVerifying:
return "校验中"
case domain.StatusExtracting:
return "解压中"
case domain.StatusInstalling:
return "安装中"
case domain.StatusInstalled:
return "已安装"
case domain.StatusUpdateAvailable:
return "可更新"
case domain.StatusRunning:
return "运行中"
case domain.StatusFailed:
return "失败"
case domain.StatusRollbackPending:
return "待恢复"
case domain.StatusIncompatible:
return "不兼容"
default:
return "未安装"
}
}
func statusColor(status domain.AppStatus) color.NRGBA {
switch status {
case domain.StatusFailed, domain.StatusRollbackPending:
return shellColors.destructive
case domain.StatusUpdateAvailable:
return shellColors.warning
case domain.StatusInstalled, domain.StatusRunning:
return shellColors.success
case domain.StatusIncompatible:
return shellColors.secondary
default:
return shellColors.primary
}
}
+131
View File
@@ -0,0 +1,131 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
func TestAppShellFillsWindow(t *testing.T) {
var operations op.Ops
size := image.Pt(1024, 680)
context := layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
dimensions := NewAppShell("Legacy").Layout(context, NewTheme())
if dimensions.Size != size {
t.Fatalf("Layout() size = %v, want %v", dimensions.Size, size)
}
}
func TestAppShellVirtualizesLargeCatalog(t *testing.T) {
items := make([]application.CatalogListItem, 500)
for index := range items {
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("软件 %03d", index),
Version: "1.0.0",
Category: "工具",
Tags: []string{"工具"},
Status: domain.StatusNotInstalled,
Installable: true,
}
}
shell := NewAppShell("Legacy", items...)
shell.Layout(testContext(image.Pt(1024, 380)), NewTheme())
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf(
"lastRendered = %d, want visible subset of %d",
shell.lastRendered,
len(items),
)
}
}
func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
items := []application.CatalogListItem{
{ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具"},
{ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像"},
}
shell := NewAppShell("Legacy", items...)
original := shell.rows["app-two"]
shell.ApplyIcon("app-one", image.NewNRGBA(image.Rect(0, 0, 16, 16)))
shell.ApplyIcon("app-two", image.NewNRGBA(image.Rect(0, 0, 24, 24)))
shell.model.SetCategory("图像")
shell.Layout(testContext(image.Pt(1024, 680)), NewTheme())
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after filtering")
}
shell.SetItems([]application.CatalogListItem{
{ID: "app-two", Name: "Two", Version: "1.1.0", Category: "图像"},
})
if shell.rows["app-two"] != original {
t.Fatal("row controls were recreated after snapshot update")
}
if _, exists := shell.rows["app-one"]; exists {
t.Fatal("removed app retained row controls")
}
if _, exists := shell.icons["app-one"]; exists {
t.Fatal("removed app retained prepared icon")
}
icon, exists := shell.icons["app-two"]
if !exists {
t.Fatal("retained app lost its prepared icon")
}
if icon.Size() != image.Pt(24, 24) {
t.Fatalf("retained app icon size = %v", icon.Size())
}
}
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
item := application.CatalogListItem{
ID: "json-parser",
Name: "JSON解析工具",
Description: "格式化并检查 JSON",
Version: "1.2.0",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
Status: domain.StatusInstalled,
Installed: true,
}
shell := NewAppShell("Legacy", item)
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
shell.ApplyIcon(item.ID, icon)
shell.model.Select(item.ID)
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
if !shell.detailRendered {
t.Fatal("selected app detail was not rendered")
}
if _, exists := shell.icons[item.ID]; !exists {
t.Fatal("ApplyIcon did not retain the prepared image operation")
}
shell.ApplyIcon(item.ID, nil)
if _, exists := shell.icons[item.ID]; exists {
t.Fatal("ApplyIcon(nil) did not remove the image")
}
}
func testContext(size image.Point) layout.Context {
var operations op.Ops
return layout.Context{
Ops: &operations,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
}
+418
View File
@@ -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
}
+162
View File
@@ -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
}
+183
View File
@@ -0,0 +1,183 @@
package application
import (
"context"
"errors"
"fmt"
)
var (
// ErrCatalogSourceUnconfigured means this build has no trusted Catalog
// endpoint/key composition. It must never be replaced with test data.
ErrCatalogSourceUnconfigured = errors.New("catalog source is unconfigured")
ErrCatalogBootstrapInvalid = errors.New("catalog bootstrap is invalid")
ErrCatalogEventPayload = errors.New("catalog event payload is invalid")
)
// CatalogSource identifies the verified source that produced a snapshot.
type CatalogSource string
const (
CatalogSourceRemote CatalogSource = "remote"
CatalogSourceCache CatalogSource = "cache"
)
// CatalogFailureCode is the public, non-sensitive reason shown by adapters.
type CatalogFailureCode string
const (
CatalogFailureSourceUnconfigured CatalogFailureCode = "catalog_source_unconfigured"
CatalogFailureLoadFailed CatalogFailureCode = "catalog_load_failed"
)
// CatalogSnapshot is an IO-free, verified and target-filtered list prepared
// before it crosses into the UI event boundary.
type CatalogSnapshot struct {
Items []CatalogListItem
Source CatalogSource
}
// CatalogSnapshotLoader prepares an already verified in-memory snapshot. Its
// implementation belongs in composition/infrastructure, never in Gio Layout.
type CatalogSnapshotLoader interface {
LoadCatalogSnapshot(context.Context) (CatalogSnapshot, error)
}
// CatalogSnapshotLoaderFunc adapts a function to CatalogSnapshotLoader.
type CatalogSnapshotLoaderFunc func(context.Context) (CatalogSnapshot, error)
func (function CatalogSnapshotLoaderFunc) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
return function(ctx)
}
// EventPublisher is the narrow runtime boundary used by CatalogBootstrap.
type EventPublisher interface {
Publish(context.Context, Event) error
}
// CatalogEvent is the typed, sanitized payload accepted by Gio adapters.
type CatalogEvent struct {
Type EventType
Items []CatalogListItem
Source CatalogSource
FailureCode CatalogFailureCode
}
// CatalogBootstrap invokes one loader and publishes exactly one catalog result.
// It is safe to run only in a background goroutine.
type CatalogBootstrap struct {
loader CatalogSnapshotLoader
publisher EventPublisher
}
// NewCatalogBootstrap creates the pure-core startup bridge.
func NewCatalogBootstrap(loader CatalogSnapshotLoader, publisher EventPublisher) *CatalogBootstrap {
return &CatalogBootstrap{loader: loader, publisher: publisher}
}
// Run publishes a sanitized success or failure event. The returned error keeps
// the original loader/publisher cause for non-UI diagnostics.
func (bootstrap *CatalogBootstrap) Run(ctx context.Context) error {
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
return ErrCatalogBootstrapInvalid
}
if err := ctx.Err(); err != nil {
return err
}
snapshot, err := bootstrap.loader.LoadCatalogSnapshot(ctx)
if err != nil {
return bootstrap.publishFailure(ctx, err)
}
if err := validateCatalogSnapshot(snapshot); err != nil {
return bootstrap.publishFailure(ctx, err)
}
return bootstrap.publisher.Publish(ctx, Event{
Type: EventCatalogRefreshed,
Payload: CatalogEvent{
Type: EventCatalogRefreshed,
Items: cloneCatalogItems(snapshot.Items),
Source: snapshot.Source,
},
})
}
func (bootstrap *CatalogBootstrap) publishFailure(ctx context.Context, cause error) error {
publishErr := bootstrap.publisher.Publish(ctx, Event{
Type: EventCatalogRejected,
Payload: CatalogEvent{Type: EventCatalogRejected, FailureCode: catalogFailureCode(cause)},
})
if publishErr != nil {
return errors.Join(cause, publishErr)
}
return cause
}
// ParseCatalogEvent validates and deep-copies the payload before an adapter
// changes UI state. Non-catalog events are left for other event handlers.
func ParseCatalogEvent(event Event) (CatalogEvent, bool, error) {
if event.Type != EventCatalogRefreshed && event.Type != EventCatalogRejected {
return CatalogEvent{}, false, nil
}
payload, ok := event.Payload.(CatalogEvent)
if !ok || payload.Type != event.Type || event.RequestID != "" || event.AppID != "" {
return CatalogEvent{}, true, ErrCatalogEventPayload
}
switch payload.Type {
case EventCatalogRefreshed:
if payload.FailureCode != "" || validateCatalogSnapshot(CatalogSnapshot{Items: payload.Items, Source: payload.Source}) != nil {
return CatalogEvent{}, true, ErrCatalogEventPayload
}
payload.Items = cloneCatalogItems(payload.Items)
return payload, true, nil
case EventCatalogRejected:
if len(payload.Items) != 0 || payload.Source != "" || !payload.FailureCode.valid() {
return CatalogEvent{}, true, ErrCatalogEventPayload
}
return payload, true, nil
default:
return CatalogEvent{}, true, ErrCatalogEventPayload
}
}
// UnconfiguredCatalogLoader is the fail-closed default for builds where the
// trusted endpoint and public key have not been provisioned.
type UnconfiguredCatalogLoader struct{}
func (UnconfiguredCatalogLoader) LoadCatalogSnapshot(ctx context.Context) (CatalogSnapshot, error) {
if err := ctx.Err(); err != nil {
return CatalogSnapshot{}, err
}
return CatalogSnapshot{}, ErrCatalogSourceUnconfigured
}
func validateCatalogSnapshot(snapshot CatalogSnapshot) error {
if !snapshot.Source.valid() {
return fmt.Errorf("%w: unrecognized catalog source", ErrCatalogBootstrapInvalid)
}
seen := make(map[string]struct{}, len(snapshot.Items))
for _, item := range snapshot.Items {
if item.ID == "" || item.Name == "" || item.Version == "" {
return fmt.Errorf("%w: incomplete catalog item", ErrCatalogBootstrapInvalid)
}
if _, exists := seen[item.ID]; exists {
return fmt.Errorf("%w: duplicate catalog item %q", ErrCatalogBootstrapInvalid, item.ID)
}
seen[item.ID] = struct{}{}
}
return nil
}
func catalogFailureCode(err error) CatalogFailureCode {
if errors.Is(err, ErrCatalogSourceUnconfigured) {
return CatalogFailureSourceUnconfigured
}
return CatalogFailureLoadFailed
}
func (source CatalogSource) valid() bool {
return source == CatalogSourceRemote || source == CatalogSourceCache
}
func (code CatalogFailureCode) valid() bool {
return code == CatalogFailureSourceUnconfigured || code == CatalogFailureLoadFailed
}
+112
View File
@@ -0,0 +1,112 @@
package application
import (
"context"
"errors"
"testing"
"softbox.local/core/domain"
)
func TestCatalogBootstrapPublishesIndependentVerifiedSnapshot(t *testing.T) {
items := []CatalogListItem{{
ID: "json-tool", Name: "JSON Tool", Version: "1.0.0", Tags: []string{"json"}, Status: domain.StatusNotInstalled,
}}
runtime := NewRuntime(1)
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
return CatalogSnapshot{Items: items, Source: CatalogSourceCache}, nil
}), runtime)
if err := bootstrap.Run(context.Background()); err != nil {
t.Fatalf("Run() error = %v", err)
}
items[0].Name = "mutated"
items[0].Tags[0] = "mutated"
event := <-runtime.Events()
payload, handled, err := ParseCatalogEvent(event)
if err != nil || !handled {
t.Fatalf("ParseCatalogEvent() = %#v, %v, %v", payload, handled, err)
}
if payload.Source != CatalogSourceCache || payload.Items[0].Name != "JSON Tool" || payload.Items[0].Tags[0] != "json" {
t.Fatalf("payload = %#v, want independent verified snapshot", payload)
}
}
func TestCatalogBootstrapPublishesStableFailureCode(t *testing.T) {
loadErr := errors.New("network endpoint details must not reach UI")
for _, test := range []struct {
name string
err error
code CatalogFailureCode
}{
{name: "unconfigured", err: ErrCatalogSourceUnconfigured, code: CatalogFailureSourceUnconfigured},
{name: "load failed", err: loadErr, code: CatalogFailureLoadFailed},
} {
t.Run(test.name, func(t *testing.T) {
runtime := NewRuntime(1)
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
return CatalogSnapshot{}, test.err
}), runtime)
err := bootstrap.Run(context.Background())
if !errors.Is(err, test.err) {
t.Fatalf("Run() error = %v, want original loader error", err)
}
payload, handled, parseErr := ParseCatalogEvent(<-runtime.Events())
if parseErr != nil || !handled || payload.FailureCode != test.code {
t.Fatalf("failure payload = %#v, handled=%v, error=%v", payload, handled, parseErr)
}
})
}
}
func TestCatalogBootstrapFailsClosedForInvalidDependenciesAndPayload(t *testing.T) {
if err := (*CatalogBootstrap)(nil).Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
t.Fatalf("nil bootstrap error = %v", err)
}
runtime := NewRuntime(1)
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
return CatalogSnapshot{Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}, nil
}), runtime)
if err := bootstrap.Run(context.Background()); !errors.Is(err, ErrCatalogBootstrapInvalid) {
t.Fatalf("invalid snapshot error = %v", err)
}
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRejected, Payload: CatalogEvent{Type: EventCatalogRejected}}); !errors.Is(err, ErrCatalogEventPayload) {
t.Fatalf("invalid rejection payload error = %v", err)
}
if _, _, err := ParseCatalogEvent(Event{Type: EventCatalogRefreshed, Payload: CatalogEvent{Type: EventCatalogRefreshed, Source: CatalogSourceRemote, Items: []CatalogListItem{{ID: "only-id"}}}}); !errors.Is(err, ErrCatalogEventPayload) {
t.Fatalf("invalid refresh payload error = %v", err)
}
}
func TestUnconfiguredCatalogLoaderHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := (UnconfiguredCatalogLoader{}).LoadCatalogSnapshot(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("LoadCatalogSnapshot() error = %v, want context.Canceled", err)
}
}
func TestCatalogBootstrapHonorsCancellationAndPreservesPublisherFailure(t *testing.T) {
called := false
ctx, cancel := context.WithCancel(context.Background())
cancel()
bootstrap := NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
called = true
return CatalogSnapshot{}, nil
}), NewRuntime(1))
if err := bootstrap.Run(ctx); !errors.Is(err, context.Canceled) || called {
t.Fatalf("canceled Run() = %v, loader called=%v", err, called)
}
publishErr := errors.New("runtime publish unavailable")
loaderErr := errors.New("loader failed")
bootstrap = NewCatalogBootstrap(CatalogSnapshotLoaderFunc(func(context.Context) (CatalogSnapshot, error) {
return CatalogSnapshot{}, loaderErr
}), catalogFailPublisher{err: publishErr})
err := bootstrap.Run(context.Background())
if !errors.Is(err, loaderErr) || !errors.Is(err, publishErr) {
t.Fatalf("combined failure = %v", err)
}
}
type catalogFailPublisher struct{ err error }
func (publisher catalogFailPublisher) Publish(context.Context, Event) error { return publisher.err }
+251
View File
@@ -0,0 +1,251 @@
package application
import (
"strings"
"softbox.local/core/domain"
)
// CatalogView is one primary software-list scope.
type CatalogView string
const (
CatalogViewAll CatalogView = "all"
CatalogViewInstalled CatalogView = "installed"
CatalogViewUpdates CatalogView = "updates"
)
// CatalogListItem is the IO-free data consumed by Gio list adapters.
type CatalogListItem struct {
ID string
Name string
Description string
Version string
Category string
Tags []string
IconRef string
Homepage string
Tutorial string
Status domain.AppStatus
Installed bool
Installable bool
Reason string
}
// CatalogListModel owns source items and composable list filters.
type CatalogListModel struct {
items []CatalogListItem
visible []CatalogListItem
categories []string
query string
category string
view CatalogView
selectedID string
}
// NewCatalogListModel copies items and initializes the all view.
func NewCatalogListModel(items []CatalogListItem) *CatalogListModel {
model := &CatalogListModel{view: CatalogViewAll}
model.SetItems(items)
return model
}
// SetItems replaces the source snapshot and recomputes categories/visibility.
func (model *CatalogListModel) SetItems(items []CatalogListItem) {
model.items = cloneCatalogItems(items)
model.categories = collectCategories(model.items)
if model.category != "" && !containsString(model.categories, model.category) {
model.category = ""
}
if model.selectedID != "" && !containsItemID(model.items, model.selectedID) {
model.selectedID = ""
}
model.refilter()
}
// SetQuery applies a case-insensitive name/ID/tag search.
func (model *CatalogListModel) SetQuery(query string) {
normalized := strings.ToLower(strings.TrimSpace(query))
if model.query == normalized {
return
}
model.query = normalized
model.refilter()
}
// SetCategory selects one exact category. Empty means every category.
func (model *CatalogListModel) SetCategory(category string) {
if category != "" && !containsString(model.categories, category) {
return
}
if model.category == category {
return
}
model.category = category
model.refilter()
}
// SetView selects the all, installed or updates scope.
func (model *CatalogListModel) SetView(view CatalogView) {
if !view.Valid() || model.view == view {
return
}
model.view = view
model.refilter()
}
// Select records a stable app ID for row interaction state.
func (model *CatalogListModel) Select(appID string) {
if appID == "" || containsItemID(model.items, appID) {
model.selectedID = appID
}
}
// ResetFilters restores the full list while retaining source data.
func (model *CatalogListModel) ResetFilters() {
model.query = ""
model.category = ""
model.view = CatalogViewAll
model.refilter()
}
// VisibleItems returns the current read-only snapshot generation without copying.
// The snapshot remains stable after later model changes. Callers must not modify
// its elements, nested Tags, or capacity; CatalogListModel is single-owner and
// does not support concurrent reads and writes.
func (model *CatalogListModel) VisibleItems() []CatalogListItem {
return model.visible
}
// Categories returns the stable first-seen category order.
func (model *CatalogListModel) Categories() []string {
return model.categories
}
// TotalCount returns the unfiltered source count.
func (model *CatalogListModel) TotalCount() int {
return len(model.items)
}
// Query returns the normalized active query.
func (model *CatalogListModel) Query() string {
return model.query
}
// Category returns the active exact category, or empty for all.
func (model *CatalogListModel) Category() string {
return model.category
}
// View returns the active primary scope.
func (model *CatalogListModel) View() CatalogView {
return model.view
}
// SelectedID returns the selected stable software ID.
func (model *CatalogListModel) SelectedID() string {
return model.selectedID
}
// SelectedItem returns the selected source item without changing filters.
func (model *CatalogListModel) SelectedItem() (CatalogListItem, bool) {
for _, item := range model.items {
if item.ID == model.selectedID {
return item, true
}
}
return CatalogListItem{}, false
}
// Valid reports whether view is supported by the MVP list.
func (view CatalogView) Valid() bool {
return view == CatalogViewAll ||
view == CatalogViewInstalled ||
view == CatalogViewUpdates
}
func (model *CatalogListModel) refilter() {
visible := make([]CatalogListItem, 0, len(model.items))
for _, item := range model.items {
if model.category != "" && item.Category != model.category {
continue
}
if !matchesView(item, model.view) {
continue
}
if model.query != "" && !matchesQuery(item, model.query) {
continue
}
visible = append(visible, item)
}
model.visible = visible
}
func matchesView(item CatalogListItem, view CatalogView) bool {
switch view {
case CatalogViewAll:
return true
case CatalogViewInstalled:
return item.Installed
case CatalogViewUpdates:
return item.Status == domain.StatusUpdateAvailable
default:
return false
}
}
func matchesQuery(item CatalogListItem, query string) bool {
if strings.Contains(strings.ToLower(item.Name), query) ||
strings.Contains(strings.ToLower(item.ID), query) {
return true
}
for _, tag := range item.Tags {
if strings.Contains(strings.ToLower(tag), query) {
return true
}
}
return false
}
func collectCategories(items []CatalogListItem) []string {
seen := make(map[string]struct{})
categories := make([]string, 0)
for _, item := range items {
if item.Category == "" {
continue
}
if _, exists := seen[item.Category]; exists {
continue
}
seen[item.Category] = struct{}{}
categories = append(categories, item.Category)
}
return categories
}
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
cloned := make([]CatalogListItem, len(items))
for index, item := range items {
cloned[index] = item
cloned[index].Tags = append([]string(nil), item.Tags...)
}
return cloned
}
func containsString(values []string, value string) bool {
for _, candidate := range values {
if candidate == value {
return true
}
}
return false
}
func containsItemID(items []CatalogListItem, appID string) bool {
for _, item := range items {
if item.ID == appID {
return true
}
}
return false
}
+260
View File
@@ -0,0 +1,260 @@
package application
import (
"reflect"
"testing"
"softbox.local/core/domain"
)
var visibleSnapshotSink []CatalogListItem
func TestCatalogListModelCombinesSearchCategoryAndView(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{
ID: "json-parser",
Name: "JSON解析工具",
Category: "开发工具",
Tags: []string{"JSON", "格式化"},
Status: domain.StatusInstalled,
Installed: true,
},
{
ID: "image-tool",
Name: "Image Tool",
Category: "图像",
Tags: []string{"PNG", "压缩"},
Status: domain.StatusUpdateAvailable,
Installed: true,
},
{
ID: "log-viewer",
Name: "日志查看器",
Category: "开发工具",
Tags: []string{"LOG", "诊断"},
Status: domain.StatusNotInstalled,
},
})
model.SetQuery(" png ")
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("")
model.SetCategory("开发工具")
assertVisibleIDs(t, model, "json-parser", "log-viewer")
model.SetView(CatalogViewInstalled)
assertVisibleIDs(t, model, "json-parser")
model.SetCategory("")
model.SetView(CatalogViewUpdates)
assertVisibleIDs(t, model, "image-tool")
model.SetQuery("IMAGE-")
assertVisibleIDs(t, model, "image-tool")
}
func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
items := []CatalogListItem{
{ID: "app-b", Name: "B", Category: "工具"},
{ID: "app-a", Name: "A", Category: "工具"},
}
model := NewCatalogListModel(items)
model.Select("app-a")
model.SetQuery("app")
assertVisibleIDs(t, model, "app-b", "app-a")
if model.SelectedID() != "app-a" {
t.Fatalf("SelectedID = %q", model.SelectedID())
}
selected, ok := model.SelectedItem()
if !ok || selected.ID != "app-a" {
t.Fatalf("SelectedItem() = %#v, %t", selected, ok)
}
model.SetItems([]CatalogListItem{{ID: "app-b", Name: "B", Category: "工具"}})
if model.SelectedID() != "" {
t.Fatalf("SelectedID after removal = %q, want empty", model.SelectedID())
}
}
func TestCatalogListModelCategoriesAndReset(t *testing.T) {
model := NewCatalogListModel([]CatalogListItem{
{ID: "one", Category: "开发"},
{ID: "two", Category: "图像"},
{ID: "three", Category: "开发"},
})
categories := model.Categories()
if len(categories) != 2 || categories[0] != "开发" || categories[1] != "图像" {
t.Fatalf("Categories = %#v", categories)
}
model.SetQuery("missing")
model.SetCategory("开发")
model.SetView(CatalogViewInstalled)
model.ResetFilters()
if model.Query() != "" ||
model.Category() != "" ||
model.View() != CatalogViewAll ||
len(model.VisibleItems()) != 3 {
t.Fatalf("model was not reset: %#v", model)
}
}
func TestCatalogListModelVisibleSnapshotsSurviveModelChanges(t *testing.T) {
tests := []struct {
name string
prepare func(*CatalogListModel)
mutate func(*CatalogListModel)
}{
{
name: "query",
mutate: func(model *CatalogListModel) { model.SetQuery("image") },
},
{
name: "category",
mutate: func(model *CatalogListModel) { model.SetCategory("图像") },
},
{
name: "view",
mutate: func(model *CatalogListModel) { model.SetView(CatalogViewUpdates) },
},
{
name: "reset",
prepare: func(model *CatalogListModel) {
model.SetQuery("image")
},
mutate: func(model *CatalogListModel) { model.ResetFilters() },
},
{
name: "items",
mutate: func(model *CatalogListModel) {
model.SetItems([]CatalogListItem{
{ID: "new-app", Name: "New", Category: "其他", Tags: []string{"new"}},
})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
model := NewCatalogListModel(catalogSnapshotFixture())
if test.prepare != nil {
test.prepare(model)
}
previous := model.VisibleItems()
if len(previous) == 0 {
t.Fatal("test setup produced an empty previous generation")
}
wantPrevious := cloneSnapshotForTest(previous)
test.mutate(model)
if !reflect.DeepEqual(previous, wantPrevious) {
t.Fatalf("previous generation changed:\n got: %#v\nwant: %#v", previous, wantPrevious)
}
current := model.VisibleItems()
if len(current) == 0 {
t.Fatal("test mutation produced an empty current generation")
}
if &previous[0] == &current[0] {
t.Fatal("current generation reused the previous backing array")
}
})
}
}
func TestCatalogListModelVisibleItemsDoesNotCopyWithinGeneration(t *testing.T) {
model := NewCatalogListModel(catalogSnapshotFixture())
first := model.VisibleItems()
second := model.VisibleItems()
if len(first) == 0 || len(second) == 0 {
t.Fatal("test setup produced an empty generation")
}
if &first[0] != &second[0] {
t.Fatal("repeated VisibleItems calls copied the current generation")
}
model.SetQuery(" ")
unchanged := model.VisibleItems()
if &first[0] != &unchanged[0] {
t.Fatal("no-op model update published a new generation")
}
if allocations := testing.AllocsPerRun(100, func() {
visibleSnapshotSink = model.VisibleItems()
}); allocations != 0 {
t.Fatalf("VisibleItems allocations per read = %v, want 0", allocations)
}
model.SetQuery("image")
changed := model.VisibleItems()
if len(changed) == 0 {
t.Fatal("changed generation is empty")
}
if &first[0] == &changed[0] {
t.Fatal("actual model update did not publish a new generation")
}
}
func TestCatalogListModelVisibleSnapshotsCoverEmptyAndRestore(t *testing.T) {
model := NewCatalogListModel(nil)
if visible := model.VisibleItems(); len(visible) != 0 {
t.Fatalf("empty catalog visible items = %#v", visible)
}
model.SetItems(catalogSnapshotFixture())
full := model.VisibleItems()
wantFull := cloneSnapshotForTest(full)
model.SetQuery("missing-app")
if visible := model.VisibleItems(); len(visible) != 0 {
t.Fatalf("no-match visible items = %#v", visible)
}
if !reflect.DeepEqual(full, wantFull) {
t.Fatalf("full generation changed after empty filter:\n got: %#v\nwant: %#v", full, wantFull)
}
model.ResetFilters()
assertVisibleIDs(t, model, "json-parser", "image-tool", "log-viewer")
if !reflect.DeepEqual(full, wantFull) {
t.Fatalf("full generation changed after reset:\n got: %#v\nwant: %#v", full, wantFull)
}
}
func catalogSnapshotFixture() []CatalogListItem {
return []CatalogListItem{
{
ID: "json-parser", Name: "JSON Parser", Category: "开发",
Tags: []string{"json", "format"}, Status: domain.StatusInstalled, Installed: true,
},
{
ID: "image-tool", Name: "Image Tool", Category: "图像",
Tags: []string{"png", "compress"}, Status: domain.StatusUpdateAvailable, Installed: true,
},
{
ID: "log-viewer", Name: "Log Viewer", Category: "开发",
Tags: []string{"log", "diagnostic"}, Status: domain.StatusNotInstalled,
},
}
}
func cloneSnapshotForTest(items []CatalogListItem) []CatalogListItem {
cloned := make([]CatalogListItem, len(items))
for index, item := range items {
cloned[index] = item
cloned[index].Tags = append([]string(nil), item.Tags...)
}
return cloned
}
func assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
t.Helper()
visible := model.VisibleItems()
if len(visible) != len(want) {
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
}
for index, item := range visible {
if item.ID != want[index] {
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package application coordinates SoftBox use cases through injected ports.
package application
+45
View File
@@ -0,0 +1,45 @@
package application
// DownloadStartedPayload begins one transfer attempt. A later attempt may
// reset Done when a remote entity cannot be safely resumed.
type DownloadStartedPayload struct {
Attempt uint64
Done int64
TotalKnown bool
Total int64
}
// DownloadProgressPayload reports monotonic progress within one attempt.
type DownloadProgressPayload struct {
Attempt uint64
Done int64
TotalKnown bool
Total int64
SpeedBytesSec int64
}
// DownloadPausedPayload maps to domain queued while preserving pause intent.
type DownloadPausedPayload struct {
Attempt uint64
Done int64
}
// DownloadCompletedPayload means bytes are durably downloaded but still
// untrusted. T-302 must verify the signed Catalog identity, size and hashes.
type DownloadCompletedPayload struct {
Attempt uint64
Done int64
Path string
}
// DownloadFailedPayload carries a stable low-level transfer/storage code.
type DownloadFailedPayload struct {
Attempt uint64
Done int64
ErrorCode string
}
// DownloadCanceledPayload confirms cleanup and suppresses the old attempt.
type DownloadCanceledPayload struct {
Attempt uint64
}
+57
View File
@@ -0,0 +1,57 @@
package application
// EventType identifies an application event consumed by UI adapters.
type EventType string
const (
EventCatalogRefreshed EventType = "CatalogRefreshed"
EventCatalogRejected EventType = "CatalogRejected"
EventDownloadStarted EventType = "DownloadStarted"
EventDownloadProgress EventType = "DownloadProgress"
EventDownloadPaused EventType = "DownloadPaused"
EventDownloadCompleted EventType = "DownloadCompleted"
EventDownloadFailed EventType = "DownloadFailed"
EventDownloadCanceled EventType = "DownloadCanceled"
EventInstallCompleted EventType = "InstallCompleted"
EventInstallRolledBack EventType = "InstallRolledBack"
EventAppStarted EventType = "AppStarted"
EventAppExited EventType = "AppExited"
EventLicenseChanged EventType = "LicenseChanged"
EventIconReady EventType = "IconReady"
EventIconFailed EventType = "IconFailed"
)
var validEventTypes = map[EventType]struct{}{
EventCatalogRefreshed: {},
EventCatalogRejected: {},
EventDownloadStarted: {},
EventDownloadProgress: {},
EventDownloadPaused: {},
EventDownloadCompleted: {},
EventDownloadFailed: {},
EventDownloadCanceled: {},
EventInstallCompleted: {},
EventInstallRolledBack: {},
EventAppStarted: {},
EventAppExited: {},
EventLicenseChanged: {},
EventIconReady: {},
EventIconFailed: {},
}
// Valid reports whether eventType is part of the documented event contract.
func (eventType EventType) Valid() bool {
_, ok := validEventTypes[eventType]
return ok
}
// Event is the common envelope delivered from background use cases to adapters.
//
// Payload is event-specific and will be replaced by concrete payload types as
// the corresponding use cases are implemented.
type Event struct {
Type EventType
RequestID string
AppID string
Payload any
}
+138
View File
@@ -0,0 +1,138 @@
package application
import (
"context"
"errors"
"fmt"
"sync"
)
var (
ErrEventRelayClosed = errors.New("application event relay closed")
ErrEventRelayInvalid = errors.New("application event relay is invalid")
)
// EventRelay is a bounded FIFO between background event pumps and the UI frame.
// Submit applies lossless backpressure; Drain must only run on the UI goroutine.
type EventRelay struct {
events chan Event
slots chan struct{}
done chan struct{}
mu sync.Mutex
closed bool
closeOnce sync.Once
}
// NewEventRelay creates a relay with a strictly positive bounded capacity.
func NewEventRelay(capacity int) (*EventRelay, error) {
if capacity <= 0 {
return nil, fmt.Errorf(
"%w: capacity must be positive",
ErrEventRelayInvalid,
)
}
relay := &EventRelay{
events: make(chan Event, capacity),
slots: make(chan struct{}, capacity),
done: make(chan struct{}),
}
for index := 0; index < capacity; index++ {
relay.slots <- struct{}{}
}
return relay, nil
}
// Submit queues one event or returns when the context/relay closes.
func (relay *EventRelay) Submit(ctx context.Context, event Event) error {
if relay == nil {
return fmt.Errorf("%w: nil relay", ErrEventRelayInvalid)
}
if !event.Type.Valid() {
return fmt.Errorf(
"%w: unknown type %q",
ErrInvalidEvent,
event.Type,
)
}
select {
case <-relay.done:
return ErrEventRelayClosed
case <-ctx.Done():
return ctx.Err()
case <-relay.slots:
}
relay.mu.Lock()
defer relay.mu.Unlock()
if relay.closed {
relay.slots <- struct{}{}
return ErrEventRelayClosed
}
if err := ctx.Err(); err != nil {
relay.slots <- struct{}{}
return err
}
relay.events <- event
return nil
}
// Drain applies the events present at entry without extending a UI frame forever.
func (relay *EventRelay) Drain(apply func(Event) error) error {
if relay == nil || apply == nil {
return fmt.Errorf("%w: nil relay or apply function", ErrEventRelayInvalid)
}
limit := len(relay.events)
var applyErrors []error
for index := 0; index < limit; index++ {
select {
case event := <-relay.events:
relay.slots <- struct{}{}
if err := apply(event); err != nil {
applyErrors = append(applyErrors, err)
}
default:
return errors.Join(applyErrors...)
}
}
return errors.Join(applyErrors...)
}
// Close unblocks pending submissions. Queued events remain available to Drain.
func (relay *EventRelay) Close() {
if relay == nil {
return
}
relay.closeOnce.Do(func() {
relay.mu.Lock()
relay.closed = true
close(relay.done)
relay.mu.Unlock()
})
}
// PumpEvents forwards application events to a relay and requests a UI frame.
// invalidate may be called concurrently; no UI state may be mutated here.
func PumpEvents(
ctx context.Context,
events <-chan Event,
relay *EventRelay,
invalidate func(),
) error {
if events == nil || relay == nil || invalidate == nil {
return fmt.Errorf("%w: incomplete event pump", ErrEventRelayInvalid)
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case event, open := <-events:
if !open {
return nil
}
if err := relay.Submit(ctx, event); err != nil {
return err
}
invalidate()
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package application
import (
"context"
"errors"
"reflect"
"testing"
"time"
)
func TestEventRelayUsesBoundedFIFOBackpressure(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
second := Event{Type: EventCatalogRejected, RequestID: "second"}
if err := relay.Submit(context.Background(), first); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
secondResult := make(chan error, 1)
go func() {
close(started)
secondResult <- relay.Submit(context.Background(), second)
}()
<-started
select {
case err := <-secondResult:
t.Fatalf("second Submit() completed while relay was full: %v", err)
default:
}
var received []string
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if err := waitRelayResult(secondResult); err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, []string{"first", "second"}) {
t.Fatalf("received order = %v", received)
}
}
func TestEventRelayCloseAndContextUnblockFullSubmit(t *testing.T) {
tests := []struct {
name string
unblock func(*EventRelay, context.CancelFunc)
wantErr error
}{
{
name: "close",
unblock: func(relay *EventRelay, _ context.CancelFunc) {
relay.Close()
},
wantErr: ErrEventRelayClosed,
},
{
name: "cancel",
unblock: func(_ *EventRelay, cancel context.CancelFunc) {
cancel()
},
wantErr: context.Canceled,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
if err := relay.Submit(
context.Background(),
Event{Type: EventCatalogRefreshed},
); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
result := make(chan error, 1)
go func() {
close(started)
result <- relay.Submit(ctx, Event{Type: EventCatalogRejected})
}()
<-started
test.unblock(relay, cancel)
if err := waitRelayResult(result); !errors.Is(err, test.wantErr) {
t.Fatalf("Submit() error = %v, want %v", err, test.wantErr)
}
})
}
}
func TestPumpEventsInvalidatesBeforeUIDrainAndStops(t *testing.T) {
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
source := make(chan Event, 2)
invalidated := make(chan struct{}, 2)
ctx, cancel := context.WithCancel(context.Background())
pumpResult := make(chan error, 1)
go func() {
pumpResult <- PumpEvents(ctx, source, relay, func() {
invalidated <- struct{}{}
})
}()
first := Event{Type: EventCatalogRefreshed, RequestID: "first"}
second := Event{Type: EventCatalogRejected, RequestID: "second"}
source <- first
source <- second
waitSignal(t, invalidated)
var received []string
if len(received) != 0 {
t.Fatal("background pump applied an event before UI drain")
}
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
waitSignal(t, invalidated)
if err := relay.Drain(func(event Event) error {
received = append(received, event.RequestID)
return nil
}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, []string{"first", "second"}) {
t.Fatalf("received order = %v", received)
}
cancel()
if err := waitRelayResult(pumpResult); !errors.Is(err, context.Canceled) {
t.Fatalf("PumpEvents() error = %v", err)
}
}
func TestEventRelayDrainReportsErrorsAndContinues(t *testing.T) {
relay, err := NewEventRelay(2)
if err != nil {
t.Fatal(err)
}
for _, eventType := range []EventType{EventCatalogRefreshed, EventCatalogRejected} {
if err := relay.Submit(context.Background(), Event{Type: eventType}); err != nil {
t.Fatal(err)
}
}
wantErr := errors.New("apply failed")
applied := 0
err = relay.Drain(func(Event) error {
applied++
return wantErr
})
if !errors.Is(err, wantErr) || applied != 2 {
t.Fatalf("Drain() = (%d applies, %v)", applied, err)
}
}
func TestNewEventRelayRejectsInvalidCapacity(t *testing.T) {
if _, err := NewEventRelay(0); !errors.Is(err, ErrEventRelayInvalid) {
t.Fatalf("NewEventRelay(0) error = %v", err)
}
relay, err := NewEventRelay(1)
if err != nil {
t.Fatal(err)
}
relay.Close()
if err := relay.Submit(
context.Background(),
Event{Type: EventCatalogRefreshed},
); !errors.Is(err, ErrEventRelayClosed) {
t.Fatalf("Submit(after Close) error = %v", err)
}
}
func waitRelayResult(result <-chan error) error {
select {
case err := <-result:
return err
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for relay")
}
}
func waitSignal(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for signal")
}
}
+33
View File
@@ -0,0 +1,33 @@
package application
import "testing"
func TestEventTypeValid(t *testing.T) {
eventTypes := []EventType{
EventCatalogRefreshed,
EventCatalogRejected,
EventDownloadStarted,
EventDownloadProgress,
EventDownloadPaused,
EventDownloadCompleted,
EventDownloadFailed,
EventDownloadCanceled,
EventInstallCompleted,
EventInstallRolledBack,
EventAppStarted,
EventAppExited,
EventLicenseChanged,
EventIconReady,
EventIconFailed,
}
for _, eventType := range eventTypes {
if !eventType.Valid() {
t.Errorf("event type %q should be valid", eventType)
}
}
if EventType("Unknown").Valid() {
t.Fatal("unknown event type should be invalid")
}
}
+270
View File
@@ -0,0 +1,270 @@
package application
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"reflect"
"strings"
)
const (
MinIconDPI = 48
MaxIconDPI = 768
)
var ErrInvalidIconEvent = errors.New("invalid icon application event")
// IconFailureCode is a stable, non-sensitive reason exposed to UI adapters.
type IconFailureCode string
const (
IconFailureUnavailable IconFailureCode = "unavailable"
IconFailureInvalid IconFailureCode = "invalid_content"
IconFailureUnsafe IconFailureCode = "unsafe_cache"
)
// Valid reports whether code is part of the documented icon event contract.
func (code IconFailureCode) Valid() bool {
switch code {
case IconFailureUnavailable, IconFailureInvalid, IconFailureUnsafe:
return true
default:
return false
}
}
// IconEventIdentity correlates one background request with one catalog icon.
type IconEventIdentity struct {
RequestID string
AppID string
Reference string
DPI int
}
// NewIconEventIdentity validates and canonicalizes an icon event identity.
func NewIconEventIdentity(
requestID string,
appID string,
reference string,
dpi int,
) (IconEventIdentity, error) {
if strings.TrimSpace(requestID) == "" {
return IconEventIdentity{}, fmt.Errorf(
"%w: empty request ID",
ErrInvalidIconEvent,
)
}
if strings.TrimSpace(appID) == "" {
return IconEventIdentity{}, fmt.Errorf(
"%w: empty app ID",
ErrInvalidIconEvent,
)
}
canonicalReference, err := NormalizeIconReference(reference)
if err != nil {
return IconEventIdentity{}, err
}
if dpi < MinIconDPI || dpi > MaxIconDPI {
return IconEventIdentity{}, fmt.Errorf(
"%w: DPI %d outside %d..%d",
ErrInvalidIconEvent,
dpi,
MinIconDPI,
MaxIconDPI,
)
}
return IconEventIdentity{
RequestID: requestID,
AppID: appID,
Reference: canonicalReference,
DPI: dpi,
}, nil
}
// NormalizeIconReference returns the canonical sha256:<lower-hex> form.
func NormalizeIconReference(reference string) (string, error) {
const prefix = "sha256:"
if !strings.HasPrefix(reference, prefix) {
return "", fmt.Errorf(
"%w: icon reference must use sha256",
ErrInvalidIconEvent,
)
}
digest := strings.ToLower(strings.TrimPrefix(reference, prefix))
decoded, err := hex.DecodeString(digest)
if err != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
return "", fmt.Errorf(
"%w: malformed icon digest",
ErrInvalidIconEvent,
)
}
return prefix + digest, nil
}
// IconReadyPayload contains an image decoded outside the Gio UI goroutine.
type IconReadyPayload struct {
Reference string
DPI int
Image image.Image
}
// IconFailedPayload contains a stable failure classification, never a raw URL.
type IconFailedPayload struct {
Reference string
DPI int
ErrorCode IconFailureCode
}
// IconEvent is the validated representation consumed by UI adapters.
type IconEvent struct {
Type EventType
Identity IconEventIdentity
Image image.Image
ErrorCode IconFailureCode
}
// NewIconReadyEvent builds a validated ready event.
func NewIconReadyEvent(
identity IconEventIdentity,
icon image.Image,
) (Event, error) {
validated, err := NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return Event{}, err
}
if isNilImage(icon) {
return Event{}, fmt.Errorf("%w: nil ready image", ErrInvalidIconEvent)
}
return Event{
Type: EventIconReady,
RequestID: validated.RequestID,
AppID: validated.AppID,
Payload: IconReadyPayload{
Reference: validated.Reference,
DPI: validated.DPI,
Image: icon,
},
}, nil
}
// NewIconFailedEvent builds a validated failure event.
func NewIconFailedEvent(
identity IconEventIdentity,
code IconFailureCode,
) (Event, error) {
validated, err := NewIconEventIdentity(
identity.RequestID,
identity.AppID,
identity.Reference,
identity.DPI,
)
if err != nil {
return Event{}, err
}
if !code.Valid() {
return Event{}, fmt.Errorf(
"%w: unknown failure code %q",
ErrInvalidIconEvent,
code,
)
}
return Event{
Type: EventIconFailed,
RequestID: validated.RequestID,
AppID: validated.AppID,
Payload: IconFailedPayload{
Reference: validated.Reference,
DPI: validated.DPI,
ErrorCode: code,
},
}, nil
}
// ParseIconEvent validates an icon envelope. Non-icon events return handled=false.
func ParseIconEvent(event Event) (parsed IconEvent, handled bool, err error) {
switch event.Type {
case EventIconReady:
payload, ok := event.Payload.(IconReadyPayload)
if !ok {
return IconEvent{}, true, fmt.Errorf(
"%w: ready payload has type %T",
ErrInvalidIconEvent,
event.Payload,
)
}
identity, identityErr := NewIconEventIdentity(
event.RequestID,
event.AppID,
payload.Reference,
payload.DPI,
)
if identityErr != nil {
return IconEvent{}, true, identityErr
}
if isNilImage(payload.Image) {
return IconEvent{}, true, fmt.Errorf(
"%w: nil ready image",
ErrInvalidIconEvent,
)
}
return IconEvent{
Type: event.Type,
Identity: identity,
Image: payload.Image,
}, true, nil
case EventIconFailed:
payload, ok := event.Payload.(IconFailedPayload)
if !ok {
return IconEvent{}, true, fmt.Errorf(
"%w: failed payload has type %T",
ErrInvalidIconEvent,
event.Payload,
)
}
identity, identityErr := NewIconEventIdentity(
event.RequestID,
event.AppID,
payload.Reference,
payload.DPI,
)
if identityErr != nil {
return IconEvent{}, true, identityErr
}
if !payload.ErrorCode.Valid() {
return IconEvent{}, true, fmt.Errorf(
"%w: unknown failure code %q",
ErrInvalidIconEvent,
payload.ErrorCode,
)
}
return IconEvent{
Type: event.Type,
Identity: identity,
ErrorCode: payload.ErrorCode,
}, true, nil
default:
return IconEvent{}, false, nil
}
}
func isNilImage(icon image.Image) bool {
if icon == nil {
return true
}
value := reflect.ValueOf(icon)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
+126
View File
@@ -0,0 +1,126 @@
package application
import (
"errors"
"image"
"strings"
"testing"
)
func TestIconEventRoundTrip(t *testing.T) {
reference := "sha256:" + strings.Repeat("A1", 32)
identity, err := NewIconEventIdentity("request-1", "app-one", reference, 144)
if err != nil {
t.Fatalf("NewIconEventIdentity() error = %v", err)
}
if identity.Reference != strings.ToLower(reference) {
t.Fatalf("canonical reference = %q", identity.Reference)
}
icon := image.NewNRGBA(image.Rect(0, 0, 24, 24))
ready, err := NewIconReadyEvent(identity, icon)
if err != nil {
t.Fatalf("NewIconReadyEvent() error = %v", err)
}
parsed, handled, err := ParseIconEvent(ready)
if err != nil || !handled {
t.Fatalf("ParseIconEvent(ready) = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != EventIconReady || parsed.Identity != identity || parsed.Image != icon {
t.Fatalf("parsed ready event = %+v", parsed)
}
failed, err := NewIconFailedEvent(identity, IconFailureUnsafe)
if err != nil {
t.Fatalf("NewIconFailedEvent() error = %v", err)
}
parsed, handled, err = ParseIconEvent(failed)
if err != nil || !handled {
t.Fatalf("ParseIconEvent(failed) = (%+v, %t, %v)", parsed, handled, err)
}
if parsed.Type != EventIconFailed ||
parsed.Identity != identity ||
parsed.ErrorCode != IconFailureUnsafe {
t.Fatalf("parsed failed event = %+v", parsed)
}
parsed, handled, err = ParseIconEvent(Event{Type: EventCatalogRefreshed})
if err != nil || handled {
t.Fatalf("ParseIconEvent(non-icon) = (%+v, %t, %v)", parsed, handled, err)
}
}
func TestIconEventRejectsInvalidIdentityAndPayload(t *testing.T) {
validReference := "sha256:" + strings.Repeat("0a", 32)
tests := []struct {
name string
requestID string
appID string
reference string
dpi int
}{
{name: "empty request", appID: "app", reference: validReference, dpi: 96},
{name: "empty app", requestID: "request", reference: validReference, dpi: 96},
{name: "bad reference", requestID: "request", appID: "app", reference: "md5:00", dpi: 96},
{name: "low DPI", requestID: "request", appID: "app", reference: validReference, dpi: 47},
{name: "high DPI", requestID: "request", appID: "app", reference: validReference, dpi: 769},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NewIconEventIdentity(
test.requestID,
test.appID,
test.reference,
test.dpi,
)
if !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconEventIdentity() error = %v", err)
}
})
}
identity, err := NewIconEventIdentity("request", "app", validReference, 96)
if err != nil {
t.Fatal(err)
}
if _, err := NewIconReadyEvent(identity, nil); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconReadyEvent(nil) error = %v", err)
}
var typedNil *image.NRGBA
if _, err := NewIconReadyEvent(identity, typedNil); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconReadyEvent(typed nil) error = %v", err)
}
if _, err := NewIconFailedEvent(identity, IconFailureCode("raw-http-error")); !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("NewIconFailedEvent(invalid code) error = %v", err)
}
invalidPayloads := []Event{
{Type: EventIconReady, RequestID: "request", AppID: "app", Payload: "wrong"},
{Type: EventIconFailed, RequestID: "request", AppID: "app", Payload: "wrong"},
{
Type: EventIconReady,
RequestID: "request",
AppID: "app",
Payload: IconReadyPayload{
Reference: validReference,
DPI: 96,
},
},
{
Type: EventIconFailed,
RequestID: "request",
AppID: "app",
Payload: IconFailedPayload{
Reference: validReference,
DPI: 96,
ErrorCode: IconFailureCode("raw"),
},
},
}
for _, event := range invalidPayloads {
_, handled, err := ParseIconEvent(event)
if !handled || !errors.Is(err, ErrInvalidIconEvent) {
t.Fatalf("ParseIconEvent(%+v) handled=%t error=%v", event, handled, err)
}
}
}
+416
View File
@@ -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
}
+903
View File
@@ -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)
}
+323
View File
@@ -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
}
}
+305
View File
@@ -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