Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a52acbf926 | ||
|
|
db8d93e843 | ||
|
|
819b1cdf88 | ||
|
|
2e21c9f327 | ||
|
|
9da72caa01 | ||
|
|
0ffeff63e1 | ||
|
|
0d6ed05d7e | ||
|
|
45c242ecec | ||
|
|
6f920eb457 | ||
|
|
0e20dd76b2 | ||
|
|
cf9fc01c68 |
@@ -0,0 +1,23 @@
|
|||||||
|
name: Phase 0 build gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Go 1.25
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25.0"
|
||||||
|
cache: false
|
||||||
|
- name: Run Phase 0 verification
|
||||||
|
run: bash scripts/verify_phase0.sh
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Printf("%s stopped: %v", core.ProductName, err)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() 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()))
|
||||||
|
var operations op.Ops
|
||||||
|
|
||||||
|
for {
|
||||||
|
switch event := window.Event().(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return event.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
context := app.NewContext(&operations, event)
|
||||||
|
shell.Layout(context, theme)
|
||||||
|
event.Frame(context.Ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module softbox.local/app-modern
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.10.1
|
||||||
|
softbox.local/core v0.0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package windows provides modern Windows platform adapters and non-Windows
|
||||||
|
// stubs for package-level tests.
|
||||||
|
package windows
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
// Edition identifies the application build channel shown by the UI.
|
||||||
|
type Edition string
|
||||||
|
|
||||||
|
const EditionModern Edition = "Modern"
|
||||||
|
|
||||||
|
// Platform is the minimal boundary for target-specific capabilities.
|
||||||
|
type Platform interface {
|
||||||
|
OS() string
|
||||||
|
Edition() Edition
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the platform implementation selected by build tags.
|
||||||
|
func New() Platform {
|
||||||
|
return newPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
|
type platformStub struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platformStub{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platformStub) OS() string {
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platformStub) Edition() Edition {
|
||||||
|
return EditionModern
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
type platform struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platform{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) OS() string {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Edition() Edition {
|
||||||
|
return EditionModern
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package gio contains the modern Gio UI adapter.
|
||||||
|
//
|
||||||
|
// Layout code is rendering-only: it must not read files, access the network,
|
||||||
|
// calculate hashes, or directly mutate background application state.
|
||||||
|
package gio
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/io/semantic"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"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},
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowControls struct {
|
||||||
|
open widget.Clickable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppShell is the modern software catalog window.
|
||||||
|
type AppShell struct {
|
||||||
|
edition string
|
||||||
|
model *application.CatalogListModel
|
||||||
|
|
||||||
|
search widget.Editor
|
||||||
|
appList layout.List
|
||||||
|
categoryList layout.List
|
||||||
|
|
||||||
|
viewAll widget.Clickable
|
||||||
|
viewInstalled widget.Clickable
|
||||||
|
viewUpdates widget.Clickable
|
||||||
|
resetFilters widget.Clickable
|
||||||
|
closeDetail widget.Clickable
|
||||||
|
|
||||||
|
categoryControls map[string]*widget.Clickable
|
||||||
|
rows map[string]*rowControls
|
||||||
|
icons map[string]paint.ImageOp
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
shell.search.SingleLine = true
|
||||||
|
shell.SetItems(items)
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyIcon stores a background-decoded image for future Layout calls.
|
||||||
|
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
nextRows := make(map[string]*rowControls, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
controls = new(rowControls)
|
||||||
|
}
|
||||||
|
nextRows[item.ID] = controls
|
||||||
|
}
|
||||||
|
shell.rows = nextRows
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout drains input first and performs no disk, network or hash IO.
|
||||||
|
func (shell *AppShell) Layout(gtx layout.Context, theme *material.Theme) layout.Dimensions {
|
||||||
|
shell.drainInput(gtx)
|
||||||
|
shell.lastRendered = 0
|
||||||
|
shell.detailRendered = false
|
||||||
|
paint.Fill(gtx.Ops, shellColors.background)
|
||||||
|
|
||||||
|
return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutHeader(gtx, theme)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutContent(gtx, theme)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutFooter(gtx, theme)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||||
|
for {
|
||||||
|
if _, ok := shell.search.Update(gtx); !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell.model.SetQuery(shell.search.Text())
|
||||||
|
|
||||||
|
for shell.viewAll.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewAll)
|
||||||
|
}
|
||||||
|
for shell.viewInstalled.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewInstalled)
|
||||||
|
}
|
||||||
|
for shell.viewUpdates.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewUpdates)
|
||||||
|
}
|
||||||
|
for category, control := range shell.categoryControls {
|
||||||
|
for control.Clicked(gtx) {
|
||||||
|
shell.model.SetCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for appID, controls := range shell.rows {
|
||||||
|
for controls.open.Clicked(gtx) {
|
||||||
|
shell.model.Select(appID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for shell.resetFilters.Clicked(gtx) {
|
||||||
|
shell.search.SetText("")
|
||||||
|
shell.model.ResetFilters()
|
||||||
|
}
|
||||||
|
for shell.closeDetail.Clicked(gtx) {
|
||||||
|
shell.model.Select("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) layoutContent(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
width := gtx.Dp(unit.Dp(168))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.muted,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, "软件视图")
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return layout.Inset{
|
||||||
|
Left: unit.Dp(8), Bottom: unit.Dp(8),
|
||||||
|
}.Layout(gtx, label.Layout)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewAll,
|
||||||
|
"全部软件",
|
||||||
|
application.CatalogViewAll,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewInstalled,
|
||||||
|
"已安装",
|
||||||
|
application.CatalogViewInstalled,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewUpdates,
|
||||||
|
"可更新",
|
||||||
|
application.CatalogViewUpdates,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
selected, hasSelection := shell.model.SelectedItem()
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(10),
|
||||||
|
layout.UniformInset(unit.Dp(16)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutCatalog(gtx, theme)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Width: unit.Dp(12)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
width := gtx.Dp(unit.Dp(320))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return shell.layoutDetail(gtx, theme, selected)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutCatalog(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%d / %d 项", len(visible), shell.model.TotalCount()),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return shell.layoutEmptyState(gtx, theme)
|
||||||
|
}
|
||||||
|
return shell.appList.Layout(gtx, len(visible), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.lastRendered++
|
||||||
|
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppRow(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(88))
|
||||||
|
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp(fmt.Sprintf(
|
||||||
|
"%s,版本 %s,状态 %s",
|
||||||
|
item.Name,
|
||||||
|
item.Version,
|
||||||
|
statusLabel(item.Status),
|
||||||
|
)).Add(gtx.Ops)
|
||||||
|
|
||||||
|
background := shellColors.muted
|
||||||
|
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||||
|
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||||
|
}
|
||||||
|
if shell.model.SelectedID() == item.ID {
|
||||||
|
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
background,
|
||||||
|
unit.Dp(8),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(48),
|
||||||
|
unit.Dp(8),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, item.Name).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s · %s · %s",
|
||||||
|
item.ID,
|
||||||
|
item.Version,
|
||||||
|
item.Category,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body1(theme, statusLabel(item.Status))
|
||||||
|
label.Color = statusColor(item.Status)
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, actionLabel(item))
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppIcon(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
name string,
|
||||||
|
iconSize unit.Dp,
|
||||||
|
radius unit.Dp,
|
||||||
|
) layout.Dimensions {
|
||||||
|
size := gtx.Dp(iconSize)
|
||||||
|
gtx.Constraints.Min = image.Pt(size, size)
|
||||||
|
gtx.Constraints.Max = gtx.Constraints.Min
|
||||||
|
if icon, exists := shell.icons[appID]; exists {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(2)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return widget.Image{
|
||||||
|
Src: icon,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.Center,
|
||||||
|
}.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
letter := "S"
|
||||||
|
for _, character := range name {
|
||||||
|
letter = string(character)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.primary,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(0)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.H6(theme, letter)
|
||||||
|
label.Color = shellColors.onPrimary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) 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(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) 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 = "联网刷新或存在已验证缓存后,软件会显示在这里。"
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "目录状态:等待已验证 Catalog")
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionLabel(item application.CatalogListItem) string {
|
||||||
|
if item.Reason != "" || item.Status == domain.StatusIncompatible {
|
||||||
|
return "查看不可用原因"
|
||||||
|
}
|
||||||
|
switch item.Status {
|
||||||
|
case domain.StatusInstalled:
|
||||||
|
return "查看或启动"
|
||||||
|
case domain.StatusUpdateAvailable:
|
||||||
|
return "查看更新"
|
||||||
|
case domain.StatusRunning:
|
||||||
|
return "查看运行状态"
|
||||||
|
case domain.StatusQueued,
|
||||||
|
domain.StatusDownloading,
|
||||||
|
domain.StatusVerifying,
|
||||||
|
domain.StatusExtracting,
|
||||||
|
domain.StatusInstalling:
|
||||||
|
return "查看任务"
|
||||||
|
case domain.StatusFailed, domain.StatusRollbackPending:
|
||||||
|
return "查看恢复选项"
|
||||||
|
default:
|
||||||
|
if item.Installable {
|
||||||
|
return "查看并安装"
|
||||||
|
}
|
||||||
|
return "查看详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailField(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
labelText string,
|
||||||
|
value string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, labelText)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(material.Body2(theme, value).Layout),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackText(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonLabel(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "deprecated":
|
||||||
|
return "软件已停止发布,不能新装或更新"
|
||||||
|
case "minimum_os":
|
||||||
|
return "当前 Windows 版本低于最低要求"
|
||||||
|
case "architecture":
|
||||||
|
return "没有适用于当前系统架构的软件包"
|
||||||
|
default:
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
go func() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Printf("%s Legacy stopped: %v", core.ProductName, err)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
app.Main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() 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()))
|
||||||
|
var operations op.Ops
|
||||||
|
|
||||||
|
for {
|
||||||
|
switch event := window.Event().(type) {
|
||||||
|
case app.DestroyEvent:
|
||||||
|
return event.Err
|
||||||
|
case app.FrameEvent:
|
||||||
|
context := app.NewContext(&operations, event)
|
||||||
|
shell.Layout(context, theme)
|
||||||
|
event.Frame(context.Ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module softbox.local/app-win7
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
gioui.org v0.6.0
|
||||||
|
softbox.local/core v0.0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
go 1.20
|
||||||
|
|
||||||
|
use (
|
||||||
|
.
|
||||||
|
../core
|
||||||
|
)
|
||||||
|
|
||||||
|
replace softbox.local/core v0.0.0 => ../core
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package windows provides Win7-compatible platform adapters and non-Windows
|
||||||
|
// stubs for package-level tests.
|
||||||
|
package windows
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
// Edition identifies the application build channel shown by the UI.
|
||||||
|
type Edition string
|
||||||
|
|
||||||
|
const EditionLegacy Edition = "Legacy"
|
||||||
|
|
||||||
|
// Platform is the minimal boundary for target-specific capabilities.
|
||||||
|
type Platform interface {
|
||||||
|
OS() string
|
||||||
|
Edition() Edition
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the platform implementation selected by build tags.
|
||||||
|
func New() Platform {
|
||||||
|
return newPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
|
type platformStub struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platformStub{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platformStub) OS() string {
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platformStub) Edition() Edition {
|
||||||
|
return EditionLegacy
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
type platform struct{}
|
||||||
|
|
||||||
|
func newPlatform() Platform {
|
||||||
|
return platform{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) OS() string {
|
||||||
|
return "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (platform) Edition() Edition {
|
||||||
|
return EditionLegacy
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package gio contains the Win7-compatible Gio UI adapter.
|
||||||
|
//
|
||||||
|
// Layout code is rendering-only: it must not read files, access the network,
|
||||||
|
// calculate hashes, or directly mutate background application state.
|
||||||
|
package gio
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
package gio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/io/semantic"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"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},
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowControls struct {
|
||||||
|
open widget.Clickable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppShell is the Legacy software catalog window.
|
||||||
|
type AppShell struct {
|
||||||
|
edition string
|
||||||
|
model *application.CatalogListModel
|
||||||
|
|
||||||
|
search widget.Editor
|
||||||
|
appList layout.List
|
||||||
|
categoryList layout.List
|
||||||
|
|
||||||
|
viewAll widget.Clickable
|
||||||
|
viewInstalled widget.Clickable
|
||||||
|
viewUpdates widget.Clickable
|
||||||
|
resetFilters widget.Clickable
|
||||||
|
closeDetail widget.Clickable
|
||||||
|
|
||||||
|
categoryControls map[string]*widget.Clickable
|
||||||
|
rows map[string]*rowControls
|
||||||
|
icons map[string]paint.ImageOp
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
shell.search.SingleLine = true
|
||||||
|
shell.SetItems(items)
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyIcon stores a background-decoded image for future Layout calls.
|
||||||
|
func (shell *AppShell) ApplyIcon(appID string, icon image.Image) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
nextRows := make(map[string]*rowControls, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
controls = new(rowControls)
|
||||||
|
}
|
||||||
|
nextRows[item.ID] = controls
|
||||||
|
}
|
||||||
|
shell.rows = nextRows
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.edition+" · Legacy · Windows 7 SP1 x64",
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||||
|
for {
|
||||||
|
if _, ok := shell.search.Update(gtx); !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell.model.SetQuery(shell.search.Text())
|
||||||
|
for shell.viewAll.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewAll)
|
||||||
|
}
|
||||||
|
for shell.viewInstalled.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewInstalled)
|
||||||
|
}
|
||||||
|
for shell.viewUpdates.Clicked(gtx) {
|
||||||
|
shell.model.SetView(application.CatalogViewUpdates)
|
||||||
|
}
|
||||||
|
for category, control := range shell.categoryControls {
|
||||||
|
for control.Clicked(gtx) {
|
||||||
|
shell.model.SetCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for appID, controls := range shell.rows {
|
||||||
|
for controls.open.Clicked(gtx) {
|
||||||
|
shell.model.Select(appID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for shell.resetFilters.Clicked(gtx) {
|
||||||
|
shell.search.SetText("")
|
||||||
|
shell.model.ResetFilters()
|
||||||
|
}
|
||||||
|
for shell.closeDetail.Clicked(gtx) {
|
||||||
|
shell.model.Select("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) layoutContent(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
width := gtx.Dp(unit.Dp(152))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.muted,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.UniformInset(unit.Dp(10)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewAll,
|
||||||
|
"全部软件",
|
||||||
|
application.CatalogViewAll,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewInstalled,
|
||||||
|
"已安装",
|
||||||
|
application.CatalogViewInstalled,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutViewButton(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
&shell.viewUpdates,
|
||||||
|
"可更新",
|
||||||
|
application.CatalogViewUpdates,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
selected, hasSelection := shell.model.SelectedItem()
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
unit.Dp(6),
|
||||||
|
layout.UniformInset(unit.Dp(12)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
visible := shell.model.VisibleItems()
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.H6(theme, viewTitle(shell.model.View())).Layout),
|
||||||
|
layout.Flexed(1, layout.Spacer{}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%d / %d 项",
|
||||||
|
len(visible),
|
||||||
|
shell.model.TotalCount(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(visible) == 0 {
|
||||||
|
return shell.layoutEmptyState(gtx, theme)
|
||||||
|
}
|
||||||
|
return shell.appList.Layout(gtx, len(visible), func(
|
||||||
|
gtx layout.Context,
|
||||||
|
index int,
|
||||||
|
) layout.Dimensions {
|
||||||
|
shell.lastRendered++
|
||||||
|
return shell.layoutAppRow(gtx, theme, visible[index])
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !hasSelection {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
width := gtx.Dp(unit.Dp(280))
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return shell.layoutDetail(gtx, theme, selected)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppRow(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
item application.CatalogListItem,
|
||||||
|
) layout.Dimensions {
|
||||||
|
controls := shell.rows[item.ID]
|
||||||
|
if controls == nil {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(80))
|
||||||
|
return controls.open.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
semantic.Button.Add(gtx.Ops)
|
||||||
|
semantic.DescriptionOp(fmt.Sprintf(
|
||||||
|
"%s,版本 %s,状态 %s",
|
||||||
|
item.Name,
|
||||||
|
item.Version,
|
||||||
|
statusLabel(item.Status),
|
||||||
|
)).Add(gtx.Ops)
|
||||||
|
background := shellColors.muted
|
||||||
|
if controls.open.Hovered() || gtx.Focused(&controls.open) {
|
||||||
|
background = color.NRGBA{R: 236, G: 253, B: 245, A: 255}
|
||||||
|
}
|
||||||
|
if shell.model.SelectedID() == item.ID {
|
||||||
|
background = color.NRGBA{R: 220, G: 252, B: 231, A: 255}
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
background,
|
||||||
|
unit.Dp(5),
|
||||||
|
layout.UniformInset(unit.Dp(10)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return shell.layoutAppIcon(
|
||||||
|
gtx,
|
||||||
|
theme,
|
||||||
|
item.ID,
|
||||||
|
item.Name,
|
||||||
|
unit.Dp(40),
|
||||||
|
unit.Dp(5),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(material.Body1(theme, item.Name).Layout),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(
|
||||||
|
theme,
|
||||||
|
fmt.Sprintf("%s · %s · %s", item.ID, item.Version, item.Category),
|
||||||
|
)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body2(theme, statusLabel(item.Status))
|
||||||
|
label.Color = statusColor(item.Status)
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) layoutAppIcon(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
appID string,
|
||||||
|
name string,
|
||||||
|
iconSize unit.Dp,
|
||||||
|
radius unit.Dp,
|
||||||
|
) layout.Dimensions {
|
||||||
|
size := gtx.Dp(iconSize)
|
||||||
|
gtx.Constraints.Min = image.Pt(size, size)
|
||||||
|
gtx.Constraints.Max = gtx.Constraints.Min
|
||||||
|
if icon, exists := shell.icons[appID]; exists {
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.surface,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(2)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return widget.Image{
|
||||||
|
Src: icon,
|
||||||
|
Fit: widget.Contain,
|
||||||
|
Position: layout.Center,
|
||||||
|
}.Layout(gtx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
letter := "S"
|
||||||
|
for _, character := range name {
|
||||||
|
letter = string(character)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return panel(
|
||||||
|
gtx,
|
||||||
|
shellColors.primary,
|
||||||
|
radius,
|
||||||
|
layout.UniformInset(unit.Dp(0)),
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Body1(theme, letter)
|
||||||
|
label.Color = shellColors.onPrimary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell *AppShell) 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(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) 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 = "联网刷新或读取已验证缓存后会显示软件。"
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailField(
|
||||||
|
gtx layout.Context,
|
||||||
|
theme *material.Theme,
|
||||||
|
labelText string,
|
||||||
|
value string,
|
||||||
|
) layout.Dimensions {
|
||||||
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := material.Caption(theme, labelText)
|
||||||
|
label.Color = shellColors.secondary
|
||||||
|
return label.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(material.Caption(theme, value).Layout),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackText(value, fallback string) string {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonLabel(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "deprecated":
|
||||||
|
return "软件已停止发布"
|
||||||
|
case "minimum_os":
|
||||||
|
return "Windows 版本低于最低要求"
|
||||||
|
case "architecture":
|
||||||
|
return "没有当前架构的软件包"
|
||||||
|
default:
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppShellRendersSelectedDetailAndAppliedIcon(t *testing.T) {
|
||||||
|
item := application.CatalogListItem{
|
||||||
|
ID: "json-parser",
|
||||||
|
Name: "JSON解析工具",
|
||||||
|
Description: "格式化并检查 JSON",
|
||||||
|
Version: "1.2.0",
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"JSON", "格式化"},
|
||||||
|
Homepage: "https://example.invalid/json-parser",
|
||||||
|
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||||
|
Status: domain.StatusInstalled,
|
||||||
|
Installed: true,
|
||||||
|
}
|
||||||
|
shell := NewAppShell("Legacy", item)
|
||||||
|
icon := image.NewNRGBA(image.Rect(0, 0, 32, 32))
|
||||||
|
shell.ApplyIcon(item.ID, icon)
|
||||||
|
shell.model.Select(item.ID)
|
||||||
|
|
||||||
|
shell.Layout(testContext(image.Pt(1200, 760)), NewTheme())
|
||||||
|
if !shell.detailRendered {
|
||||||
|
t.Fatal("selected app detail was not rendered")
|
||||||
|
}
|
||||||
|
if _, exists := shell.icons[item.ID]; !exists {
|
||||||
|
t.Fatal("ApplyIcon did not retain the prepared image operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.ApplyIcon(item.ID, nil)
|
||||||
|
if _, exists := shell.icons[item.ID]; exists {
|
||||||
|
t.Fatal("ApplyIcon(nil) did not remove the image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContext(size image.Point) layout.Context {
|
||||||
|
var operations op.Ops
|
||||||
|
return layout.Context{
|
||||||
|
Ops: &operations,
|
||||||
|
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
|
||||||
|
Constraints: layout.Exact(size),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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 immutable-by-convention snapshot.
|
||||||
|
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() {
|
||||||
|
model.visible = model.visible[:0]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
model.visible = append(model.visible, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesView(item CatalogListItem, view CatalogView) bool {
|
||||||
|
switch view {
|
||||||
|
case CatalogViewAll:
|
||||||
|
return true
|
||||||
|
case CatalogViewInstalled:
|
||||||
|
return item.Installed
|
||||||
|
case CatalogViewUpdates:
|
||||||
|
return item.Status == domain.StatusUpdateAvailable
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesQuery(item CatalogListItem, query string) bool {
|
||||||
|
if strings.Contains(strings.ToLower(item.Name), query) ||
|
||||||
|
strings.Contains(strings.ToLower(item.ID), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, tag := range item.Tags {
|
||||||
|
if strings.Contains(strings.ToLower(tag), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectCategories(items []CatalogListItem) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
categories := make([]string, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Category == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[item.Category]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Category] = struct{}{}
|
||||||
|
categories = append(categories, item.Category)
|
||||||
|
}
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCatalogItems(items []CatalogListItem) []CatalogListItem {
|
||||||
|
cloned := make([]CatalogListItem, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
cloned[index] = item
|
||||||
|
cloned[index].Tags = append([]string(nil), item.Tags...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, value string) bool {
|
||||||
|
for _, candidate := range values {
|
||||||
|
if candidate == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItemID(items []CatalogListItem, appID string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == appID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 assertVisibleIDs(t *testing.T, model *CatalogListModel, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
visible := model.VisibleItems()
|
||||||
|
if len(visible) != len(want) {
|
||||||
|
t.Fatalf("visible IDs length = %d, want %d: %#v", len(visible), len(want), visible)
|
||||||
|
}
|
||||||
|
for index, item := range visible {
|
||||||
|
if item.ID != want[index] {
|
||||||
|
t.Fatalf("visible[%d].ID = %q, want %q", index, item.ID, want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package application coordinates SoftBox use cases through injected ports.
|
||||||
|
package application
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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"
|
||||||
|
EventInstallCompleted EventType = "InstallCompleted"
|
||||||
|
EventInstallRolledBack EventType = "InstallRolledBack"
|
||||||
|
EventAppStarted EventType = "AppStarted"
|
||||||
|
EventAppExited EventType = "AppExited"
|
||||||
|
EventLicenseChanged EventType = "LicenseChanged"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validEventTypes = map[EventType]struct{}{
|
||||||
|
EventCatalogRefreshed: {},
|
||||||
|
EventCatalogRejected: {},
|
||||||
|
EventDownloadStarted: {},
|
||||||
|
EventDownloadProgress: {},
|
||||||
|
EventDownloadPaused: {},
|
||||||
|
EventDownloadCompleted: {},
|
||||||
|
EventDownloadFailed: {},
|
||||||
|
EventInstallCompleted: {},
|
||||||
|
EventInstallRolledBack: {},
|
||||||
|
EventAppStarted: {},
|
||||||
|
EventAppExited: {},
|
||||||
|
EventLicenseChanged: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether eventType is part of the documented event contract.
|
||||||
|
func (eventType EventType) Valid() bool {
|
||||||
|
_, ok := validEventTypes[eventType]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is the common envelope delivered from background use cases to adapters.
|
||||||
|
//
|
||||||
|
// Payload is event-specific and will be replaced by concrete payload types as
|
||||||
|
// the corresponding use cases are implemented.
|
||||||
|
type Event struct {
|
||||||
|
Type EventType
|
||||||
|
RequestID string
|
||||||
|
AppID string
|
||||||
|
Payload any
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEventTypeValid(t *testing.T) {
|
||||||
|
eventTypes := []EventType{
|
||||||
|
EventCatalogRefreshed,
|
||||||
|
EventCatalogRejected,
|
||||||
|
EventDownloadStarted,
|
||||||
|
EventDownloadProgress,
|
||||||
|
EventDownloadPaused,
|
||||||
|
EventDownloadCompleted,
|
||||||
|
EventDownloadFailed,
|
||||||
|
EventInstallCompleted,
|
||||||
|
EventInstallRolledBack,
|
||||||
|
EventAppStarted,
|
||||||
|
EventAppExited,
|
||||||
|
EventLicenseChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, eventType := range eventTypes {
|
||||||
|
if !eventType.Valid() {
|
||||||
|
t.Errorf("event type %q should be valid", eventType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if EventType("Unknown").Valid() {
|
||||||
|
t.Fatal("unknown event type should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidEvent indicates that an event is not part of the event contract.
|
||||||
|
ErrInvalidEvent = errors.New("invalid application event")
|
||||||
|
// ErrRuntimeClosed indicates that the runtime no longer accepts events.
|
||||||
|
ErrRuntimeClosed = errors.New("application runtime closed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runtime is the minimal event bus shared by background use cases and adapters.
|
||||||
|
type Runtime struct {
|
||||||
|
events chan Event
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRuntime creates an event runtime with the requested queue capacity.
|
||||||
|
func NewRuntime(buffer int) *Runtime {
|
||||||
|
if buffer < 0 {
|
||||||
|
panic("application runtime buffer must not be negative")
|
||||||
|
}
|
||||||
|
return &Runtime{
|
||||||
|
events: make(chan Event, buffer),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish queues an event or returns when the context/runtime is closed.
|
||||||
|
func (runtime *Runtime) Publish(ctx context.Context, event Event) error {
|
||||||
|
if !event.Type.Valid() {
|
||||||
|
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-runtime.done:
|
||||||
|
return ErrRuntimeClosed
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-runtime.done:
|
||||||
|
return ErrRuntimeClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case runtime.events <- event:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events exposes the read-only event stream to adapters.
|
||||||
|
func (runtime *Runtime) Events() <-chan Event {
|
||||||
|
return runtime.events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done is closed when the runtime stops accepting events.
|
||||||
|
func (runtime *Runtime) Done() <-chan struct{} {
|
||||||
|
return runtime.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops future publishes. It is safe to call more than once.
|
||||||
|
func (runtime *Runtime) Close() {
|
||||||
|
runtime.closeOnce.Do(func() {
|
||||||
|
close(runtime.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimePublish(t *testing.T) {
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
event := Event{
|
||||||
|
Type: EventDownloadStarted,
|
||||||
|
RequestID: "request-1",
|
||||||
|
AppID: "json-parser",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.Publish(context.Background(), event); err != nil {
|
||||||
|
t.Fatalf("Publish() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := <-runtime.Events()
|
||||||
|
if got != event {
|
||||||
|
t.Fatalf("Events() got %#v, want %#v", got, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeRejectsInvalidEvent(t *testing.T) {
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
|
||||||
|
err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")})
|
||||||
|
if !errors.Is(err, ErrInvalidEvent) {
|
||||||
|
t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimePublishHonorsContextCancellation(t *testing.T) {
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil {
|
||||||
|
t.Fatalf("first Publish() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := runtime.Publish(ctx, Event{Type: EventDownloadProgress})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeClose(t *testing.T) {
|
||||||
|
runtime := NewRuntime(1)
|
||||||
|
runtime.Close()
|
||||||
|
runtime.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-runtime.Done():
|
||||||
|
default:
|
||||||
|
t.Fatal("Done() should be closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted})
|
||||||
|
if !errors.Is(err, ErrRuntimeClosed) {
|
||||||
|
t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var integerJSONNumber = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
|
||||||
|
|
||||||
|
func parseRestrictedJSON(data []byte) (any, error) {
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
value, err := decodeJSONValue(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := decoder.Token(); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: trailing data: %v", ErrInvalidDocument, err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONValue(decoder *json.Decoder) (any, error) {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidDocument, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value := token.(type) {
|
||||||
|
case json.Delim:
|
||||||
|
switch value {
|
||||||
|
case '{':
|
||||||
|
object := make(map[string]any)
|
||||||
|
for decoder.More() {
|
||||||
|
keyToken, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: object key: %v", ErrInvalidDocument, err)
|
||||||
|
}
|
||||||
|
key, ok := keyToken.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
if _, exists := object[key]; exists {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key)
|
||||||
|
}
|
||||||
|
child, err := decodeJSONValue(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
object[key] = child
|
||||||
|
}
|
||||||
|
end, err := decoder.Token()
|
||||||
|
if err != nil || end != json.Delim('}') {
|
||||||
|
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
return object, nil
|
||||||
|
case '[':
|
||||||
|
var array []any
|
||||||
|
for decoder.More() {
|
||||||
|
child, err := decodeJSONValue(decoder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
array = append(array, child)
|
||||||
|
}
|
||||||
|
end, err := decoder.Token()
|
||||||
|
if err != nil || end != json.Delim(']') {
|
||||||
|
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
return array, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unexpected delimiter %q", ErrInvalidDocument, value)
|
||||||
|
}
|
||||||
|
case json.Number:
|
||||||
|
if !integerJSONNumber.MatchString(string(value)) {
|
||||||
|
return nil, fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
case string, bool, nil:
|
||||||
|
return value, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unsupported token %T", ErrInvalidDocument, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalJSON(value any) ([]byte, error) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := appendCanonicalJSON(&buffer, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case nil:
|
||||||
|
buffer.WriteString("null")
|
||||||
|
case bool:
|
||||||
|
if value {
|
||||||
|
buffer.WriteString("true")
|
||||||
|
} else {
|
||||||
|
buffer.WriteString("false")
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: encode string: %v", ErrInvalidDocument, err)
|
||||||
|
}
|
||||||
|
buffer.Write(encoded)
|
||||||
|
case json.Number:
|
||||||
|
if !integerJSONNumber.MatchString(string(value)) {
|
||||||
|
return fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||||
|
}
|
||||||
|
buffer.WriteString(string(value))
|
||||||
|
case []any:
|
||||||
|
buffer.WriteByte('[')
|
||||||
|
for index, child := range value {
|
||||||
|
if index > 0 {
|
||||||
|
buffer.WriteByte(',')
|
||||||
|
}
|
||||||
|
if err := appendCanonicalJSON(buffer, child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.WriteByte(']')
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(value))
|
||||||
|
for key := range value {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
buffer.WriteByte('{')
|
||||||
|
for index, key := range keys {
|
||||||
|
if index > 0 {
|
||||||
|
buffer.WriteByte(',')
|
||||||
|
}
|
||||||
|
encodedKey, err := json.Marshal(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: encode key: %v", ErrInvalidDocument, err)
|
||||||
|
}
|
||||||
|
buffer.Write(encodedKey)
|
||||||
|
buffer.WriteByte(':')
|
||||||
|
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.WriteByte('}')
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unsupported value %T", ErrInvalidDocument, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Client composes signed loading, protocol validation and target filtering.
|
||||||
|
type Client struct {
|
||||||
|
loader *Loader
|
||||||
|
parser Parser
|
||||||
|
target Target
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates the formal Catalog loading path. Protocol validation is
|
||||||
|
// installed on Loader so invalid remote data cannot replace the usable cache.
|
||||||
|
func NewClient(
|
||||||
|
verifier Verifier,
|
||||||
|
fetcher Fetcher,
|
||||||
|
cache Cache,
|
||||||
|
target Target,
|
||||||
|
) *Client {
|
||||||
|
parser := Parser{ExpectedChannel: target.Channel}
|
||||||
|
return &Client{
|
||||||
|
loader: NewLoader(verifier, fetcher, cache, parser),
|
||||||
|
parser: parser,
|
||||||
|
target: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load returns a verified, validated and target-filtered Catalog.
|
||||||
|
func (client *Client) Load(ctx context.Context) (Catalog, error) {
|
||||||
|
result, err := client.loader.Load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Catalog{}, err
|
||||||
|
}
|
||||||
|
manifest, err := client.parser.Parse(result.Document)
|
||||||
|
if err != nil {
|
||||||
|
return Catalog{}, err
|
||||||
|
}
|
||||||
|
entries, err := Filter(manifest, client.target)
|
||||||
|
if err != nil {
|
||||||
|
return Catalog{}, err
|
||||||
|
}
|
||||||
|
return Catalog{
|
||||||
|
Manifest: manifest,
|
||||||
|
Entries: entries,
|
||||||
|
Source: result.Source,
|
||||||
|
Warning: result.Warning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientValidatesBeforeReplacingCache(t *testing.T) {
|
||||||
|
publicKey, privateKey := catalogTestKey()
|
||||||
|
verifier, err := NewVerifier(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVerifier() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||||
|
validDocument, _ := signCatalogPayload(t, validPayload, privateKey)
|
||||||
|
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||||
|
|
||||||
|
var invalidPayload map[string]any
|
||||||
|
if err := json.Unmarshal(validPayload, &invalidPayload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
invalidPayload["channel"] = "win7"
|
||||||
|
encodedInvalid, err := json.Marshal(invalidPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode invalid payload: %v", err)
|
||||||
|
}
|
||||||
|
invalidDocument, _ := signCatalogPayload(t, encodedInvalid, privateKey)
|
||||||
|
|
||||||
|
client := NewClient(
|
||||||
|
verifier,
|
||||||
|
FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return invalidDocument, nil
|
||||||
|
}),
|
||||||
|
cache,
|
||||||
|
Target{
|
||||||
|
Channel: ChannelModern,
|
||||||
|
OS: Windows10,
|
||||||
|
Architecture: ArchitectureAMD64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result, err := client.Load(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != SourceCache {
|
||||||
|
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Warning, ErrChannelMismatch) {
|
||||||
|
t.Fatalf("Warning = %v, want %v", result.Warning, ErrChannelMismatch)
|
||||||
|
}
|
||||||
|
if cache.storeCalls != 0 {
|
||||||
|
t.Fatalf("Store() calls = %d, want 0", cache.storeCalls)
|
||||||
|
}
|
||||||
|
if len(result.Entries) != 1 || result.Entries[0].App.ID != "json-parser" {
|
||||||
|
t.Fatalf("Entries = %#v", result.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCachePathEmpty = errors.New("catalog cache path is empty")
|
||||||
|
|
||||||
|
// FileCache stores a signed Catalog document and a crash-recovery backup.
|
||||||
|
type FileCache struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileCache(path string) *FileCache {
|
||||||
|
return &FileCache{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *FileCache) Load() ([]byte, error) {
|
||||||
|
cache.mu.Lock()
|
||||||
|
defer cache.mu.Unlock()
|
||||||
|
|
||||||
|
if cache.path == "" {
|
||||||
|
return nil, ErrCachePathEmpty
|
||||||
|
}
|
||||||
|
document, err := os.ReadFile(cache.path)
|
||||||
|
if err == nil {
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.ReadFile(cache.backupPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *FileCache) Store(document []byte) error {
|
||||||
|
cache.mu.Lock()
|
||||||
|
defer cache.mu.Unlock()
|
||||||
|
|
||||||
|
if cache.path == "" {
|
||||||
|
return ErrCachePathEmpty
|
||||||
|
}
|
||||||
|
directory := filepath.Dir(cache.path)
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create catalog cache directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
temporary, err := os.CreateTemp(directory, ".catalog-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create catalog cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("protect catalog cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(document); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("write catalog cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("sync catalog cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close catalog cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupPath := cache.backupPath()
|
||||||
|
movedCurrent := false
|
||||||
|
if _, err := os.Stat(cache.path); err == nil {
|
||||||
|
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove stale catalog cache backup: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(cache.path, backupPath); err != nil {
|
||||||
|
return fmt.Errorf("backup current catalog cache: %w", err)
|
||||||
|
}
|
||||||
|
movedCurrent = true
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("inspect current catalog cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(temporaryPath, cache.path); err != nil {
|
||||||
|
if movedCurrent {
|
||||||
|
_ = os.Rename(backupPath, cache.path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("activate catalog cache: %w", err)
|
||||||
|
}
|
||||||
|
if movedCurrent {
|
||||||
|
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove catalog cache backup: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *FileCache) backupPath() string {
|
||||||
|
return cache.path + ".backup"
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileCacheStoreAndUpdate(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "cache", "manifest.json")
|
||||||
|
cache := NewFileCache(path)
|
||||||
|
|
||||||
|
if err := cache.Store([]byte("first")); err != nil {
|
||||||
|
t.Fatalf("Store(first) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := cache.Store([]byte("second")); err != nil {
|
||||||
|
t.Fatalf("Store(second) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := cache.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "second" {
|
||||||
|
t.Fatalf("Load() = %q, want %q", got, "second")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".backup"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("backup should be removed after successful update, stat error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCacheLoadsBackupAfterInterruptedSwitch(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
|
cache := NewFileCache(path)
|
||||||
|
if err := cache.Store([]byte("verified")); err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(path, path+".backup"); err != nil {
|
||||||
|
t.Fatalf("simulate interrupted switch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := cache.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "verified" {
|
||||||
|
t.Fatalf("Load() = %q, want %q", got, "verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Filter returns visible entries for target while retaining incompatible apps
|
||||||
|
// with a stable reason and no package action.
|
||||||
|
func Filter(manifest Manifest, target Target) ([]Entry, error) {
|
||||||
|
if !target.Channel.valid() ||
|
||||||
|
!target.OS.valid() ||
|
||||||
|
!target.Architecture.valid() {
|
||||||
|
return nil, fmt.Errorf("%w: %+v", ErrUnsupportedTarget, target)
|
||||||
|
}
|
||||||
|
if manifest.Channel != target.Channel {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
ErrChannelMismatch,
|
||||||
|
manifest.Channel,
|
||||||
|
target.Channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]Entry, 0, len(manifest.Apps))
|
||||||
|
for _, app := range manifest.Apps {
|
||||||
|
if app.Status == CatalogStatusHidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := Entry{App: app}
|
||||||
|
if app.Status == CatalogStatusDeprecated {
|
||||||
|
entry.Reason = ReasonDeprecated
|
||||||
|
entries = append(entries, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !supportsOS(target.OS, app.MinOS) {
|
||||||
|
entry.Reason = ReasonMinimumOS
|
||||||
|
entries = append(entries, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
publishedPackage, ok := app.Packages[target.Architecture]
|
||||||
|
if !ok {
|
||||||
|
entry.Reason = ReasonArchitecture
|
||||||
|
entries = append(entries, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry.Package = &publishedPackage
|
||||||
|
entry.Installable = true
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsOS(target, minimum WindowsRelease) bool {
|
||||||
|
ranks := map[WindowsRelease]int{
|
||||||
|
Windows7SP1: 7,
|
||||||
|
Windows10: 10,
|
||||||
|
Windows11: 11,
|
||||||
|
}
|
||||||
|
return ranks[target] >= ranks[minimum]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFilterHandlesStatusOSAndArchitecture(t *testing.T) {
|
||||||
|
manifest := validManifestForTest()
|
||||||
|
active := manifest.Apps[0]
|
||||||
|
|
||||||
|
deprecated := active
|
||||||
|
deprecated.ID = "deprecated-app"
|
||||||
|
deprecated.Status = CatalogStatusDeprecated
|
||||||
|
|
||||||
|
hidden := active
|
||||||
|
hidden.ID = "hidden-app"
|
||||||
|
hidden.Status = CatalogStatusHidden
|
||||||
|
|
||||||
|
newOS := active
|
||||||
|
newOS.ID = "windows-11-app"
|
||||||
|
newOS.MinOS = Windows11
|
||||||
|
|
||||||
|
wrongArchitecture := active
|
||||||
|
wrongArchitecture.ID = "x86-app"
|
||||||
|
wrongArchitecture.Architectures = []Architecture{Architecture386}
|
||||||
|
wrongArchitecture.Packages = map[Architecture]Package{
|
||||||
|
Architecture386: active.Packages[ArchitectureAMD64],
|
||||||
|
}
|
||||||
|
manifest.Apps = []App{active, deprecated, hidden, newOS, wrongArchitecture}
|
||||||
|
|
||||||
|
entries, err := Filter(manifest, Target{
|
||||||
|
Channel: ChannelModern,
|
||||||
|
OS: Windows10,
|
||||||
|
Architecture: ArchitectureAMD64,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Filter() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 4 {
|
||||||
|
t.Fatalf("len(entries) = %d, want 4", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEntry := func(index int, id string, installable bool, reason AvailabilityReason) {
|
||||||
|
t.Helper()
|
||||||
|
entry := entries[index]
|
||||||
|
if entry.App.ID != id ||
|
||||||
|
entry.Installable != installable ||
|
||||||
|
entry.Reason != reason {
|
||||||
|
t.Fatalf(
|
||||||
|
"entries[%d] = {%q %t %q}, want {%q %t %q}",
|
||||||
|
index,
|
||||||
|
entry.App.ID,
|
||||||
|
entry.Installable,
|
||||||
|
entry.Reason,
|
||||||
|
id,
|
||||||
|
installable,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEntry(0, "json-parser", true, ReasonNone)
|
||||||
|
assertEntry(1, "deprecated-app", false, ReasonDeprecated)
|
||||||
|
assertEntry(2, "windows-11-app", false, ReasonMinimumOS)
|
||||||
|
assertEntry(3, "x86-app", false, ReasonArchitecture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterAllowsWin7CompatibleAppOnModernWindows(t *testing.T) {
|
||||||
|
manifest := validManifestForTest()
|
||||||
|
manifest.Apps[0].MinOS = Windows7SP1
|
||||||
|
|
||||||
|
entries, err := Filter(manifest, Target{
|
||||||
|
Channel: ChannelModern,
|
||||||
|
OS: Windows11,
|
||||||
|
Architecture: ArchitectureAMD64,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Filter() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 || !entries[0].Installable {
|
||||||
|
t.Fatalf("entries = %#v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultMaxManifestBytes int64 = 8 << 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInsecureCatalogURL = errors.New("catalog URL must use HTTPS")
|
||||||
|
ErrCatalogHTTPStatus = errors.New("catalog HTTP status is not successful")
|
||||||
|
ErrCatalogTooLarge = errors.New("catalog response exceeds size limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPFetcher obtains a Catalog document over HTTPS with a bounded response.
|
||||||
|
type HTTPFetcher struct {
|
||||||
|
URL string
|
||||||
|
Client *http.Client
|
||||||
|
MaxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch implements Fetcher.
|
||||||
|
func (fetcher HTTPFetcher) Fetch(ctx context.Context) ([]byte, error) {
|
||||||
|
parsedURL, err := url.Parse(fetcher.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse catalog URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateFetchURL(parsedURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create catalog request: %w", err)
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := fetcher.Client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
clientCopy := *client
|
||||||
|
previousRedirectCheck := client.CheckRedirect
|
||||||
|
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||||
|
if err := validateFetchURL(request.URL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if previousRedirectCheck != nil {
|
||||||
|
return previousRedirectCheck(request, via)
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := clientCopy.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch catalog: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrCatalogHTTPStatus, response.Status)
|
||||||
|
}
|
||||||
|
maxBytes := fetcher.MaxBytes
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = DefaultMaxManifestBytes
|
||||||
|
}
|
||||||
|
if response.ContentLength > maxBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: content-length %d, limit %d",
|
||||||
|
ErrCatalogTooLarge,
|
||||||
|
response.ContentLength,
|
||||||
|
maxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
document, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read catalog response: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(document)) > maxBytes {
|
||||||
|
return nil, fmt.Errorf("%w: limit %d", ErrCatalogTooLarge, maxBytes)
|
||||||
|
}
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFetchURL(parsedURL *url.URL) error {
|
||||||
|
if parsedURL == nil ||
|
||||||
|
parsedURL.Scheme != "https" ||
|
||||||
|
parsedURL.Host == "" ||
|
||||||
|
parsedURL.User != nil {
|
||||||
|
return ErrInsecureCatalogURL
|
||||||
|
}
|
||||||
|
if parsedURL.Fragment != "" {
|
||||||
|
return fmt.Errorf("%w: fragments are not allowed", ErrInsecureCatalogURL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPFetcherRequiresHTTPSAndBoundsResponse(t *testing.T) {
|
||||||
|
t.Run("insecure URL", func(t *testing.T) {
|
||||||
|
_, err := (HTTPFetcher{URL: "http://example.invalid/manifest.json"}).
|
||||||
|
Fetch(context.Background())
|
||||||
|
if !errors.Is(err, ErrInsecureCatalogURL) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want %v", err, ErrInsecureCatalogURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("successful HTTPS", func(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
if request.Header.Get("Accept") != "application/json" {
|
||||||
|
t.Errorf("Accept = %q", request.Header.Get("Accept"))
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte(`{"schema_version":1}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
document, err := (HTTPFetcher{
|
||||||
|
URL: server.URL,
|
||||||
|
Client: server.Client(),
|
||||||
|
MaxBytes: 64,
|
||||||
|
}).Fetch(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(document) != `{"schema_version":1}` {
|
||||||
|
t.Fatalf("document = %q", document)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("too large", func(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
writer.Header().Set("Content-Length", "6")
|
||||||
|
_, _ = writer.Write([]byte("123456"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := (HTTPFetcher{
|
||||||
|
URL: server.URL,
|
||||||
|
Client: server.Client(),
|
||||||
|
MaxBytes: 5,
|
||||||
|
}).Fetch(context.Background())
|
||||||
|
if !errors.Is(err, ErrCatalogTooLarge) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogTooLarge)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTP error", func(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
_, err := (HTTPFetcher{
|
||||||
|
URL: server.URL,
|
||||||
|
Client: server.Client(),
|
||||||
|
}).Fetch(context.Background())
|
||||||
|
if !errors.Is(err, ErrCatalogHTTPStatus) {
|
||||||
|
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogHTTPStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultMaxIconBytes int64 = 2 << 20
|
||||||
|
DefaultMaxIconDimension = 2048
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrIconReferenceInvalid = errors.New("icon reference is invalid")
|
||||||
|
ErrIconDPIInvalid = errors.New("icon DPI is invalid")
|
||||||
|
ErrIconHashMismatch = errors.New("icon SHA-256 does not match reference")
|
||||||
|
ErrIconTooLarge = errors.New("icon exceeds resource limits")
|
||||||
|
ErrIconImageInvalid = errors.New("icon image is invalid")
|
||||||
|
ErrIconCacheUnsafe = errors.New("icon cache layout is unsafe")
|
||||||
|
ErrNoValidIcon = errors.New("no valid icon available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// IconRequest identifies one content-addressed icon at one UI DPI.
|
||||||
|
type IconRequest struct {
|
||||||
|
Reference string
|
||||||
|
DPI int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconFetcher obtains icon bytes outside Gio Layout.
|
||||||
|
type IconFetcher interface {
|
||||||
|
FetchIcon(context.Context, IconRequest) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconFetchFunc adapts a function to IconFetcher.
|
||||||
|
type IconFetchFunc func(context.Context, IconRequest) ([]byte, error)
|
||||||
|
|
||||||
|
func (function IconFetchFunc) FetchIcon(
|
||||||
|
ctx context.Context,
|
||||||
|
request IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return function(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconSource identifies the cache tier that returned bytes.
|
||||||
|
type IconSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IconSourceMemory IconSource = "memory"
|
||||||
|
IconSourceDisk IconSource = "disk"
|
||||||
|
IconSourceRemote IconSource = "remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IconResult contains verified bytes and a non-fatal disk-store warning.
|
||||||
|
type IconResult struct {
|
||||||
|
Bytes []byte
|
||||||
|
Source IconSource
|
||||||
|
Warning error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconLoadError preserves disk and refresh failures.
|
||||||
|
type IconLoadError struct {
|
||||||
|
Disk error
|
||||||
|
Fetch error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (loadError *IconLoadError) Error() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s: disk=%v; fetch=%v",
|
||||||
|
ErrNoValidIcon,
|
||||||
|
loadError.Disk,
|
||||||
|
loadError.Fetch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (loadError *IconLoadError) Unwrap() []error {
|
||||||
|
return []error{ErrNoValidIcon, loadError.Disk, loadError.Fetch}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconCache is a content-verified memory + disk cache keyed by digest and DPI.
|
||||||
|
type IconCache struct {
|
||||||
|
root string
|
||||||
|
fetcher IconFetcher
|
||||||
|
maxBytes int64
|
||||||
|
maxDimension int
|
||||||
|
mu sync.Mutex
|
||||||
|
memory map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIconCache creates a cache with safe default resource limits.
|
||||||
|
func NewIconCache(root string, fetcher IconFetcher) *IconCache {
|
||||||
|
return &IconCache{
|
||||||
|
root: root,
|
||||||
|
fetcher: fetcher,
|
||||||
|
maxBytes: DefaultMaxIconBytes,
|
||||||
|
maxDimension: DefaultMaxIconDimension,
|
||||||
|
memory: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load resolves memory, verified disk, then verified remote bytes.
|
||||||
|
func (cache *IconCache) Load(ctx context.Context, request IconRequest) (IconResult, error) {
|
||||||
|
cache.mu.Lock()
|
||||||
|
defer cache.mu.Unlock()
|
||||||
|
|
||||||
|
digest, key, err := cacheKey(request)
|
||||||
|
if err != nil {
|
||||||
|
return IconResult{}, err
|
||||||
|
}
|
||||||
|
if document, exists := cache.memory[key]; exists {
|
||||||
|
return IconResult{
|
||||||
|
Bytes: append([]byte(nil), document...),
|
||||||
|
Source: IconSourceMemory,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, pathErr := cache.filePath(digest, request.DPI)
|
||||||
|
if pathErr != nil {
|
||||||
|
return IconResult{}, pathErr
|
||||||
|
}
|
||||||
|
document, diskErr := cache.loadDisk(filePath, digest)
|
||||||
|
if diskErr == nil {
|
||||||
|
cache.memory[key] = append([]byte(nil), document...)
|
||||||
|
return IconResult{
|
||||||
|
Bytes: append([]byte(nil), document...),
|
||||||
|
Source: IconSourceDisk,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(diskErr) {
|
||||||
|
if errors.Is(diskErr, ErrIconCacheUnsafe) {
|
||||||
|
return IconResult{}, diskErr
|
||||||
|
}
|
||||||
|
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||||
|
return IconResult{}, fmt.Errorf(
|
||||||
|
"%w: remove invalid cache entry: %v",
|
||||||
|
ErrIconCacheUnsafe,
|
||||||
|
removeErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.fetcher == nil {
|
||||||
|
return IconResult{}, &IconLoadError{
|
||||||
|
Disk: diskErr,
|
||||||
|
Fetch: errors.New("icon fetcher is not configured"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document, fetchErr := cache.fetcher.FetchIcon(ctx, request)
|
||||||
|
if fetchErr != nil {
|
||||||
|
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: fetchErr}
|
||||||
|
}
|
||||||
|
if err := cache.validate(document, digest); err != nil {
|
||||||
|
return IconResult{}, &IconLoadError{Disk: diskErr, Fetch: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
storeErr := cache.storeDisk(filePath, document)
|
||||||
|
cache.memory[key] = append([]byte(nil), document...)
|
||||||
|
return IconResult{
|
||||||
|
Bytes: append([]byte(nil), document...),
|
||||||
|
Source: IconSourceRemote,
|
||||||
|
Warning: storeErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeIcon decodes already verified bytes outside Layout for ApplyIcon.
|
||||||
|
func DecodeIcon(document []byte) (image.Image, error) {
|
||||||
|
decoded, _, err := image.Decode(bytes.NewReader(document))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrIconImageInvalid, err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *IconCache) validate(document []byte, digest string) error {
|
||||||
|
maxBytes := cache.maxBytes
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = DefaultMaxIconBytes
|
||||||
|
}
|
||||||
|
if int64(len(document)) > maxBytes {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: bytes=%d limit=%d",
|
||||||
|
ErrIconTooLarge,
|
||||||
|
len(document),
|
||||||
|
maxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actual := sha256.Sum256(document)
|
||||||
|
if hex.EncodeToString(actual[:]) != digest {
|
||||||
|
return ErrIconHashMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
config, _, err := image.DecodeConfig(bytes.NewReader(document))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: decode config: %v", ErrIconImageInvalid, err)
|
||||||
|
}
|
||||||
|
maxDimension := cache.maxDimension
|
||||||
|
if maxDimension <= 0 {
|
||||||
|
maxDimension = DefaultMaxIconDimension
|
||||||
|
}
|
||||||
|
if config.Width <= 0 ||
|
||||||
|
config.Height <= 0 ||
|
||||||
|
config.Width > maxDimension ||
|
||||||
|
config.Height > maxDimension {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: dimensions=%dx%d limit=%d",
|
||||||
|
ErrIconTooLarge,
|
||||||
|
config.Width,
|
||||||
|
config.Height,
|
||||||
|
maxDimension,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, _, err := image.Decode(bytes.NewReader(document)); err != nil {
|
||||||
|
return fmt.Errorf("%w: decode: %v", ErrIconImageInvalid, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *IconCache) filePath(digest string, dpi int) (string, error) {
|
||||||
|
if cache.root == "" {
|
||||||
|
return "", fmt.Errorf("%w: empty root", ErrIconCacheUnsafe)
|
||||||
|
}
|
||||||
|
absoluteRoot, err := filepath.Abs(cache.root)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrIconCacheUnsafe, err)
|
||||||
|
}
|
||||||
|
return filepath.Join(
|
||||||
|
absoluteRoot,
|
||||||
|
digest+"-"+strconv.Itoa(dpi)+".icon",
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *IconCache) loadDisk(filePath, digest string) ([]byte, error) {
|
||||||
|
info, err := os.Lstat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%w: cache entry is not a regular file", ErrIconCacheUnsafe)
|
||||||
|
}
|
||||||
|
document, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read icon cache: %w", err)
|
||||||
|
}
|
||||||
|
if err := cache.validate(document, digest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *IconCache) storeDisk(filePath string, document []byte) error {
|
||||||
|
directory := filepath.Dir(filePath)
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create icon cache directory: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(directory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect icon cache directory: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return fmt.Errorf("%w: root is not a real directory", ErrIconCacheUnsafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
temporary, err := os.CreateTemp(directory, ".icon-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create icon cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("protect icon cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(document); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("write icon cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("sync icon cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close icon cache temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, filePath); err != nil {
|
||||||
|
return fmt.Errorf("activate icon cache entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheKey(request IconRequest) (digest string, key string, err error) {
|
||||||
|
const prefix = "sha256:"
|
||||||
|
if !strings.HasPrefix(request.Reference, prefix) {
|
||||||
|
return "", "", ErrIconReferenceInvalid
|
||||||
|
}
|
||||||
|
digest = strings.ToLower(strings.TrimPrefix(request.Reference, prefix))
|
||||||
|
decoded, decodeErr := hex.DecodeString(digest)
|
||||||
|
if decodeErr != nil || len(decoded) != sha256.Size || len(digest) != sha256.Size*2 {
|
||||||
|
return "", "", ErrIconReferenceInvalid
|
||||||
|
}
|
||||||
|
if request.DPI < 48 || request.DPI > 768 {
|
||||||
|
return "", "", ErrIconDPIInvalid
|
||||||
|
}
|
||||||
|
return digest, digest + "@" + strconv.Itoa(request.DPI), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIconCacheUsesMemoryAndOfflineDisk(t *testing.T) {
|
||||||
|
document := testPNG(t, 16, 16)
|
||||||
|
request := iconRequest(document, 96)
|
||||||
|
fetchCalls := 0
|
||||||
|
root := t.TempDir()
|
||||||
|
cache := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
fetchCalls++
|
||||||
|
return document, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
result, err := cache.Load(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(remote) error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != IconSourceRemote || fetchCalls != 1 {
|
||||||
|
t.Fatalf("remote result = %#v, fetchCalls=%d", result, fetchCalls)
|
||||||
|
}
|
||||||
|
result, err = cache.Load(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(memory) error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != IconSourceMemory || fetchCalls != 1 {
|
||||||
|
t.Fatalf("memory result = %#v, fetchCalls=%d", result, fetchCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
offline := errors.New("offline")
|
||||||
|
restarted := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return nil, offline
|
||||||
|
}))
|
||||||
|
result, err = restarted.Load(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(disk) error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != IconSourceDisk {
|
||||||
|
t.Fatalf("disk source = %q, want %q", result.Source, IconSourceDisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconCacheSeparatesDPIKeys(t *testing.T) {
|
||||||
|
document := testPNG(t, 16, 16)
|
||||||
|
fetchCalls := 0
|
||||||
|
root := t.TempDir()
|
||||||
|
cache := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
fetchCalls++
|
||||||
|
return document, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, dpi := range []int{96, 144} {
|
||||||
|
if _, err := cache.Load(context.Background(), iconRequest(document, dpi)); err != nil {
|
||||||
|
t.Fatalf("Load(%d DPI) error = %v", dpi, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetchCalls != 2 {
|
||||||
|
t.Fatalf("fetchCalls = %d, want 2", fetchCalls)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("disk entries = %d, want 2", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconCacheRejectsUntrustedImages(t *testing.T) {
|
||||||
|
validDocument := testPNG(t, 16, 16)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
request IconRequest
|
||||||
|
document []byte
|
||||||
|
wantErr error
|
||||||
|
configure func(*IconCache)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "hash mismatch",
|
||||||
|
request: iconRequest([]byte("different"), 96),
|
||||||
|
document: validDocument,
|
||||||
|
wantErr: ErrIconHashMismatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid image",
|
||||||
|
request: iconRequest([]byte("not an image"), 96),
|
||||||
|
document: []byte("not an image"),
|
||||||
|
wantErr: ErrIconImageInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "byte limit",
|
||||||
|
request: iconRequest(validDocument, 96),
|
||||||
|
document: validDocument,
|
||||||
|
wantErr: ErrIconTooLarge,
|
||||||
|
configure: func(cache *IconCache) {
|
||||||
|
cache.maxBytes = int64(len(validDocument) - 1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dimension limit",
|
||||||
|
request: iconRequest(validDocument, 96),
|
||||||
|
document: validDocument,
|
||||||
|
wantErr: ErrIconTooLarge,
|
||||||
|
configure: func(cache *IconCache) {
|
||||||
|
cache.maxDimension = 8
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cache := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return test.document, nil
|
||||||
|
}))
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(cache)
|
||||||
|
}
|
||||||
|
_, err := cache.Load(context.Background(), test.request)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Load() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(root)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("ReadDir() error = %v", readErr)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("invalid icon wrote %d disk entries", len(entries))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconCacheRepairsCorruptDiskAndReportsOfflineFailure(t *testing.T) {
|
||||||
|
document := testPNG(t, 16, 16)
|
||||||
|
request := iconRequest(document, 120)
|
||||||
|
root := t.TempDir()
|
||||||
|
online := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return document, nil
|
||||||
|
}))
|
||||||
|
if _, err := online.Load(context.Background(), request); err != nil {
|
||||||
|
t.Fatalf("Load(seed) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("ReadDir() = %v, %v", entries, err)
|
||||||
|
}
|
||||||
|
filePath := filepath.Join(root, entries[0].Name())
|
||||||
|
if err := os.WriteFile(filePath, []byte("corrupt"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(corrupt) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repairs := 0
|
||||||
|
repairing := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
repairs++
|
||||||
|
return document, nil
|
||||||
|
}))
|
||||||
|
result, err := repairing.Load(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(repair) error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != IconSourceRemote || repairs != 1 {
|
||||||
|
t.Fatalf("repair result = %#v, repairs=%d", result, repairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filePath, []byte("corrupt again"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(corrupt again) error = %v", err)
|
||||||
|
}
|
||||||
|
offline := NewIconCache(root, IconFetchFunc(func(
|
||||||
|
context.Context,
|
||||||
|
IconRequest,
|
||||||
|
) ([]byte, error) {
|
||||||
|
return nil, errors.New("offline")
|
||||||
|
}))
|
||||||
|
_, err = offline.Load(context.Background(), request)
|
||||||
|
if !errors.Is(err, ErrNoValidIcon) {
|
||||||
|
t.Fatalf("Load(offline corrupt) error = %v, want %v", err, ErrNoValidIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeIcon(t *testing.T) {
|
||||||
|
document := testPNG(t, 8, 8)
|
||||||
|
decoded, err := DecodeIcon(document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeIcon() error = %v", err)
|
||||||
|
}
|
||||||
|
if decoded.Bounds().Dx() != 8 || decoded.Bounds().Dy() != 8 {
|
||||||
|
t.Fatalf("Bounds = %v", decoded.Bounds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconRequest(document []byte, dpi int) IconRequest {
|
||||||
|
digest := sha256.Sum256(document)
|
||||||
|
return IconRequest{
|
||||||
|
Reference: "sha256:" + hex.EncodeToString(digest[:]),
|
||||||
|
DPI: dpi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPNG(t *testing.T, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
source := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
source.SetNRGBA(x, y, color.NRGBA{
|
||||||
|
R: uint8(x),
|
||||||
|
G: uint8(y),
|
||||||
|
B: 120,
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
if err := png.Encode(&buffer, source); err != nil {
|
||||||
|
t.Fatalf("png.Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNoValidCatalog = errors.New("no valid catalog available")
|
||||||
|
|
||||||
|
// Fetcher obtains a signed Catalog document from a remote or test source.
|
||||||
|
type Fetcher interface {
|
||||||
|
Fetch(context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchFunc adapts a function to Fetcher.
|
||||||
|
type FetchFunc func(context.Context) ([]byte, error)
|
||||||
|
|
||||||
|
func (function FetchFunc) Fetch(ctx context.Context) ([]byte, error) {
|
||||||
|
return function(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache stores the last verified signed document.
|
||||||
|
type Cache interface {
|
||||||
|
Load() ([]byte, error)
|
||||||
|
Store([]byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentValidator rejects signed documents that this client cannot consume.
|
||||||
|
// Validators run before a remote document can replace the last valid cache.
|
||||||
|
type DocumentValidator interface {
|
||||||
|
Validate(VerifiedDocument) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentValidatorFunc adapts a function to DocumentValidator.
|
||||||
|
type DocumentValidatorFunc func(VerifiedDocument) error
|
||||||
|
|
||||||
|
func (function DocumentValidatorFunc) Validate(document VerifiedDocument) error {
|
||||||
|
return function(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSource describes where a verified result came from.
|
||||||
|
type LoadSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceRemote LoadSource = "remote"
|
||||||
|
SourceCache LoadSource = "cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadResult returns verified bytes and a non-fatal refresh/cache warning.
|
||||||
|
type LoadResult struct {
|
||||||
|
Document VerifiedDocument
|
||||||
|
Source LoadSource
|
||||||
|
Warning error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadError preserves both the refresh and cache failure.
|
||||||
|
type LoadError struct {
|
||||||
|
Refresh error
|
||||||
|
Cache error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *LoadError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: refresh=%v; cache=%v", ErrNoValidCatalog, err.Refresh, err.Cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *LoadError) Unwrap() error {
|
||||||
|
return ErrNoValidCatalog
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader verifies remote data before storing it and re-verifies cache fallback.
|
||||||
|
type Loader struct {
|
||||||
|
verifier Verifier
|
||||||
|
fetcher Fetcher
|
||||||
|
cache Cache
|
||||||
|
validators []DocumentValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoader(
|
||||||
|
verifier Verifier,
|
||||||
|
fetcher Fetcher,
|
||||||
|
cache Cache,
|
||||||
|
validators ...DocumentValidator,
|
||||||
|
) *Loader {
|
||||||
|
return &Loader{
|
||||||
|
verifier: verifier,
|
||||||
|
fetcher: fetcher,
|
||||||
|
cache: cache,
|
||||||
|
validators: append([]DocumentValidator(nil), validators...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load prefers a verified remote document and falls back to verified cache.
|
||||||
|
func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
|
||||||
|
remoteBytes, refreshErr := loader.fetcher.Fetch(ctx)
|
||||||
|
if refreshErr == nil {
|
||||||
|
verified, verifyErr := loader.verifier.Verify(remoteBytes)
|
||||||
|
if verifyErr == nil {
|
||||||
|
verifyErr = loader.validate(verified)
|
||||||
|
if verifyErr == nil {
|
||||||
|
storeErr := loader.cache.Store(verified.Bytes)
|
||||||
|
return LoadResult{
|
||||||
|
Document: verified,
|
||||||
|
Source: SourceRemote,
|
||||||
|
Warning: storeErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshErr = verifyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBytes, cacheErr := loader.cache.Load()
|
||||||
|
if cacheErr == nil {
|
||||||
|
var verified VerifiedDocument
|
||||||
|
verified, cacheErr = loader.verifier.Verify(cachedBytes)
|
||||||
|
if cacheErr == nil {
|
||||||
|
cacheErr = loader.validate(verified)
|
||||||
|
if cacheErr == nil {
|
||||||
|
return LoadResult{
|
||||||
|
Document: verified,
|
||||||
|
Source: SourceCache,
|
||||||
|
Warning: refreshErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoadResult{}, &LoadError{
|
||||||
|
Refresh: refreshErr,
|
||||||
|
Cache: cacheErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (loader *Loader) validate(document VerifiedDocument) error {
|
||||||
|
for _, validator := range loader.validators {
|
||||||
|
if validator == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validator.Validate(document); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoaderUsesVerifiedRemoteAndStoresCache(t *testing.T) {
|
||||||
|
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||||
|
cache := &memoryCache{}
|
||||||
|
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return validDocument, nil
|
||||||
|
}), cache)
|
||||||
|
|
||||||
|
result, err := loader.Load(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != SourceRemote {
|
||||||
|
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
|
||||||
|
}
|
||||||
|
if string(cache.document) != string(validDocument) {
|
||||||
|
t.Fatal("verified remote document was not stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoaderFallsBackToVerifiedCache(t *testing.T) {
|
||||||
|
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||||
|
offline := errors.New("offline")
|
||||||
|
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||||
|
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return nil, offline
|
||||||
|
}), cache)
|
||||||
|
|
||||||
|
result, err := loader.Load(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != SourceCache {
|
||||||
|
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Warning, offline) {
|
||||||
|
t.Fatalf("Warning = %v, want %v", result.Warning, offline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoaderRejectsRemoteWithoutOverwritingCache(t *testing.T) {
|
||||||
|
verifier, validDocument, tamperedDocument := loaderTestDocuments(t)
|
||||||
|
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
|
||||||
|
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return tamperedDocument, nil
|
||||||
|
}), cache)
|
||||||
|
|
||||||
|
result, err := loader.Load(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != SourceCache {
|
||||||
|
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Warning, ErrSignatureInvalid) {
|
||||||
|
t.Fatalf("Warning = %v, want %v", result.Warning, ErrSignatureInvalid)
|
||||||
|
}
|
||||||
|
if cache.storeCalls != 0 {
|
||||||
|
t.Fatalf("Store() called %d times for invalid remote", cache.storeCalls)
|
||||||
|
}
|
||||||
|
if string(cache.document) != string(validDocument) {
|
||||||
|
t.Fatal("invalid remote changed cached document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoaderRejectsInvalidCache(t *testing.T) {
|
||||||
|
verifier, _, tamperedDocument := loaderTestDocuments(t)
|
||||||
|
offline := errors.New("offline")
|
||||||
|
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return nil, offline
|
||||||
|
}), &memoryCache{document: tamperedDocument})
|
||||||
|
|
||||||
|
_, err := loader.Load(context.Background())
|
||||||
|
if !errors.Is(err, ErrNoValidCatalog) {
|
||||||
|
t.Fatalf("Load() error = %v, want %v", err, ErrNoValidCatalog)
|
||||||
|
}
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("Load() error type = %T, want *LoadError", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(loadErr.Cache, ErrSignatureInvalid) {
|
||||||
|
t.Fatalf("cache error = %v, want %v", loadErr.Cache, ErrSignatureInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoaderReturnsCacheStoreWarning(t *testing.T) {
|
||||||
|
verifier, validDocument, _ := loaderTestDocuments(t)
|
||||||
|
storeFailure := errors.New("disk full")
|
||||||
|
cache := &memoryCache{storeErr: storeFailure}
|
||||||
|
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
|
||||||
|
return validDocument, nil
|
||||||
|
}), cache)
|
||||||
|
|
||||||
|
result, err := loader.Load(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Source != SourceRemote {
|
||||||
|
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Warning, storeFailure) {
|
||||||
|
t.Fatalf("Warning = %v, want %v", result.Warning, storeFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryCache struct {
|
||||||
|
document []byte
|
||||||
|
loadErr error
|
||||||
|
storeErr error
|
||||||
|
storeCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *memoryCache) Load() ([]byte, error) {
|
||||||
|
if cache.loadErr != nil {
|
||||||
|
return nil, cache.loadErr
|
||||||
|
}
|
||||||
|
return append([]byte(nil), cache.document...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *memoryCache) Store(document []byte) error {
|
||||||
|
cache.storeCalls++
|
||||||
|
if cache.storeErr != nil {
|
||||||
|
return cache.storeErr
|
||||||
|
}
|
||||||
|
cache.document = append([]byte(nil), document...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loaderTestDocuments(t *testing.T) (Verifier, []byte, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
publicKey, privateKey := catalogTestKey()
|
||||||
|
verifier, err := NewVerifier(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVerifier() error = %v", err)
|
||||||
|
}
|
||||||
|
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||||
|
validDocument, signature := signCatalogPayload(t, validPayload, privateKey)
|
||||||
|
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
|
||||||
|
tamperedDocument := attachCatalogSignature(t, tamperedPayload, signature)
|
||||||
|
return verifier, validDocument, tamperedDocument
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
// ManifestChannel separates modern and Win7 delivery tracks.
|
||||||
|
type ManifestChannel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChannelModern ManifestChannel = "modern"
|
||||||
|
ChannelWin7 ManifestChannel = "win7"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseChannel is the app release track supported by the MVP.
|
||||||
|
type ReleaseChannel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReleaseStable ReleaseChannel = "stable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppCatalogStatus controls catalog visibility and installability.
|
||||||
|
type AppCatalogStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatalogStatusActive AppCatalogStatus = "active"
|
||||||
|
CatalogStatusDeprecated AppCatalogStatus = "deprecated"
|
||||||
|
CatalogStatusHidden AppCatalogStatus = "hidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Architecture identifies a Windows package architecture.
|
||||||
|
type Architecture string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Architecture386 Architecture = "386"
|
||||||
|
ArchitectureAMD64 Architecture = "amd64"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WindowsRelease is an ordered minimum Windows release identifier.
|
||||||
|
type WindowsRelease string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Windows7SP1 WindowsRelease = "windows-7-sp1"
|
||||||
|
Windows10 WindowsRelease = "windows-10"
|
||||||
|
Windows11 WindowsRelease = "windows-11"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the signed Catalog protocol v1 document.
|
||||||
|
type Manifest struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Channel ManifestChannel `json:"channel"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
MinBoxVersion string `json:"min_box_version"`
|
||||||
|
Apps []App `json:"apps"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// App is one published product in a Manifest.
|
||||||
|
type App struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Channel ReleaseChannel `json:"channel"`
|
||||||
|
Status AppCatalogStatus `json:"status"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Tutorial string `json:"tutorial,omitempty"`
|
||||||
|
MinOS WindowsRelease `json:"min_os"`
|
||||||
|
Architectures []Architecture `json:"architectures"`
|
||||||
|
EntryEXE string `json:"entry_exe"`
|
||||||
|
RequiresAdmin bool `json:"requires_admin"`
|
||||||
|
Packages map[Architecture]Package `json:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package describes one downloadable ZIP artifact.
|
||||||
|
type Package struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target describes the client build and operating system consuming a Catalog.
|
||||||
|
type Target struct {
|
||||||
|
Channel ManifestChannel
|
||||||
|
OS WindowsRelease
|
||||||
|
Architecture Architecture
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailabilityReason is stable data for UI localization and action gating.
|
||||||
|
type AvailabilityReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReasonNone AvailabilityReason = ""
|
||||||
|
ReasonDeprecated AvailabilityReason = "deprecated"
|
||||||
|
ReasonMinimumOS AvailabilityReason = "minimum_os"
|
||||||
|
ReasonArchitecture AvailabilityReason = "architecture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is a visible app after target filtering.
|
||||||
|
type Entry struct {
|
||||||
|
App App
|
||||||
|
Package *Package
|
||||||
|
Installable bool
|
||||||
|
Reason AvailabilityReason
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog is a verified, validated and target-filtered Manifest.
|
||||||
|
type Catalog struct {
|
||||||
|
Manifest Manifest
|
||||||
|
Entries []Entry
|
||||||
|
Source LoadSource
|
||||||
|
Warning error
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidManifest = errors.New("invalid catalog manifest")
|
||||||
|
ErrChannelMismatch = errors.New("catalog channel mismatch")
|
||||||
|
ErrUnsupportedTarget = errors.New("unsupported catalog target")
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
|
||||||
|
iconRefPattern = regexp.MustCompile(`^sha256:[0-9A-Fa-f]{64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parser validates the protocol shape for one delivery channel.
|
||||||
|
type Parser struct {
|
||||||
|
ExpectedChannel ManifestChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate implements DocumentValidator.
|
||||||
|
func (parser Parser) Validate(document VerifiedDocument) error {
|
||||||
|
_, err := parser.Parse(document)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse strictly decodes a previously verified Manifest.
|
||||||
|
func (parser Parser) Parse(document VerifiedDocument) (Manifest, error) {
|
||||||
|
if !parser.ExpectedChannel.valid() {
|
||||||
|
return Manifest{}, fmt.Errorf(
|
||||||
|
"%w: channel %q",
|
||||||
|
ErrUnsupportedTarget,
|
||||||
|
parser.ExpectedChannel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(document.Bytes))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
var manifest Manifest
|
||||||
|
if err := decoder.Decode(&manifest); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("%w: decode: %v", ErrInvalidManifest, err)
|
||||||
|
}
|
||||||
|
if err := consumeEOF(decoder); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
if err := validateManifest(manifest, parser.ExpectedChannel); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeEOF(decoder *json.Decoder) error {
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("%w: trailing JSON value", ErrInvalidManifest)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: trailing data: %v", ErrInvalidManifest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManifest(manifest Manifest, expectedChannel ManifestChannel) error {
|
||||||
|
if manifest.SchemaVersion != 1 {
|
||||||
|
return invalidField("schema_version", "must be 1")
|
||||||
|
}
|
||||||
|
if !manifest.Channel.valid() {
|
||||||
|
return invalidField("channel", "unsupported value %q", manifest.Channel)
|
||||||
|
}
|
||||||
|
if manifest.Channel != expectedChannel {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
ErrChannelMismatch,
|
||||||
|
manifest.Channel,
|
||||||
|
expectedChannel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
generatedAt, err := time.Parse(time.RFC3339, manifest.GeneratedAt)
|
||||||
|
_, offset := generatedAt.Zone()
|
||||||
|
if err != nil || offset != 0 {
|
||||||
|
return invalidField("generated_at", "must be an RFC3339 UTC timestamp")
|
||||||
|
}
|
||||||
|
if _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
|
||||||
|
return invalidField("min_box_version", "must be SemVer")
|
||||||
|
}
|
||||||
|
if err := validateSignature(manifest.Signature); err != nil {
|
||||||
|
return invalidField("signature", "%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIDs := make(map[string]struct{}, len(manifest.Apps))
|
||||||
|
for index, app := range manifest.Apps {
|
||||||
|
if err := validateApp(app); err != nil {
|
||||||
|
return fmt.Errorf("%w: apps[%d]: %v", ErrInvalidManifest, index, err)
|
||||||
|
}
|
||||||
|
if _, exists := seenIDs[app.ID]; exists {
|
||||||
|
return fmt.Errorf("%w: duplicate app id %q", ErrInvalidManifest, app.ID)
|
||||||
|
}
|
||||||
|
seenIDs[app.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApp(app App) error {
|
||||||
|
if !appIDPattern.MatchString(app.ID) {
|
||||||
|
return invalidField("id", "must match ^[a-z0-9-]+$")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(app.Name) == "" {
|
||||||
|
return invalidField("name", "must not be empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(app.Description) == "" {
|
||||||
|
return invalidField("description", "must not be empty")
|
||||||
|
}
|
||||||
|
if _, err := domain.ParseSemVer(app.Version); err != nil {
|
||||||
|
return invalidField("version", "must be SemVer")
|
||||||
|
}
|
||||||
|
if app.Channel != ReleaseStable {
|
||||||
|
return invalidField("channel", "unsupported value %q", app.Channel)
|
||||||
|
}
|
||||||
|
if !app.Status.valid() {
|
||||||
|
return invalidField("status", "unsupported value %q", app.Status)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(app.Category) == "" {
|
||||||
|
return invalidField("category", "must not be empty")
|
||||||
|
}
|
||||||
|
if len(app.Tags) == 0 {
|
||||||
|
return invalidField("tags", "must contain at least one tag")
|
||||||
|
}
|
||||||
|
for _, tag := range app.Tags {
|
||||||
|
if strings.TrimSpace(tag) == "" {
|
||||||
|
return invalidField("tags", "must not contain empty values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if app.Icon != "" && !iconRefPattern.MatchString(app.Icon) {
|
||||||
|
return invalidField("icon", "must be sha256:<64 hexadecimal characters>")
|
||||||
|
}
|
||||||
|
if err := validateOptionalHTTPSURL("homepage", app.Homepage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOptionalHTTPSURL("tutorial", app.Tutorial); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !app.MinOS.valid() {
|
||||||
|
return invalidField("min_os", "unsupported value %q", app.MinOS)
|
||||||
|
}
|
||||||
|
if len(app.Architectures) == 0 {
|
||||||
|
return invalidField("architectures", "must not be empty")
|
||||||
|
}
|
||||||
|
if !validSafeRelativePath(app.EntryEXE) {
|
||||||
|
return invalidField("entry_exe", "must be a safe relative path")
|
||||||
|
}
|
||||||
|
if len(app.Packages) == 0 {
|
||||||
|
return invalidField("packages", "must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
architectures := make(map[Architecture]struct{}, len(app.Architectures))
|
||||||
|
for _, architecture := range app.Architectures {
|
||||||
|
if !architecture.valid() {
|
||||||
|
return invalidField("architectures", "unsupported value %q", architecture)
|
||||||
|
}
|
||||||
|
if _, exists := architectures[architecture]; exists {
|
||||||
|
return invalidField("architectures", "duplicate value %q", architecture)
|
||||||
|
}
|
||||||
|
architectures[architecture] = struct{}{}
|
||||||
|
}
|
||||||
|
for architecture := range architectures {
|
||||||
|
publishedPackage, exists := app.Packages[architecture]
|
||||||
|
if !exists {
|
||||||
|
return invalidField("packages", "missing %q package", architecture)
|
||||||
|
}
|
||||||
|
if err := validatePackage(architecture, publishedPackage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for architecture := range app.Packages {
|
||||||
|
if _, exists := architectures[architecture]; !exists {
|
||||||
|
return invalidField(
|
||||||
|
"packages",
|
||||||
|
"package %q is absent from architectures",
|
||||||
|
architecture,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePackage(architecture Architecture, publishedPackage Package) error {
|
||||||
|
if !architecture.valid() {
|
||||||
|
return invalidField("packages", "unsupported key %q", architecture)
|
||||||
|
}
|
||||||
|
if err := validateHTTPSURL(publishedPackage.URL); err != nil {
|
||||||
|
return invalidField("packages."+string(architecture)+".url", "%v", err)
|
||||||
|
}
|
||||||
|
if publishedPackage.Size <= 0 {
|
||||||
|
return invalidField("packages."+string(architecture)+".size", "must be positive")
|
||||||
|
}
|
||||||
|
if !sha256Pattern.MatchString(publishedPackage.SHA256) {
|
||||||
|
return invalidField(
|
||||||
|
"packages."+string(architecture)+".sha256",
|
||||||
|
"must contain 64 hexadecimal characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(publishedPackage.SHA256); err != nil {
|
||||||
|
return invalidField("packages."+string(architecture)+".sha256", "%v", err)
|
||||||
|
}
|
||||||
|
if err := validateSignature(publishedPackage.Signature); err != nil {
|
||||||
|
return invalidField("packages."+string(architecture)+".signature", "%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSignature(value string) error {
|
||||||
|
signature, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be strict Base64: %v", err)
|
||||||
|
}
|
||||||
|
if len(signature) != 64 {
|
||||||
|
return fmt.Errorf("must decode to 64 bytes")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOptionalHTTPSURL(fieldName, value string) error {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateHTTPSURL(value); err != nil {
|
||||||
|
return invalidField(fieldName, "%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHTTPSURL(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||||
|
return errors.New("must be an absolute HTTPS URL without user information")
|
||||||
|
}
|
||||||
|
if parsed.Fragment != "" {
|
||||||
|
return errors.New("must not contain a fragment")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSafeRelativePath(value string) bool {
|
||||||
|
if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(value)
|
||||||
|
return cleaned == value &&
|
||||||
|
cleaned != "." &&
|
||||||
|
!strings.HasPrefix(cleaned, "/") &&
|
||||||
|
cleaned != ".." &&
|
||||||
|
!strings.HasPrefix(cleaned, "../")
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidField(field, format string, values ...any) error {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s: %s",
|
||||||
|
ErrInvalidManifest,
|
||||||
|
field,
|
||||||
|
fmt.Sprintf(format, values...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel ManifestChannel) valid() bool {
|
||||||
|
return channel == ChannelModern || channel == ChannelWin7
|
||||||
|
}
|
||||||
|
|
||||||
|
func (status AppCatalogStatus) valid() bool {
|
||||||
|
return status == CatalogStatusActive ||
|
||||||
|
status == CatalogStatusDeprecated ||
|
||||||
|
status == CatalogStatusHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
func (architecture Architecture) valid() bool {
|
||||||
|
return architecture == Architecture386 || architecture == ArchitectureAMD64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (release WindowsRelease) valid() bool {
|
||||||
|
return release == Windows7SP1 || release == Windows10 || release == Windows11
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParserAcceptsValidSignedManifest(t *testing.T) {
|
||||||
|
verifier, document := signedFixture(t, "manifest-valid-payload.json")
|
||||||
|
verified, err := verifier.Verify(document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(manifest.Apps) != 1 || manifest.Apps[0].ID != "json-parser" {
|
||||||
|
t.Fatalf("Apps = %#v", manifest.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParserRejectsWrongChannelAndUnknownFields(t *testing.T) {
|
||||||
|
verifier, document := signedFixture(t, "manifest-valid-payload.json")
|
||||||
|
verified, err := verifier.Verify(document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = (Parser{ExpectedChannel: ChannelWin7}).Parse(verified)
|
||||||
|
if !errors.Is(err, ErrChannelMismatch) {
|
||||||
|
t.Fatalf("Parse() error = %v, want %v", err, ErrChannelMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(readCatalogFixture(t, "manifest-valid-payload.json"), &payload); err != nil {
|
||||||
|
t.Fatalf("decode fixture: %v", err)
|
||||||
|
}
|
||||||
|
payload["unexpected"] = true
|
||||||
|
mutatedPayload, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode fixture: %v", err)
|
||||||
|
}
|
||||||
|
_, privateKey := catalogTestKey()
|
||||||
|
mutatedDocument, _ := signCatalogPayload(t, mutatedPayload, privateKey)
|
||||||
|
verified, err = verifier.Verify(mutatedDocument)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify(mutated) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
|
||||||
|
if !errors.Is(err, ErrInvalidManifest) {
|
||||||
|
t.Fatalf("Parse(mutated) error = %v, want %v", err, ErrInvalidManifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParserRejectsDuplicateAppIDAndPackageMismatch(t *testing.T) {
|
||||||
|
base := validManifestForTest()
|
||||||
|
base.Apps = append(base.Apps, base.Apps[0])
|
||||||
|
_, err := parseSignedManifestForTest(t, base, ChannelModern)
|
||||||
|
if !errors.Is(err, ErrInvalidManifest) {
|
||||||
|
t.Fatalf("duplicate Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
base = validManifestForTest()
|
||||||
|
delete(base.Apps[0].Packages, ArchitectureAMD64)
|
||||||
|
_, err = parseSignedManifestForTest(t, base, ChannelModern)
|
||||||
|
if !errors.Is(err, ErrInvalidManifest) {
|
||||||
|
t.Fatalf("package Parse() error = %v, want %v", err, ErrInvalidManifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedFixture(t *testing.T, name string) (Verifier, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
publicKey, privateKey := catalogTestKey()
|
||||||
|
verifier, err := NewVerifier(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVerifier() error = %v", err)
|
||||||
|
}
|
||||||
|
document, _ := signCatalogPayload(t, readCatalogFixture(t, name), privateKey)
|
||||||
|
return verifier, document
|
||||||
|
}
|
||||||
|
|
||||||
|
func validManifestForTest() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Channel: ChannelModern,
|
||||||
|
GeneratedAt: "2026-07-16T00:00:00Z",
|
||||||
|
MinBoxVersion: "1.0.0",
|
||||||
|
Apps: []App{
|
||||||
|
{
|
||||||
|
ID: "json-parser",
|
||||||
|
Name: "JSON解析工具",
|
||||||
|
Description: "测试目录项",
|
||||||
|
Version: "1.2.0",
|
||||||
|
Channel: ReleaseStable,
|
||||||
|
Status: CatalogStatusActive,
|
||||||
|
Category: "开发工具",
|
||||||
|
Tags: []string{"工具", "JSON"},
|
||||||
|
Icon: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
Homepage: "https://example.invalid/json-parser",
|
||||||
|
Tutorial: "https://example.invalid/json-parser/tutorial",
|
||||||
|
MinOS: Windows10,
|
||||||
|
Architectures: []Architecture{ArchitectureAMD64},
|
||||||
|
EntryEXE: "JsonParser.exe",
|
||||||
|
Packages: map[Architecture]Package{
|
||||||
|
ArchitectureAMD64: {
|
||||||
|
URL: "https://download.invalid/json-parser.zip",
|
||||||
|
Size: 42,
|
||||||
|
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedManifestForTest(
|
||||||
|
t *testing.T,
|
||||||
|
manifest Manifest,
|
||||||
|
expectedChannel ManifestChannel,
|
||||||
|
) (Manifest, error) {
|
||||||
|
t.Helper()
|
||||||
|
manifest.Signature = ""
|
||||||
|
payloadValue := map[string]any{}
|
||||||
|
encodedManifest, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode manifest: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(encodedManifest, &payloadValue); err != nil {
|
||||||
|
t.Fatalf("decode manifest: %v", err)
|
||||||
|
}
|
||||||
|
delete(payloadValue, "signature")
|
||||||
|
payload, err := json.Marshal(payloadValue)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, privateKey := catalogTestKey()
|
||||||
|
document, _ := signCatalogPayload(t, payload, privateKey)
|
||||||
|
verifier, err := NewVerifier(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVerifier() error = %v", err)
|
||||||
|
}
|
||||||
|
verified, err := verifier.Verify(document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify() error = %v", err)
|
||||||
|
}
|
||||||
|
return (Parser{ExpectedChannel: expectedChannel}).Parse(verified)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"manifest.schema.json",
|
||||||
|
"app.schema.json",
|
||||||
|
"installed-app.schema.json",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read schema: %v", err)
|
||||||
|
}
|
||||||
|
var schema map[string]any
|
||||||
|
if err := json.Unmarshal(document, &schema); err != nil {
|
||||||
|
t.Fatalf("decode schema: %v", err)
|
||||||
|
}
|
||||||
|
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
|
||||||
|
t.Fatalf("$schema = %v", schema["$schema"])
|
||||||
|
}
|
||||||
|
if schema["type"] != "object" {
|
||||||
|
t.Fatalf("type = %v", schema["type"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidDocument = errors.New("invalid catalog document")
|
||||||
|
ErrDuplicateField = errors.New("duplicate catalog field")
|
||||||
|
ErrUnsupportedNumber = errors.New("unsupported catalog number")
|
||||||
|
ErrSignatureMissing = errors.New("catalog signature missing")
|
||||||
|
ErrSignatureInvalid = errors.New("catalog signature invalid")
|
||||||
|
ErrPublicKeyInvalid = errors.New("catalog public key invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifiedDocument contains the original signed document and its signing bytes.
|
||||||
|
type VerifiedDocument struct {
|
||||||
|
Bytes []byte
|
||||||
|
SignedPayload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier validates signed Catalog JSON documents with one Ed25519 public key.
|
||||||
|
type Verifier struct {
|
||||||
|
publicKey ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerifier copies and validates the public key.
|
||||||
|
func NewVerifier(publicKey []byte) (Verifier, error) {
|
||||||
|
if len(publicKey) != ed25519.PublicKeySize {
|
||||||
|
return Verifier{}, fmt.Errorf(
|
||||||
|
"%w: got %d bytes, want %d",
|
||||||
|
ErrPublicKeyInvalid,
|
||||||
|
len(publicKey),
|
||||||
|
ed25519.PublicKeySize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keyCopy := append(ed25519.PublicKey(nil), publicKey...)
|
||||||
|
return Verifier{publicKey: keyCopy}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify rejects ambiguous JSON and validates the top-level signature.
|
||||||
|
func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
|
||||||
|
rootValue, err := parseRestrictedJSON(document)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedDocument{}, err
|
||||||
|
}
|
||||||
|
root, ok := rootValue.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return VerifiedDocument{}, fmt.Errorf("%w: root must be an object", ErrInvalidDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureValue, exists := root["signature"]
|
||||||
|
if !exists {
|
||||||
|
return VerifiedDocument{}, ErrSignatureMissing
|
||||||
|
}
|
||||||
|
signatureText, ok := signatureValue.(string)
|
||||||
|
if !ok {
|
||||||
|
return VerifiedDocument{}, fmt.Errorf("%w: signature must be a string", ErrSignatureInvalid)
|
||||||
|
}
|
||||||
|
delete(root, "signature")
|
||||||
|
|
||||||
|
signedPayload, err := canonicalJSON(root)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedDocument{}, err
|
||||||
|
}
|
||||||
|
signature, err := base64.StdEncoding.Strict().DecodeString(signatureText)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedDocument{}, fmt.Errorf("%w: base64: %v", ErrSignatureInvalid, err)
|
||||||
|
}
|
||||||
|
if len(signature) != ed25519.SignatureSize {
|
||||||
|
return VerifiedDocument{}, fmt.Errorf(
|
||||||
|
"%w: got %d signature bytes, want %d",
|
||||||
|
ErrSignatureInvalid,
|
||||||
|
len(signature),
|
||||||
|
ed25519.SignatureSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
|
||||||
|
return VerifiedDocument{}, ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerifiedDocument{
|
||||||
|
Bytes: append([]byte(nil), document...),
|
||||||
|
SignedPayload: append([]byte(nil), signedPayload...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifierFixtures(t *testing.T) {
|
||||||
|
publicKey, privateKey := catalogTestKey()
|
||||||
|
verifier, err := NewVerifier(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVerifier() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
|
||||||
|
validDocument, validSignature := signCatalogPayload(t, validPayload, privateKey)
|
||||||
|
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
|
||||||
|
tamperedDocument := attachCatalogSignature(t, tamperedPayload, validSignature)
|
||||||
|
forgedDocument := readCatalogFixture(t, "manifest-forged.json")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
document []byte
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "valid", document: validDocument},
|
||||||
|
{name: "tampered", document: tamperedDocument, wantErr: ErrSignatureInvalid},
|
||||||
|
{name: "forged", document: forgedDocument, wantErr: ErrSignatureInvalid},
|
||||||
|
{
|
||||||
|
name: "duplicate field",
|
||||||
|
document: []byte(`{"channel":"modern","channel":"win7","signature":"x"}`),
|
||||||
|
wantErr: ErrDuplicateField,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional number",
|
||||||
|
document: []byte(`{"schema_version":1.5,"signature":"x"}`),
|
||||||
|
wantErr: ErrUnsupportedNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exponent number",
|
||||||
|
document: []byte(`{"schema_version":1e2,"signature":"x"}`),
|
||||||
|
wantErr: ErrUnsupportedNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing signature",
|
||||||
|
document: validPayload,
|
||||||
|
wantErr: ErrSignatureMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing value",
|
||||||
|
document: append(append([]byte(nil), validDocument...), []byte(` {}`)...),
|
||||||
|
wantErr: ErrInvalidDocument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid UTF-8",
|
||||||
|
document: []byte{0xff, 0xfe},
|
||||||
|
wantErr: ErrInvalidDocument,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
verified, err := verifier.Verify(test.document)
|
||||||
|
if test.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(verified.SignedPayload) == 0 {
|
||||||
|
t.Fatal("Verify() returned empty signed payload")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogTestKey() (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||||
|
seed := sha256.Sum256([]byte("SoftBox catalog verifier test key - never use in production"))
|
||||||
|
privateKey := ed25519.NewKeyFromSeed(seed[:])
|
||||||
|
return privateKey.Public().(ed25519.PublicKey), privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCatalogFixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "testdata", "catalog", name)
|
||||||
|
document, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
func signCatalogPayload(t *testing.T, payload []byte, privateKey ed25519.PrivateKey) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
value, err := parseRestrictedJSON(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse payload: %v", err)
|
||||||
|
}
|
||||||
|
root, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("payload root is not an object")
|
||||||
|
}
|
||||||
|
canonical, err := canonicalJSON(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonicalize payload: %v", err)
|
||||||
|
}
|
||||||
|
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
|
||||||
|
return attachCatalogSignature(t, payload, signature), signature
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachCatalogSignature(t *testing.T, payload []byte, signature string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
value, err := parseRestrictedJSON(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse payload: %v", err)
|
||||||
|
}
|
||||||
|
root, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("payload root is not an object")
|
||||||
|
}
|
||||||
|
root["signature"] = signature
|
||||||
|
document, err := json.MarshalIndent(root, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode signed document: %v", err)
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package domain contains SoftBox business models and rules.
|
||||||
|
package domain
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidSemVer = errors.New("invalid semantic version")
|
||||||
|
|
||||||
|
// SemVersion is a parsed Semantic Version 2.0.0 value.
|
||||||
|
type SemVersion struct {
|
||||||
|
original string
|
||||||
|
major string
|
||||||
|
minor string
|
||||||
|
patch string
|
||||||
|
prerelease []string
|
||||||
|
build []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSemVer parses a complete Semantic Version 2.0.0 string.
|
||||||
|
func ParseSemVer(value string) (SemVersion, error) {
|
||||||
|
if value == "" {
|
||||||
|
return SemVersion{}, fmt.Errorf("%w: empty value", ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
coreAndPrerelease := value
|
||||||
|
var build []string
|
||||||
|
if separator := strings.IndexByte(value, '+'); separator >= 0 {
|
||||||
|
if strings.IndexByte(value[separator+1:], '+') >= 0 {
|
||||||
|
return SemVersion{}, fmt.Errorf("%w: multiple build separators", ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
coreAndPrerelease = value[:separator]
|
||||||
|
var err error
|
||||||
|
build, err = parseIdentifiers(value[separator+1:], false)
|
||||||
|
if err != nil {
|
||||||
|
return SemVersion{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
core := coreAndPrerelease
|
||||||
|
var prerelease []string
|
||||||
|
if separator := strings.IndexByte(coreAndPrerelease, '-'); separator >= 0 {
|
||||||
|
core = coreAndPrerelease[:separator]
|
||||||
|
var err error
|
||||||
|
prerelease, err = parseIdentifiers(coreAndPrerelease[separator+1:], true)
|
||||||
|
if err != nil {
|
||||||
|
return SemVersion{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(core, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return SemVersion{}, fmt.Errorf("%w: core must contain major.minor.patch", ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if !validNumericIdentifier(part, false) {
|
||||||
|
return SemVersion{}, fmt.Errorf("%w: invalid core identifier %q", ErrInvalidSemVer, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SemVersion{
|
||||||
|
original: value,
|
||||||
|
major: parts[0],
|
||||||
|
minor: parts[1],
|
||||||
|
patch: parts[2],
|
||||||
|
prerelease: prerelease,
|
||||||
|
build: build,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the original normalized-by-validation input.
|
||||||
|
func (version SemVersion) String() string {
|
||||||
|
return version.original
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare applies SemVer precedence. Build metadata does not affect ordering.
|
||||||
|
func (version SemVersion) Compare(other SemVersion) int {
|
||||||
|
if comparison := compareNumericText(version.major, other.major); comparison != 0 {
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
if comparison := compareNumericText(version.minor, other.minor); comparison != 0 {
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
if comparison := compareNumericText(version.patch, other.patch); comparison != 0 {
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(version.prerelease) == 0 && len(other.prerelease) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(version.prerelease) == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if len(other.prerelease) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(version.prerelease)
|
||||||
|
if len(other.prerelease) < count {
|
||||||
|
count = len(other.prerelease)
|
||||||
|
}
|
||||||
|
for index := 0; index < count; index++ {
|
||||||
|
left := version.prerelease[index]
|
||||||
|
right := other.prerelease[index]
|
||||||
|
leftNumeric := allDigits(left)
|
||||||
|
rightNumeric := allDigits(right)
|
||||||
|
switch {
|
||||||
|
case leftNumeric && rightNumeric:
|
||||||
|
if comparison := compareNumericText(left, right); comparison != 0 {
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
case leftNumeric:
|
||||||
|
return -1
|
||||||
|
case rightNumeric:
|
||||||
|
return 1
|
||||||
|
case left < right:
|
||||||
|
return -1
|
||||||
|
case left > right:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(version.prerelease) < len(other.prerelease):
|
||||||
|
return -1
|
||||||
|
case len(version.prerelease) > len(other.prerelease):
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSemVer parses and compares two version strings.
|
||||||
|
func CompareSemVer(left, right string) (int, error) {
|
||||||
|
leftVersion, err := ParseSemVer(left)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rightVersion, err := ParseSemVer(right)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return leftVersion.Compare(rightVersion), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIdentifiers(value string, rejectNumericLeadingZero bool) ([]string, error) {
|
||||||
|
identifiers := strings.Split(value, ".")
|
||||||
|
if value == "" || len(identifiers) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: empty identifier list", ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
for _, identifier := range identifiers {
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
for _, character := range identifier {
|
||||||
|
if (character < '0' || character > '9') &&
|
||||||
|
(character < 'A' || character > 'Z') &&
|
||||||
|
(character < 'a' || character > 'z') &&
|
||||||
|
character != '-' {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: invalid identifier %q",
|
||||||
|
ErrInvalidSemVer,
|
||||||
|
identifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rejectNumericLeadingZero &&
|
||||||
|
allDigits(identifier) &&
|
||||||
|
!validNumericIdentifier(identifier, false) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: numeric prerelease identifier %q has a leading zero",
|
||||||
|
ErrInvalidSemVer,
|
||||||
|
identifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identifiers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validNumericIdentifier(value string, allowLeadingZero bool) bool {
|
||||||
|
if value == "" || !allDigits(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return allowLeadingZero || len(value) == 1 || value[0] != '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character < '0' || character > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareNumericText(left, right string) int {
|
||||||
|
switch {
|
||||||
|
case len(left) < len(right):
|
||||||
|
return -1
|
||||||
|
case len(left) > len(right):
|
||||||
|
return 1
|
||||||
|
case left < right:
|
||||||
|
return -1
|
||||||
|
case left > right:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSemVerPrecedence(t *testing.T) {
|
||||||
|
ordered := []string{
|
||||||
|
"1.0.0-alpha",
|
||||||
|
"1.0.0-alpha.1",
|
||||||
|
"1.0.0-alpha.beta",
|
||||||
|
"1.0.0-beta",
|
||||||
|
"1.0.0-beta.2",
|
||||||
|
"1.0.0-beta.11",
|
||||||
|
"1.0.0-rc.1",
|
||||||
|
"1.0.0",
|
||||||
|
"1.0.1",
|
||||||
|
"1.1.0",
|
||||||
|
"2.0.0",
|
||||||
|
}
|
||||||
|
for index := 0; index < len(ordered)-1; index++ {
|
||||||
|
comparison, err := CompareSemVer(ordered[index], ordered[index+1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareSemVer() error = %v", err)
|
||||||
|
}
|
||||||
|
if comparison >= 0 {
|
||||||
|
t.Fatalf("%q should precede %q", ordered[index], ordered[index+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemVerIgnoresBuildMetadata(t *testing.T) {
|
||||||
|
comparison, err := CompareSemVer("1.2.3+build.1", "1.2.3+build.99")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareSemVer() error = %v", err)
|
||||||
|
}
|
||||||
|
if comparison != 0 {
|
||||||
|
t.Fatalf("comparison = %d, want 0", comparison)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemVerSupportsLargeNumericIdentifiers(t *testing.T) {
|
||||||
|
comparison, err := CompareSemVer(
|
||||||
|
"999999999999999999999999999.0.0",
|
||||||
|
"1000000000000000000000000000.0.0",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareSemVer() error = %v", err)
|
||||||
|
}
|
||||||
|
if comparison >= 0 {
|
||||||
|
t.Fatalf("comparison = %d, want negative", comparison)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemVerRejectsInvalidValues(t *testing.T) {
|
||||||
|
for _, value := range []string{
|
||||||
|
"",
|
||||||
|
"1",
|
||||||
|
"1.2",
|
||||||
|
"01.2.3",
|
||||||
|
"1.02.3",
|
||||||
|
"1.2.03",
|
||||||
|
"1.2.3-",
|
||||||
|
"1.2.3-alpha..1",
|
||||||
|
"1.2.3-01",
|
||||||
|
"1.2.3+",
|
||||||
|
"1.2.3+build..1",
|
||||||
|
"v1.2.3",
|
||||||
|
"1.2.3 alpha",
|
||||||
|
} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
_, err := ParseSemVer(value)
|
||||||
|
if !errors.Is(err, ErrInvalidSemVer) {
|
||||||
|
t.Fatalf("ParseSemVer(%q) error = %v, want %v", value, err, ErrInvalidSemVer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppStatus describes the user-visible lifecycle state of one catalog app.
|
||||||
|
type AppStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusNotInstalled AppStatus = "not_installed"
|
||||||
|
StatusQueued AppStatus = "queued"
|
||||||
|
StatusDownloading AppStatus = "downloading"
|
||||||
|
StatusVerifying AppStatus = "verifying"
|
||||||
|
StatusExtracting AppStatus = "extracting"
|
||||||
|
StatusInstalling AppStatus = "installing"
|
||||||
|
StatusInstalled AppStatus = "installed"
|
||||||
|
StatusUpdateAvailable AppStatus = "update_available"
|
||||||
|
StatusRunning AppStatus = "running"
|
||||||
|
StatusFailed AppStatus = "failed"
|
||||||
|
StatusRollbackPending AppStatus = "rollback_pending"
|
||||||
|
StatusIncompatible AppStatus = "incompatible"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidStatus indicates that a transition contains an unknown status.
|
||||||
|
ErrInvalidStatus = errors.New("invalid app status")
|
||||||
|
// ErrInvalidTransition indicates that two valid states cannot transition directly.
|
||||||
|
ErrInvalidTransition = errors.New("invalid app status transition")
|
||||||
|
)
|
||||||
|
|
||||||
|
var validStatuses = map[AppStatus]struct{}{
|
||||||
|
StatusNotInstalled: {},
|
||||||
|
StatusQueued: {},
|
||||||
|
StatusDownloading: {},
|
||||||
|
StatusVerifying: {},
|
||||||
|
StatusExtracting: {},
|
||||||
|
StatusInstalling: {},
|
||||||
|
StatusInstalled: {},
|
||||||
|
StatusUpdateAvailable: {},
|
||||||
|
StatusRunning: {},
|
||||||
|
StatusFailed: {},
|
||||||
|
StatusRollbackPending: {},
|
||||||
|
StatusIncompatible: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusTransition struct {
|
||||||
|
from AppStatus
|
||||||
|
to AppStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedTransitions = map[statusTransition]struct{}{
|
||||||
|
{StatusNotInstalled, StatusQueued}: {},
|
||||||
|
{StatusNotInstalled, StatusIncompatible}: {},
|
||||||
|
{StatusQueued, StatusDownloading}: {},
|
||||||
|
{StatusQueued, StatusNotInstalled}: {},
|
||||||
|
{StatusQueued, StatusInstalled}: {},
|
||||||
|
{StatusQueued, StatusUpdateAvailable}: {},
|
||||||
|
{StatusQueued, StatusFailed}: {},
|
||||||
|
{StatusDownloading, StatusQueued}: {},
|
||||||
|
{StatusDownloading, StatusVerifying}: {},
|
||||||
|
{StatusDownloading, StatusNotInstalled}: {},
|
||||||
|
{StatusDownloading, StatusInstalled}: {},
|
||||||
|
{StatusDownloading, StatusUpdateAvailable}: {},
|
||||||
|
{StatusDownloading, StatusFailed}: {},
|
||||||
|
{StatusVerifying, StatusExtracting}: {},
|
||||||
|
{StatusVerifying, StatusFailed}: {},
|
||||||
|
{StatusExtracting, StatusInstalling}: {},
|
||||||
|
{StatusExtracting, StatusFailed}: {},
|
||||||
|
{StatusInstalling, StatusInstalled}: {},
|
||||||
|
{StatusInstalling, StatusRollbackPending}: {},
|
||||||
|
{StatusInstalling, StatusFailed}: {},
|
||||||
|
{StatusInstalled, StatusUpdateAvailable}: {},
|
||||||
|
{StatusInstalled, StatusRunning}: {},
|
||||||
|
{StatusInstalled, StatusQueued}: {},
|
||||||
|
{StatusInstalled, StatusIncompatible}: {},
|
||||||
|
{StatusUpdateAvailable, StatusQueued}: {},
|
||||||
|
{StatusUpdateAvailable, StatusRunning}: {},
|
||||||
|
{StatusUpdateAvailable, StatusInstalled}: {},
|
||||||
|
{StatusUpdateAvailable, StatusIncompatible}: {},
|
||||||
|
{StatusRunning, StatusInstalled}: {},
|
||||||
|
{StatusRunning, StatusUpdateAvailable}: {},
|
||||||
|
{StatusFailed, StatusQueued}: {},
|
||||||
|
{StatusFailed, StatusNotInstalled}: {},
|
||||||
|
{StatusFailed, StatusInstalled}: {},
|
||||||
|
{StatusFailed, StatusUpdateAvailable}: {},
|
||||||
|
{StatusFailed, StatusRollbackPending}: {},
|
||||||
|
{StatusRollbackPending, StatusInstalled}: {},
|
||||||
|
{StatusRollbackPending, StatusFailed}: {},
|
||||||
|
{StatusIncompatible, StatusNotInstalled}: {},
|
||||||
|
{StatusIncompatible, StatusInstalled}: {},
|
||||||
|
{StatusIncompatible, StatusUpdateAvailable}: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether status is one of the documented domain states.
|
||||||
|
func (status AppStatus) Valid() bool {
|
||||||
|
_, ok := validStatuses[status]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransition reports whether from can move directly to to.
|
||||||
|
func CanTransition(from, to AppStatus) bool {
|
||||||
|
return ValidateTransition(from, to) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTransition verifies a direct state change.
|
||||||
|
func ValidateTransition(from, to AppStatus) error {
|
||||||
|
if !from.Valid() {
|
||||||
|
return fmt.Errorf("%w: %q", ErrInvalidStatus, from)
|
||||||
|
}
|
||||||
|
if !to.Valid() {
|
||||||
|
return fmt.Errorf("%w: %q", ErrInvalidStatus, to)
|
||||||
|
}
|
||||||
|
if _, ok := allowedTransitions[statusTransition{from: from, to: to}]; !ok {
|
||||||
|
return &TransitionError{From: from, To: to}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionError describes a rejected direct state change.
|
||||||
|
type TransitionError struct {
|
||||||
|
From AppStatus
|
||||||
|
To AppStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *TransitionError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap allows callers to use errors.Is with ErrInvalidTransition.
|
||||||
|
func (err *TransitionError) Unwrap() error {
|
||||||
|
return ErrInvalidTransition
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidStatusFacts = errors.New("invalid app status facts")
|
||||||
|
|
||||||
|
// AppStatusFacts are IO-free observations used to derive one visible state.
|
||||||
|
type AppStatusFacts struct {
|
||||||
|
Operation AppStatus
|
||||||
|
Running bool
|
||||||
|
RecoveryPending bool
|
||||||
|
Incompatible bool
|
||||||
|
InstalledVersion string
|
||||||
|
CatalogVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAppStatus derives the single user-visible state from local and
|
||||||
|
// background-operation facts.
|
||||||
|
func ResolveAppStatus(facts AppStatusFacts) (AppStatus, error) {
|
||||||
|
if facts.RecoveryPending {
|
||||||
|
return StatusRollbackPending, nil
|
||||||
|
}
|
||||||
|
if facts.Operation != "" {
|
||||||
|
if !validOperationStatus(facts.Operation) {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: operation %q",
|
||||||
|
ErrInvalidStatusFacts,
|
||||||
|
facts.Operation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return facts.Operation, nil
|
||||||
|
}
|
||||||
|
if facts.Running {
|
||||||
|
return StatusRunning, nil
|
||||||
|
}
|
||||||
|
if facts.Incompatible {
|
||||||
|
return StatusIncompatible, nil
|
||||||
|
}
|
||||||
|
if facts.InstalledVersion == "" {
|
||||||
|
return StatusNotInstalled, nil
|
||||||
|
}
|
||||||
|
if _, err := ParseSemVer(facts.InstalledVersion); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: installed version: %v", ErrInvalidStatusFacts, err)
|
||||||
|
}
|
||||||
|
if facts.CatalogVersion == "" {
|
||||||
|
return StatusInstalled, nil
|
||||||
|
}
|
||||||
|
comparison, err := CompareSemVer(facts.InstalledVersion, facts.CatalogVersion)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: catalog version: %v", ErrInvalidStatusFacts, err)
|
||||||
|
}
|
||||||
|
if comparison < 0 {
|
||||||
|
return StatusUpdateAvailable, nil
|
||||||
|
}
|
||||||
|
return StatusInstalled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOperationStatus(status AppStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case StatusQueued,
|
||||||
|
StatusDownloading,
|
||||||
|
StatusVerifying,
|
||||||
|
StatusExtracting,
|
||||||
|
StatusInstalling,
|
||||||
|
StatusFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveAppStatusCoversAllStates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
facts AppStatusFacts
|
||||||
|
want AppStatus
|
||||||
|
}{
|
||||||
|
{name: "not installed", want: StatusNotInstalled},
|
||||||
|
{name: "queued", facts: AppStatusFacts{Operation: StatusQueued}, want: StatusQueued},
|
||||||
|
{name: "downloading", facts: AppStatusFacts{Operation: StatusDownloading}, want: StatusDownloading},
|
||||||
|
{name: "verifying", facts: AppStatusFacts{Operation: StatusVerifying}, want: StatusVerifying},
|
||||||
|
{name: "extracting", facts: AppStatusFacts{Operation: StatusExtracting}, want: StatusExtracting},
|
||||||
|
{name: "installing", facts: AppStatusFacts{Operation: StatusInstalling}, want: StatusInstalling},
|
||||||
|
{
|
||||||
|
name: "installed",
|
||||||
|
facts: AppStatusFacts{
|
||||||
|
InstalledVersion: "1.2.0",
|
||||||
|
CatalogVersion: "1.2.0",
|
||||||
|
},
|
||||||
|
want: StatusInstalled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update available",
|
||||||
|
facts: AppStatusFacts{
|
||||||
|
InstalledVersion: "1.2.0",
|
||||||
|
CatalogVersion: "1.3.0",
|
||||||
|
},
|
||||||
|
want: StatusUpdateAvailable,
|
||||||
|
},
|
||||||
|
{name: "running", facts: AppStatusFacts{Running: true}, want: StatusRunning},
|
||||||
|
{name: "failed", facts: AppStatusFacts{Operation: StatusFailed}, want: StatusFailed},
|
||||||
|
{
|
||||||
|
name: "rollback pending",
|
||||||
|
facts: AppStatusFacts{RecoveryPending: true},
|
||||||
|
want: StatusRollbackPending,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incompatible",
|
||||||
|
facts: AppStatusFacts{Incompatible: true},
|
||||||
|
want: StatusIncompatible,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[AppStatus]bool)
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
status, err := ResolveAppStatus(test.facts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if status != test.want {
|
||||||
|
t.Fatalf("status = %q, want %q", status, test.want)
|
||||||
|
}
|
||||||
|
seen[status] = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(seen) != 12 {
|
||||||
|
t.Fatalf("covered %d states, want 12", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAppStatusPriorityAndValidation(t *testing.T) {
|
||||||
|
status, err := ResolveAppStatus(AppStatusFacts{
|
||||||
|
Operation: StatusDownloading,
|
||||||
|
Running: true,
|
||||||
|
RecoveryPending: true,
|
||||||
|
Incompatible: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusRollbackPending {
|
||||||
|
t.Fatalf("status = %q, want %q", status, StatusRollbackPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ResolveAppStatus(AppStatusFacts{Operation: StatusInstalled})
|
||||||
|
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||||
|
t.Fatalf("operation error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||||
|
}
|
||||||
|
_, err = ResolveAppStatus(AppStatusFacts{InstalledVersion: "not-semver"})
|
||||||
|
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||||
|
t.Fatalf("version error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppStatusValid(t *testing.T) {
|
||||||
|
statuses := []AppStatus{
|
||||||
|
StatusNotInstalled,
|
||||||
|
StatusQueued,
|
||||||
|
StatusDownloading,
|
||||||
|
StatusVerifying,
|
||||||
|
StatusExtracting,
|
||||||
|
StatusInstalling,
|
||||||
|
StatusInstalled,
|
||||||
|
StatusUpdateAvailable,
|
||||||
|
StatusRunning,
|
||||||
|
StatusFailed,
|
||||||
|
StatusRollbackPending,
|
||||||
|
StatusIncompatible,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range statuses {
|
||||||
|
if !status.Valid() {
|
||||||
|
t.Errorf("status %q should be valid", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if AppStatus("unknown").Valid() {
|
||||||
|
t.Fatal("unknown status should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTransition(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from AppStatus
|
||||||
|
to AppStatus
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{name: "queue install", from: StatusNotInstalled, to: StatusQueued},
|
||||||
|
{name: "start download", from: StatusQueued, to: StatusDownloading},
|
||||||
|
{name: "pause download", from: StatusDownloading, to: StatusQueued},
|
||||||
|
{name: "finish install", from: StatusInstalling, to: StatusInstalled},
|
||||||
|
{name: "start app", from: StatusInstalled, to: StatusRunning},
|
||||||
|
{name: "recover rollback", from: StatusRollbackPending, to: StatusInstalled},
|
||||||
|
{name: "skip install pipeline", from: StatusNotInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||||
|
{name: "download while running", from: StatusRunning, to: StatusDownloading, want: ErrInvalidTransition},
|
||||||
|
{name: "extract incompatible app", from: StatusIncompatible, to: StatusExtracting, want: ErrInvalidTransition},
|
||||||
|
{name: "same status", from: StatusInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||||
|
{name: "unknown source", from: AppStatus("unknown"), to: StatusQueued, want: ErrInvalidStatus},
|
||||||
|
{name: "unknown target", from: StatusQueued, to: AppStatus("unknown"), want: ErrInvalidStatus},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := ValidateTransition(test.from, test.to)
|
||||||
|
if test.want == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTransition(%q, %q) error = %v", test.from, test.to, err)
|
||||||
|
}
|
||||||
|
if !CanTransition(test.from, test.to) {
|
||||||
|
t.Fatalf("CanTransition(%q, %q) = false, want true", test.from, test.to)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, test.want) {
|
||||||
|
t.Fatalf("ValidateTransition(%q, %q) error = %v, want %v", test.from, test.to, err, test.want)
|
||||||
|
}
|
||||||
|
if CanTransition(test.from, test.to) {
|
||||||
|
t.Fatalf("CanTransition(%q, %q) = true, want false", test.from, test.to)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module softbox.local/core
|
||||||
|
|
||||||
|
go 1.20
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidArchive = errors.New("invalid ZIP archive")
|
||||||
|
ErrPathEscape = errors.New("ZIP path escapes payload")
|
||||||
|
ErrUnsupportedEntry = errors.New("unsupported ZIP entry")
|
||||||
|
ErrUnexpectedEntry = errors.New("unexpected ZIP package entry")
|
||||||
|
ErrDuplicateEntry = errors.New("duplicate ZIP entry")
|
||||||
|
ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported")
|
||||||
|
ErrTooManyEntries = errors.New("ZIP entry limit exceeded")
|
||||||
|
ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded")
|
||||||
|
ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded")
|
||||||
|
ErrEntrypointInvalid = errors.New("invalid package entrypoint")
|
||||||
|
ErrEntrypointMissing = errors.New("package entrypoint is missing")
|
||||||
|
ErrAppManifestMissing = errors.New("package app.json is missing")
|
||||||
|
ErrDestinationExists = errors.New("staging destination already exists")
|
||||||
|
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extractor writes only payload/ contents from a pre-verified package ZIP.
|
||||||
|
type Extractor struct {
|
||||||
|
limits Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractResult struct {
|
||||||
|
Files int
|
||||||
|
Bytes int64
|
||||||
|
EntrypointPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type plannedEntry struct {
|
||||||
|
file *zip.File
|
||||||
|
archivePath string
|
||||||
|
outputPath string
|
||||||
|
directory bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExtractor(limits Limits) (Extractor, error) {
|
||||||
|
if err := limits.validate(); err != nil {
|
||||||
|
return Extractor{}, err
|
||||||
|
}
|
||||||
|
return Extractor{limits: limits}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFile assumes zipPath already passed Catalog signature and SHA-256 checks.
|
||||||
|
func (extractor Extractor) ExtractFile(
|
||||||
|
zipPath string,
|
||||||
|
destination string,
|
||||||
|
entrypoint string,
|
||||||
|
) (ExtractResult, error) {
|
||||||
|
archive, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err)
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
return extractor.extract(&archive.Reader, destination, entrypoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor Extractor) extract(
|
||||||
|
archive *zip.Reader,
|
||||||
|
destination string,
|
||||||
|
entrypoint string,
|
||||||
|
) (result ExtractResult, err error) {
|
||||||
|
if err := extractor.limits.validate(); err != nil {
|
||||||
|
return ExtractResult{}, err
|
||||||
|
}
|
||||||
|
normalizedEntrypoint, err := normalizeEntrypoint(entrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return ExtractResult{}, err
|
||||||
|
}
|
||||||
|
plan, err := extractor.preflight(archive, normalizedEntrypoint)
|
||||||
|
if err != nil {
|
||||||
|
return ExtractResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("create staging parent: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(destination, 0o700); err != nil {
|
||||||
|
if os.IsExist(err) {
|
||||||
|
return ExtractResult{}, ErrDestinationExists
|
||||||
|
}
|
||||||
|
return ExtractResult{}, fmt.Errorf("create staging destination: %w", err)
|
||||||
|
}
|
||||||
|
complete := false
|
||||||
|
defer func() {
|
||||||
|
if !complete {
|
||||||
|
_ = os.RemoveAll(destination)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
for _, entry := range plan {
|
||||||
|
target := filepath.Join(destination, filepath.FromSlash(entry.outputPath))
|
||||||
|
if entry.directory {
|
||||||
|
if entry.outputPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("create staging directory: %w", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := entry.file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err)
|
||||||
|
}
|
||||||
|
mode := os.FileMode(0o600)
|
||||||
|
if entry.file.Mode().Perm()&0o111 != 0 {
|
||||||
|
mode = 0o700
|
||||||
|
}
|
||||||
|
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||||
|
if err != nil {
|
||||||
|
source.Close()
|
||||||
|
return ExtractResult{}, fmt.Errorf("create staging file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := extractor.limits.MaxUncompressedBytes - written
|
||||||
|
readLimit := remaining
|
||||||
|
if readLimit < math.MaxInt64 {
|
||||||
|
readLimit++
|
||||||
|
}
|
||||||
|
copied, copyErr := io.Copy(output, io.LimitReader(source, readLimit))
|
||||||
|
closeOutputErr := output.Close()
|
||||||
|
closeSourceErr := source.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr)
|
||||||
|
}
|
||||||
|
if closeOutputErr != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("close staging file: %w", closeOutputErr)
|
||||||
|
}
|
||||||
|
if closeSourceErr != nil {
|
||||||
|
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
|
||||||
|
}
|
||||||
|
if copied > remaining {
|
||||||
|
return ExtractResult{}, ErrExpandedTooLarge
|
||||||
|
}
|
||||||
|
if uint64(copied) != entry.file.UncompressedSize64 {
|
||||||
|
return ExtractResult{}, fmt.Errorf(
|
||||||
|
"%w: %s expanded to %d bytes, header declares %d",
|
||||||
|
ErrArchiveCorrupt,
|
||||||
|
entry.archivePath,
|
||||||
|
copied,
|
||||||
|
entry.file.UncompressedSize64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
written += copied
|
||||||
|
result.Files++
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Bytes = written
|
||||||
|
result.EntrypointPath = filepath.Join(
|
||||||
|
destination,
|
||||||
|
filepath.FromSlash(normalizedEntrypoint),
|
||||||
|
)
|
||||||
|
complete = true
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (extractor Extractor) preflight(
|
||||||
|
archive *zip.Reader,
|
||||||
|
entrypoint string,
|
||||||
|
) ([]plannedEntry, error) {
|
||||||
|
if len(archive.File) > extractor.limits.MaxEntries {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: got %d, limit %d",
|
||||||
|
ErrTooManyEntries,
|
||||||
|
len(archive.File),
|
||||||
|
extractor.limits.MaxEntries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entrypointArchivePath := "payload/" + entrypoint
|
||||||
|
seenPaths := make(map[string]string, len(archive.File))
|
||||||
|
plan := make([]plannedEntry, 0, len(archive.File))
|
||||||
|
var totalUncompressed uint64
|
||||||
|
var totalCompressed uint64
|
||||||
|
appManifestFound := false
|
||||||
|
entrypointFound := false
|
||||||
|
|
||||||
|
for _, file := range archive.File {
|
||||||
|
normalized, directory, err := validateArchiveEntry(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folded := strings.ToLower(normalized)
|
||||||
|
if previous, exists := seenPaths[folded]; exists {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: %q conflicts with %q",
|
||||||
|
ErrDuplicateEntry,
|
||||||
|
normalized,
|
||||||
|
previous,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seenPaths[folded] = normalized
|
||||||
|
|
||||||
|
if file.UncompressedSize64 > uint64(extractor.limits.MaxUncompressedBytes)-totalUncompressed {
|
||||||
|
return nil, ErrExpandedTooLarge
|
||||||
|
}
|
||||||
|
totalUncompressed += file.UncompressedSize64
|
||||||
|
if ^uint64(0)-totalCompressed < file.CompressedSize64 {
|
||||||
|
return nil, fmt.Errorf("%w: compressed size overflow", ErrInvalidArchive)
|
||||||
|
}
|
||||||
|
totalCompressed += file.CompressedSize64
|
||||||
|
if exceedsCompressionRatio(
|
||||||
|
file.UncompressedSize64,
|
||||||
|
file.CompressedSize64,
|
||||||
|
extractor.limits.MaxCompressionRatio,
|
||||||
|
) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrCompressionRatio, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case normalized == "app.json":
|
||||||
|
if directory {
|
||||||
|
return nil, fmt.Errorf("%w: app.json is a directory", ErrUnexpectedEntry)
|
||||||
|
}
|
||||||
|
appManifestFound = true
|
||||||
|
case normalized == "files.json":
|
||||||
|
if directory {
|
||||||
|
return nil, fmt.Errorf("%w: files.json is a directory", ErrUnexpectedEntry)
|
||||||
|
}
|
||||||
|
case normalized == "payload":
|
||||||
|
if !directory {
|
||||||
|
return nil, fmt.Errorf("%w: payload must be a directory", ErrUnexpectedEntry)
|
||||||
|
}
|
||||||
|
plan = append(plan, plannedEntry{
|
||||||
|
file: file,
|
||||||
|
archivePath: normalized,
|
||||||
|
outputPath: "",
|
||||||
|
directory: true,
|
||||||
|
})
|
||||||
|
case strings.HasPrefix(normalized, "payload/"):
|
||||||
|
outputPath := strings.TrimPrefix(normalized, "payload/")
|
||||||
|
plan = append(plan, plannedEntry{
|
||||||
|
file: file,
|
||||||
|
archivePath: normalized,
|
||||||
|
outputPath: outputPath,
|
||||||
|
directory: directory,
|
||||||
|
})
|
||||||
|
if normalized == entrypointArchivePath && !directory {
|
||||||
|
entrypointFound = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnexpectedEntry, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !appManifestFound {
|
||||||
|
return nil, ErrAppManifestMissing
|
||||||
|
}
|
||||||
|
if exceedsCompressionRatio(
|
||||||
|
totalUncompressed,
|
||||||
|
totalCompressed,
|
||||||
|
extractor.limits.MaxCompressionRatio,
|
||||||
|
) {
|
||||||
|
return nil, fmt.Errorf("%w: whole archive", ErrCompressionRatio)
|
||||||
|
}
|
||||||
|
if !entrypointFound {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrEntrypointMissing, entrypoint)
|
||||||
|
}
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateArchiveEntry(file *zip.File) (string, bool, error) {
|
||||||
|
if file.Flags&0x1 != 0 {
|
||||||
|
return "", false, fmt.Errorf("%w: %s", ErrEncryptedEntry, file.Name)
|
||||||
|
}
|
||||||
|
normalized, directory, err := normalizeArchivePath(file.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := file.Mode()
|
||||||
|
if mode&os.ModeSymlink != 0 {
|
||||||
|
return "", false, fmt.Errorf("%w: symlink %s", ErrUnsupportedEntry, normalized)
|
||||||
|
}
|
||||||
|
if directory {
|
||||||
|
if !mode.IsDir() {
|
||||||
|
return "", false, fmt.Errorf("%w: non-directory mode for %s", ErrUnsupportedEntry, normalized)
|
||||||
|
}
|
||||||
|
return normalized, true, nil
|
||||||
|
}
|
||||||
|
if !mode.IsRegular() {
|
||||||
|
return "", false, fmt.Errorf("%w: special file %s", ErrUnsupportedEntry, normalized)
|
||||||
|
}
|
||||||
|
return normalized, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeArchivePath(name string) (string, bool, error) {
|
||||||
|
if name == "" || !utf8.ValidString(name) || strings.ContainsRune(name, '\x00') {
|
||||||
|
return "", false, fmt.Errorf("%w: invalid entry name", ErrPathEscape)
|
||||||
|
}
|
||||||
|
if strings.Contains(name, `\`) || strings.Contains(name, ":") {
|
||||||
|
return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name)
|
||||||
|
}
|
||||||
|
directory := strings.HasSuffix(name, "/")
|
||||||
|
trimmed := strings.TrimSuffix(name, "/")
|
||||||
|
if trimmed == "" || path.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") {
|
||||||
|
return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name)
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(trimmed)
|
||||||
|
if cleaned != trimmed ||
|
||||||
|
cleaned == "." ||
|
||||||
|
cleaned == ".." ||
|
||||||
|
strings.HasPrefix(cleaned, "../") {
|
||||||
|
return "", false, fmt.Errorf("%w: %q", ErrPathEscape, name)
|
||||||
|
}
|
||||||
|
return cleaned, directory, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEntrypoint(entrypoint string) (string, error) {
|
||||||
|
if entrypoint == "" ||
|
||||||
|
!utf8.ValidString(entrypoint) ||
|
||||||
|
strings.ContainsRune(entrypoint, '\x00') ||
|
||||||
|
strings.Contains(entrypoint, `\`) ||
|
||||||
|
strings.Contains(entrypoint, ":") ||
|
||||||
|
strings.HasSuffix(entrypoint, "/") ||
|
||||||
|
path.IsAbs(entrypoint) {
|
||||||
|
return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint)
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(entrypoint)
|
||||||
|
if cleaned != entrypoint ||
|
||||||
|
cleaned == "." ||
|
||||||
|
cleaned == ".." ||
|
||||||
|
strings.HasPrefix(cleaned, "../") ||
|
||||||
|
cleaned == "payload" ||
|
||||||
|
strings.HasPrefix(cleaned, "payload/") {
|
||||||
|
return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint)
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool {
|
||||||
|
if uncompressed == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if compressed == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return float64(uncompressed)/float64(compressed) > maximum
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractorExtractsPayloadOnly(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{"entrypoint":"bin/App.exe"}`)},
|
||||||
|
{name: "files.json", body: []byte(`{"files":[]}`)},
|
||||||
|
{name: "payload/bin/", mode: os.ModeDir | 0o755},
|
||||||
|
{name: "payload/bin/App.exe", body: []byte("executable"), mode: 0o755},
|
||||||
|
{name: "payload/readme.txt", body: []byte("hello")},
|
||||||
|
})
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
|
||||||
|
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Files != 2 {
|
||||||
|
t.Fatalf("Files = %d, want 2", result.Files)
|
||||||
|
}
|
||||||
|
if result.Bytes != int64(len("executable")+len("hello")) {
|
||||||
|
t.Fatalf("Bytes = %d, want %d", result.Bytes, len("executable")+len("hello"))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(result.EntrypointPath); err != nil {
|
||||||
|
t.Fatalf("entrypoint stat error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(destination, "app.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("app.json should not be extracted, stat error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorRejectsAttackArchives(t *testing.T) {
|
||||||
|
base := []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("ok")},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entries []testZIPEntry
|
||||||
|
entrypoint string
|
||||||
|
limits Limits
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absolute path",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "/payload/evil.exe", body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrPathEscape,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drive path",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "C:/payload/evil.exe", body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrPathEscape,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ADS path",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/App.exe:stream", body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrPathEscape,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot dot traversal",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/../evil.exe", body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrPathEscape,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash traversal",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: `payload\..\evil.exe`, body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrPathEscape,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted entry",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/secret.bin", body: []byte("x"), flags: 0x1}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrEncryptedEntry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "symlink",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{
|
||||||
|
name: "payload/link",
|
||||||
|
body: []byte("../../outside"),
|
||||||
|
mode: os.ModeSymlink | 0o777,
|
||||||
|
}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrUnsupportedEntry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special file",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/pipe", mode: os.ModeNamedPipe | 0o600}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrUnsupportedEntry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case folded duplicate",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/app.exe", body: []byte("duplicate")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrDuplicateEntry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unexpected top level entry",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "install.bat", body: []byte("echo unsafe")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: testLimits(),
|
||||||
|
wantErr: ErrUnexpectedEntry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many entries",
|
||||||
|
entries: appendEntries(base,
|
||||||
|
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: Limits{
|
||||||
|
MaxEntries: 2,
|
||||||
|
MaxUncompressedBytes: 1024,
|
||||||
|
MaxCompressionRatio: 100,
|
||||||
|
},
|
||||||
|
wantErr: ErrTooManyEntries,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expanded size",
|
||||||
|
entries: []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("0123456789")},
|
||||||
|
},
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: Limits{
|
||||||
|
MaxEntries: 10,
|
||||||
|
MaxUncompressedBytes: 8,
|
||||||
|
MaxCompressionRatio: 100,
|
||||||
|
},
|
||||||
|
wantErr: ErrExpandedTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compression ratio",
|
||||||
|
entries: []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{
|
||||||
|
name: "payload/App.exe",
|
||||||
|
body: bytes.Repeat([]byte("A"), 4096),
|
||||||
|
method: zip.Deflate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entrypoint: "App.exe",
|
||||||
|
limits: Limits{
|
||||||
|
MaxEntries: 10,
|
||||||
|
MaxUncompressedBytes: 8192,
|
||||||
|
MaxCompressionRatio: 2,
|
||||||
|
},
|
||||||
|
wantErr: ErrCompressionRatio,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, test.entries)
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
extractor := mustExtractor(t, test.limits)
|
||||||
|
|
||||||
|
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("ok")},
|
||||||
|
})
|
||||||
|
tests := []struct {
|
||||||
|
entrypoint string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid},
|
||||||
|
{entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid},
|
||||||
|
{entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid},
|
||||||
|
{entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid},
|
||||||
|
{entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.entrypoint, func(t *testing.T) {
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("invalid entrypoint left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorRejectsExistingDestination(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("ok")},
|
||||||
|
})
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
if err := os.Mkdir(destination, 0o700); err != nil {
|
||||||
|
t.Fatalf("Mkdir() error = %v", err)
|
||||||
|
}
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
|
||||||
|
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
||||||
|
if !errors.Is(err, ErrDestinationExists) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
|
||||||
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||||
|
{name: "app.json", body: []byte(`{}`)},
|
||||||
|
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
|
||||||
|
})
|
||||||
|
corruptZIPEntryData(t, archivePath, "payload/App.exe")
|
||||||
|
destination := filepath.Join(t.TempDir(), "staging")
|
||||||
|
extractor := mustExtractor(t, testLimits())
|
||||||
|
|
||||||
|
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
||||||
|
if !errors.Is(err, ErrArchiveCorrupt) {
|
||||||
|
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testZIPEntry struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
mode os.FileMode
|
||||||
|
method uint16
|
||||||
|
flags uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLimits() Limits {
|
||||||
|
return Limits{
|
||||||
|
MaxEntries: 20,
|
||||||
|
MaxUncompressedBytes: 16 * 1024,
|
||||||
|
MaxCompressionRatio: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExtractor(t *testing.T, limits Limits) Extractor {
|
||||||
|
t.Helper()
|
||||||
|
extractor, err := NewExtractor(limits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewExtractor() error = %v", err)
|
||||||
|
}
|
||||||
|
return extractor
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEntries(base []testZIPEntry, extra ...testZIPEntry) []testZIPEntry {
|
||||||
|
result := append([]testZIPEntry(nil), base...)
|
||||||
|
return append(result, extra...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestZIP(t *testing.T, entries []testZIPEntry) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "package.zip")
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create ZIP: %v", err)
|
||||||
|
}
|
||||||
|
writer := zip.NewWriter(file)
|
||||||
|
for _, entry := range entries {
|
||||||
|
header := &zip.FileHeader{
|
||||||
|
Name: entry.name,
|
||||||
|
Method: entry.method,
|
||||||
|
Flags: entry.flags,
|
||||||
|
}
|
||||||
|
mode := entry.mode
|
||||||
|
if mode == 0 {
|
||||||
|
mode = 0o600
|
||||||
|
}
|
||||||
|
header.SetMode(mode)
|
||||||
|
part, err := writer.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
writer.Close()
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("create ZIP entry %s: %v", entry.name, err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(entry.body); err != nil {
|
||||||
|
writer.Close()
|
||||||
|
file.Close()
|
||||||
|
t.Fatalf("write ZIP entry %s: %v", entry.name, 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 ZIP file: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func corruptZIPEntryData(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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read ZIP for corruption: %v", err)
|
||||||
|
}
|
||||||
|
data[offset] ^= 0xff
|
||||||
|
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("write corrupted ZIP: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLimitsAreValid(t *testing.T) {
|
||||||
|
if _, err := NewExtractor(DefaultLimits()); err != nil {
|
||||||
|
t.Fatalf("NewExtractor(DefaultLimits()) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorRejectsInvalidLimits(t *testing.T) {
|
||||||
|
tests := []Limits{
|
||||||
|
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
|
||||||
|
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
|
||||||
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
|
||||||
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
|
||||||
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, limits := range tests {
|
||||||
|
if _, err := NewExtractor(limits); !errors.Is(err, ErrInvalidLimits) {
|
||||||
|
t.Errorf("case %d NewExtractor() error = %v, want %v", index, err, ErrInvalidLimits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnsafeInstallLayout = errors.New("unsafe install directory layout")
|
||||||
|
ErrStagingMissing = errors.New("staging directory is missing")
|
||||||
|
ErrBackupExists = errors.New("backup directory already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type appLayout struct {
|
||||||
|
root string
|
||||||
|
current string
|
||||||
|
staging string
|
||||||
|
backup string
|
||||||
|
transaction string
|
||||||
|
transactionBackup string
|
||||||
|
}
|
||||||
|
|
||||||
|
type directoryState struct {
|
||||||
|
current bool
|
||||||
|
staging bool
|
||||||
|
backup bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectAppLayout(root string) (appLayout, error) {
|
||||||
|
if root == "" {
|
||||||
|
return appLayout{}, fmt.Errorf("%w: empty root", ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
absolute, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return appLayout{}, fmt.Errorf("%w: %v", ErrUnsafeInstallLayout, err)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(absolute)
|
||||||
|
if err != nil {
|
||||||
|
return appLayout{}, fmt.Errorf("%w: root: %v", ErrUnsafeInstallLayout, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return appLayout{}, fmt.Errorf("%w: root is not a real directory", ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
transaction, transactionBackup := transactionPaths(absolute)
|
||||||
|
return appLayout{
|
||||||
|
root: absolute,
|
||||||
|
current: filepath.Join(absolute, "current"),
|
||||||
|
staging: filepath.Join(absolute, "staging"),
|
||||||
|
backup: filepath.Join(absolute, "backup"),
|
||||||
|
transaction: transaction,
|
||||||
|
transactionBackup: transactionBackup,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectDirectories(layout appLayout) (directoryState, error) {
|
||||||
|
current, err := inspectManagedDirectory(layout.current)
|
||||||
|
if err != nil {
|
||||||
|
return directoryState{}, err
|
||||||
|
}
|
||||||
|
staging, err := inspectManagedDirectory(layout.staging)
|
||||||
|
if err != nil {
|
||||||
|
return directoryState{}, err
|
||||||
|
}
|
||||||
|
backup, err := inspectManagedDirectory(layout.backup)
|
||||||
|
if err != nil {
|
||||||
|
return directoryState{}, err
|
||||||
|
}
|
||||||
|
return directoryState{
|
||||||
|
current: current,
|
||||||
|
staging: staging,
|
||||||
|
backup: backup,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectManagedDirectory(path string) (bool, error) {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%w: inspect %s: %v", ErrUnsafeInstallLayout, path, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return false, fmt.Errorf("%w: %s is not a real directory", ErrUnsafeInstallLayout, path)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeManagedDirectory(layout appLayout, target string) error {
|
||||||
|
if filepath.Dir(target) != layout.root {
|
||||||
|
return fmt.Errorf("%w: refuse removal outside app root", ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
base := filepath.Base(target)
|
||||||
|
if base != "current" && base != "staging" && base != "backup" {
|
||||||
|
return fmt.Errorf("%w: refuse removal of %s", ErrUnsafeInstallLayout, base)
|
||||||
|
}
|
||||||
|
exists, err := inspectManagedDirectory(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(target); err != nil {
|
||||||
|
return fmt.Errorf("remove managed directory %s: %w", base, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidLimits = errors.New("invalid ZIP extraction limits")
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultMaxEntries = 10_000
|
||||||
|
DefaultMaxUncompressedBytes = int64(4 * 1024 * 1024 * 1024)
|
||||||
|
DefaultMaxCompressionRatio = 200.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits bounds archive metadata and decompressed output.
|
||||||
|
type Limits struct {
|
||||||
|
MaxEntries int
|
||||||
|
MaxUncompressedBytes int64
|
||||||
|
MaxCompressionRatio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLimits() Limits {
|
||||||
|
return Limits{
|
||||||
|
MaxEntries: DefaultMaxEntries,
|
||||||
|
MaxUncompressedBytes: DefaultMaxUncompressedBytes,
|
||||||
|
MaxCompressionRatio: DefaultMaxCompressionRatio,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limits Limits) validate() error {
|
||||||
|
if limits.MaxEntries <= 0 {
|
||||||
|
return fmt.Errorf("%w: MaxEntries must be positive", ErrInvalidLimits)
|
||||||
|
}
|
||||||
|
if limits.MaxUncompressedBytes <= 0 {
|
||||||
|
return fmt.Errorf("%w: MaxUncompressedBytes must be positive", ErrInvalidLimits)
|
||||||
|
}
|
||||||
|
if limits.MaxCompressionRatio <= 0 ||
|
||||||
|
math.IsNaN(limits.MaxCompressionRatio) ||
|
||||||
|
math.IsInf(limits.MaxCompressionRatio, 0) {
|
||||||
|
return fmt.Errorf("%w: MaxCompressionRatio must be finite and positive", ErrInvalidLimits)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrRecoveryInconsistent = errors.New("install recovery state is inconsistent")
|
||||||
|
|
||||||
|
type RecoveryAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RecoveryNone RecoveryAction = "none"
|
||||||
|
RecoveryAborted RecoveryAction = "aborted"
|
||||||
|
RecoveryRolledBack RecoveryAction = "rolled_back"
|
||||||
|
RecoveryCommitted RecoveryAction = "committed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecoveryResult struct {
|
||||||
|
Action RecoveryAction
|
||||||
|
Phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover resolves an interrupted transaction from journal and directory state.
|
||||||
|
func Recover(root string) (RecoveryResult, error) {
|
||||||
|
layout, err := inspectAppLayout(root)
|
||||||
|
if err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
record, exists, err := loadTransaction(layout)
|
||||||
|
if err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
state, err := inspectDirectories(layout)
|
||||||
|
if err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if state.backup {
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: backup exists without transaction",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return RecoveryResult{Action: RecoveryNone}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := RecoveryResult{Phase: string(record.Phase)}
|
||||||
|
switch record.Phase {
|
||||||
|
case phaseCommitted:
|
||||||
|
if !state.current || state.staging {
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: committed current=%t staging=%t",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
state.current,
|
||||||
|
state.staging,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := removeManagedDirectory(layout, layout.backup); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryCommitted
|
||||||
|
return result, nil
|
||||||
|
case phaseRollbackRequired:
|
||||||
|
return recoverRollbackRequired(layout, record, state, result)
|
||||||
|
case phasePrepared, phaseCurrentBackedUp, phaseStagingActivated:
|
||||||
|
return recoverUncommitted(layout, record, state, result)
|
||||||
|
default:
|
||||||
|
return RecoveryResult{}, fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverUncommitted(
|
||||||
|
layout appLayout,
|
||||||
|
record transactionRecord,
|
||||||
|
state directoryState,
|
||||||
|
result RecoveryResult,
|
||||||
|
) (RecoveryResult, error) {
|
||||||
|
if record.HadCurrent {
|
||||||
|
if state.backup {
|
||||||
|
if state.current && state.staging {
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: current, staging and backup all exist",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if state.current {
|
||||||
|
if err := os.Rename(layout.current, layout.staging); err != nil {
|
||||||
|
return RecoveryResult{}, fmt.Errorf("move unverified current aside: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(layout.backup, layout.current); err != nil {
|
||||||
|
return RecoveryResult{}, fmt.Errorf("restore backup during recovery: %w", err)
|
||||||
|
}
|
||||||
|
if err := removeManagedDirectory(layout, layout.staging); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryRolledBack
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if record.Phase == phasePrepared && state.current && state.staging {
|
||||||
|
if err := removeManagedDirectory(layout, layout.staging); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryAborted
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: old current has no recoverable backup",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.backup || (state.current && state.staging) {
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: initial install has conflicting directories",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := removeManagedDirectory(layout, layout.current); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeManagedDirectory(layout, layout.staging); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryAborted
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverRollbackRequired(
|
||||||
|
layout appLayout,
|
||||||
|
record transactionRecord,
|
||||||
|
state directoryState,
|
||||||
|
result RecoveryResult,
|
||||||
|
) (RecoveryResult, error) {
|
||||||
|
if record.HadCurrent && !state.backup {
|
||||||
|
if !state.current {
|
||||||
|
return RecoveryResult{}, fmt.Errorf(
|
||||||
|
"%w: rollback lost current and backup",
|
||||||
|
ErrRecoveryInconsistent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := removeManagedDirectory(layout, layout.staging); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryRolledBack
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return RecoveryResult{}, err
|
||||||
|
}
|
||||||
|
result.Action = RecoveryRolledBack
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrHealthCheckRequired = errors.New("health check is required")
|
||||||
|
ErrHealthCheckFailed = errors.New("installed version failed health check")
|
||||||
|
ErrRollbackFailed = errors.New("install rollback failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthCheck func(currentPath string) error
|
||||||
|
|
||||||
|
type switchStep string
|
||||||
|
|
||||||
|
const (
|
||||||
|
stepPrepared switchStep = "prepared"
|
||||||
|
stepCurrentRenamed switchStep = "current_renamed"
|
||||||
|
stepCurrentBackedUp switchStep = "current_backed_up"
|
||||||
|
stepStagingRenamed switchStep = "staging_renamed"
|
||||||
|
stepStagingActivated switchStep = "staging_activated"
|
||||||
|
stepRollbackRequired switchStep = "rollback_required"
|
||||||
|
stepCommitted switchStep = "committed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RollbackError reports both the health failure and rollback failure.
|
||||||
|
type RollbackError struct {
|
||||||
|
Health error
|
||||||
|
Rollback error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *RollbackError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: health=%v; rollback=%v", ErrRollbackFailed, err.Health, err.Rollback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err *RollbackError) Unwrap() error {
|
||||||
|
return ErrRollbackFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switcher activates a verified staging directory and runs an injected check.
|
||||||
|
type Switcher struct {
|
||||||
|
health HealthCheck
|
||||||
|
afterStep func(switchStep) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSwitcher(health HealthCheck) *Switcher {
|
||||||
|
return &Switcher{health: health}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (switcher *Switcher) Switch(root string) error {
|
||||||
|
if switcher.health == nil {
|
||||||
|
return ErrHealthCheckRequired
|
||||||
|
}
|
||||||
|
layout, err := inspectAppLayout(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, exists, err := loadTransaction(layout); err != nil {
|
||||||
|
return err
|
||||||
|
} else if exists {
|
||||||
|
return ErrRecoveryRequired
|
||||||
|
}
|
||||||
|
state, err := inspectDirectories(layout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !state.staging {
|
||||||
|
return ErrStagingMissing
|
||||||
|
}
|
||||||
|
if state.backup {
|
||||||
|
return ErrBackupExists
|
||||||
|
}
|
||||||
|
|
||||||
|
record := newTransaction(phasePrepared, state.current)
|
||||||
|
if err := writeTransaction(layout, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepPrepared); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.current {
|
||||||
|
if err := os.Rename(layout.current, layout.backup); err != nil {
|
||||||
|
return fmt.Errorf("backup current directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepCurrentRenamed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.Phase = phaseCurrentBackedUp
|
||||||
|
if err := writeTransaction(layout, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepCurrentBackedUp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(layout.staging, layout.current); err != nil {
|
||||||
|
return fmt.Errorf("activate staging directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepStagingRenamed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record.Phase = phaseStagingActivated
|
||||||
|
if err := writeTransaction(layout, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepStagingActivated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
healthErr := switcher.health(layout.current)
|
||||||
|
if healthErr != nil {
|
||||||
|
record.Phase = phaseRollbackRequired
|
||||||
|
if err := writeTransaction(layout, record); err != nil {
|
||||||
|
return &RollbackError{Health: healthErr, Rollback: err}
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepRollbackRequired); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
|
||||||
|
return &RollbackError{Health: healthErr, Rollback: err}
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return &RollbackError{Health: healthErr, Rollback: err}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Phase = phaseCommitted
|
||||||
|
if err := writeTransaction(layout, record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := switcher.runStep(stepCommitted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.HadCurrent {
|
||||||
|
if err := removeManagedDirectory(layout, layout.backup); err != nil {
|
||||||
|
return fmt.Errorf("%w: cleanup committed backup: %v", ErrRecoveryRequired, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := removeTransaction(layout); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrRecoveryRequired, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (switcher *Switcher) runStep(step switchStep) error {
|
||||||
|
if switcher.afterStep == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return switcher.afterStep(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rollbackActivated(layout appLayout, hadCurrent bool) error {
|
||||||
|
state, err := inspectDirectories(layout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hadCurrent {
|
||||||
|
if !state.backup {
|
||||||
|
return fmt.Errorf("%w: previous current backup is missing", ErrRollbackFailed)
|
||||||
|
}
|
||||||
|
if state.current {
|
||||||
|
if state.staging {
|
||||||
|
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
|
||||||
|
}
|
||||||
|
if err := os.Rename(layout.current, layout.staging); err != nil {
|
||||||
|
return fmt.Errorf("move failed current aside: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(layout.backup, layout.current); err != nil {
|
||||||
|
if _, statErr := os.Stat(layout.staging); statErr == nil {
|
||||||
|
_ = os.Rename(layout.staging, layout.current)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("restore previous current: %w", err)
|
||||||
|
}
|
||||||
|
return removeManagedDirectory(layout, layout.staging)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.backup {
|
||||||
|
return fmt.Errorf("%w: unexpected backup without previous current", ErrRollbackFailed)
|
||||||
|
}
|
||||||
|
if state.current {
|
||||||
|
if state.staging {
|
||||||
|
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
|
||||||
|
}
|
||||||
|
if err := os.Rename(layout.current, layout.staging); err != nil {
|
||||||
|
return fmt.Errorf("move failed initial install aside: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removeManagedDirectory(layout, layout.staging)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errSimulatedCrash = errors.New("simulated crash")
|
||||||
|
|
||||||
|
func TestSwitcherCommitsHealthyUpdate(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
switcher := NewSwitcher(func(currentPath string) error {
|
||||||
|
assertVersion(t, currentPath, "new")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := switcher.Switch(root); err != nil {
|
||||||
|
t.Fatalf("Switch() error = %v", err)
|
||||||
|
}
|
||||||
|
assertVersion(t, filepath.Join(root, "current"), "new")
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, "backup"))
|
||||||
|
assertMissing(t, filepath.Join(root, transactionFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitcherRollsBackFailedUpdate(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
healthFailure := errors.New("new version did not start")
|
||||||
|
switcher := NewSwitcher(func(string) error {
|
||||||
|
return healthFailure
|
||||||
|
})
|
||||||
|
|
||||||
|
err := switcher.Switch(root)
|
||||||
|
if !errors.Is(err, ErrHealthCheckFailed) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed)
|
||||||
|
}
|
||||||
|
assertVersion(t, filepath.Join(root, "current"), "old")
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, "backup"))
|
||||||
|
assertMissing(t, filepath.Join(root, transactionFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitcherRemovesFailedInitialInstall(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "", "new")
|
||||||
|
switcher := NewSwitcher(func(string) error {
|
||||||
|
return errors.New("health failed")
|
||||||
|
})
|
||||||
|
|
||||||
|
err := switcher.Switch(root)
|
||||||
|
if !errors.Is(err, ErrHealthCheckFailed) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrHealthCheckFailed)
|
||||||
|
}
|
||||||
|
assertMissing(t, filepath.Join(root, "current"))
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, transactionFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverInterruptedSwitch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
crashStep switchStep
|
||||||
|
wantVersion string
|
||||||
|
wantAction RecoveryAction
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "after current rename before phase update",
|
||||||
|
crashStep: stepCurrentRenamed,
|
||||||
|
wantVersion: "old",
|
||||||
|
wantAction: RecoveryRolledBack,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "after staging rename before phase update",
|
||||||
|
crashStep: stepStagingRenamed,
|
||||||
|
wantVersion: "old",
|
||||||
|
wantAction: RecoveryRolledBack,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "after committed journal before cleanup",
|
||||||
|
crashStep: stepCommitted,
|
||||||
|
wantVersion: "new",
|
||||||
|
wantAction: RecoveryCommitted,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
switcher := NewSwitcher(func(string) error { return nil })
|
||||||
|
switcher.afterStep = func(step switchStep) error {
|
||||||
|
if step == test.crashStep {
|
||||||
|
return errSimulatedCrash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := switcher.Switch(root)
|
||||||
|
if !errors.Is(err, errSimulatedCrash) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Recover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Action != test.wantAction {
|
||||||
|
t.Fatalf("Action = %q, want %q", result.Action, test.wantAction)
|
||||||
|
}
|
||||||
|
assertVersion(t, filepath.Join(root, "current"), test.wantVersion)
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, "backup"))
|
||||||
|
assertMissing(t, filepath.Join(root, transactionFileName))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverReadsTransactionBackup(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
switcher := NewSwitcher(func(string) error { return nil })
|
||||||
|
switcher.afterStep = func(step switchStep) error {
|
||||||
|
if step == stepStagingRenamed {
|
||||||
|
return errSimulatedCrash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
|
||||||
|
}
|
||||||
|
if err := os.Rename(
|
||||||
|
filepath.Join(root, transactionFileName),
|
||||||
|
filepath.Join(root, transactionBackupFileName),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("move transaction to backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Recover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Action != RecoveryRolledBack {
|
||||||
|
t.Fatalf("Action = %q, want %q", result.Action, RecoveryRolledBack)
|
||||||
|
}
|
||||||
|
assertVersion(t, filepath.Join(root, "current"), "old")
|
||||||
|
assertMissing(t, filepath.Join(root, transactionBackupFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverAbortsInterruptedInitialInstall(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "", "new")
|
||||||
|
switcher := NewSwitcher(func(string) error { return nil })
|
||||||
|
switcher.afterStep = func(step switchStep) error {
|
||||||
|
if step == stepStagingRenamed {
|
||||||
|
return errSimulatedCrash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Recover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Action != RecoveryAborted {
|
||||||
|
t.Fatalf("Action = %q, want %q", result.Action, RecoveryAborted)
|
||||||
|
}
|
||||||
|
assertMissing(t, filepath.Join(root, "current"))
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, transactionFileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverCompletesInterruptedRollback(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
switcher := NewSwitcher(func(string) error {
|
||||||
|
return errors.New("health failed")
|
||||||
|
})
|
||||||
|
switcher.afterStep = func(step switchStep) error {
|
||||||
|
if step == stepRollbackRequired {
|
||||||
|
return errSimulatedCrash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := switcher.Switch(root); !errors.Is(err, errSimulatedCrash) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, errSimulatedCrash)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Recover(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Action != RecoveryRolledBack {
|
||||||
|
t.Fatalf("Action = %q, want %q", result.Action, RecoveryRolledBack)
|
||||||
|
}
|
||||||
|
assertVersion(t, filepath.Join(root, "current"), "old")
|
||||||
|
assertMissing(t, filepath.Join(root, "staging"))
|
||||||
|
assertMissing(t, filepath.Join(root, "backup"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitcherRejectsUnsafeStartingState(t *testing.T) {
|
||||||
|
t.Run("missing staging", func(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "")
|
||||||
|
err := NewSwitcher(func(string) error { return nil }).Switch(root)
|
||||||
|
if !errors.Is(err, ErrStagingMissing) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrStagingMissing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing backup", func(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
writeVersion(t, filepath.Join(root, "backup"), "stale")
|
||||||
|
err := NewSwitcher(func(string) error { return nil }).Switch(root)
|
||||||
|
if !errors.Is(err, ErrBackupExists) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrBackupExists)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("staging symlink", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(t.TempDir(), "target")
|
||||||
|
writeVersion(t, target, "new")
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, "staging")); err != nil {
|
||||||
|
t.Skipf("symlink unavailable: %v", err)
|
||||||
|
}
|
||||||
|
err := NewSwitcher(func(string) error { return nil }).Switch(root)
|
||||||
|
if !errors.Is(err, ErrUnsafeInstallLayout) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("transaction symlink", func(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "new")
|
||||||
|
target := filepath.Join(t.TempDir(), "transaction.json")
|
||||||
|
if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write symlink target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, transactionFileName)); err != nil {
|
||||||
|
t.Skipf("symlink unavailable: %v", err)
|
||||||
|
}
|
||||||
|
err := NewSwitcher(func(string) error { return nil }).Switch(root)
|
||||||
|
if !errors.Is(err, ErrUnsafeInstallLayout) {
|
||||||
|
t.Fatalf("Switch() error = %v, want %v", err, ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverRejectsOrphanBackup(t *testing.T) {
|
||||||
|
root := makeInstallRoot(t, "old", "")
|
||||||
|
writeVersion(t, filepath.Join(root, "backup"), "orphan")
|
||||||
|
|
||||||
|
_, err := Recover(root)
|
||||||
|
if !errors.Is(err, ErrRecoveryInconsistent) {
|
||||||
|
t.Fatalf("Recover() error = %v, want %v", err, ErrRecoveryInconsistent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeInstallRoot(t *testing.T, currentVersion, stagingVersion string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if currentVersion != "" {
|
||||||
|
writeVersion(t, filepath.Join(root, "current"), currentVersion)
|
||||||
|
}
|
||||||
|
if stagingVersion != "" {
|
||||||
|
writeVersion(t, filepath.Join(root, "staging"), stagingVersion)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVersion(t *testing.T, directory, version string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||||
|
t.Fatalf("create version directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "version.txt"), []byte(version), 0o600); err != nil {
|
||||||
|
t.Fatalf("write version: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertVersion(t *testing.T, directory, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join(directory, "version.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read version from %s: %v", directory, err)
|
||||||
|
}
|
||||||
|
if string(data) != want {
|
||||||
|
t.Fatalf("version in %s = %q, want %q", directory, data, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMissing(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("%s should be missing, stat error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
transactionFileName = "install-transaction.json"
|
||||||
|
transactionBackupFileName = "install-transaction.json.backup"
|
||||||
|
transactionSchemaVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type transactionPhase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
phasePrepared transactionPhase = "prepared"
|
||||||
|
phaseCurrentBackedUp transactionPhase = "current_backed_up"
|
||||||
|
phaseStagingActivated transactionPhase = "staging_activated"
|
||||||
|
phaseRollbackRequired transactionPhase = "rollback_required"
|
||||||
|
phaseCommitted transactionPhase = "committed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTransactionCorrupt = errors.New("install transaction is corrupt")
|
||||||
|
ErrRecoveryRequired = errors.New("install recovery is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
type transactionRecord struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Phase transactionPhase `json:"phase"`
|
||||||
|
HadCurrent bool `json:"had_current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTransaction(phase transactionPhase, hadCurrent bool) transactionRecord {
|
||||||
|
return transactionRecord{
|
||||||
|
SchemaVersion: transactionSchemaVersion,
|
||||||
|
Phase: phase,
|
||||||
|
HadCurrent: hadCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (record transactionRecord) validate() error {
|
||||||
|
if record.SchemaVersion != transactionSchemaVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: schema_version=%d",
|
||||||
|
ErrTransactionCorrupt,
|
||||||
|
record.SchemaVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch record.Phase {
|
||||||
|
case phasePrepared,
|
||||||
|
phaseCurrentBackedUp,
|
||||||
|
phaseStagingActivated,
|
||||||
|
phaseRollbackRequired,
|
||||||
|
phaseCommitted:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: phase=%q", ErrTransactionCorrupt, record.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTransaction(layout appLayout, record transactionRecord) error {
|
||||||
|
if err := record.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode install transaction: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := replaceFileWithBackup(
|
||||||
|
layout.root,
|
||||||
|
layout.transaction,
|
||||||
|
layout.transactionBackup,
|
||||||
|
data,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("write install transaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTransaction(layout appLayout) (transactionRecord, bool, error) {
|
||||||
|
data, err := readTransactionFile(layout.transaction)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
data, err = readTransactionFile(layout.transactionBackup)
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return transactionRecord{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return transactionRecord{}, false, fmt.Errorf("read install transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var record transactionRecord
|
||||||
|
if err := decoder.Decode(&record); err != nil {
|
||||||
|
return transactionRecord{}, false, fmt.Errorf("%w: %v", ErrTransactionCorrupt, err)
|
||||||
|
}
|
||||||
|
if err := ensureJSONEOF(decoder); err != nil {
|
||||||
|
return transactionRecord{}, false, err
|
||||||
|
}
|
||||||
|
if err := record.validate(); err != nil {
|
||||||
|
return transactionRecord{}, false, err
|
||||||
|
}
|
||||||
|
return record, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTransactionFile(path string) ([]byte, error) {
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%w: transaction is not a regular file", ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureJSONEOF(decoder *json.Decoder) error {
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("%w: trailing JSON value", ErrTransactionCorrupt)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: trailing data: %v", ErrTransactionCorrupt, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTransaction(layout appLayout) error {
|
||||||
|
if err := os.Remove(layout.transaction); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove install transaction: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(layout.transactionBackup); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove install transaction backup: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFileWithBackup(
|
||||||
|
directory string,
|
||||||
|
target string,
|
||||||
|
backup string,
|
||||||
|
data []byte,
|
||||||
|
) error {
|
||||||
|
temporary, err := os.CreateTemp(directory, ".install-transaction-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(data); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
movedTarget := false
|
||||||
|
if info, err := os.Lstat(target); err == nil {
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%w: transaction target is not a regular file", ErrUnsafeInstallLayout)
|
||||||
|
}
|
||||||
|
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(target, backup); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
movedTarget = true
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(temporaryPath, target); err != nil {
|
||||||
|
if movedTarget {
|
||||||
|
_ = os.Rename(backup, target)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if movedTarget {
|
||||||
|
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func transactionPaths(root string) (string, string) {
|
||||||
|
return filepath.Join(root, transactionFileName),
|
||||||
|
filepath.Join(root, transactionBackupFileName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package core exposes shared, UI-independent SoftBox functionality.
|
||||||
|
package core
|
||||||
|
|
||||||
|
// ProductName is the stable display name shared by both application targets.
|
||||||
|
const ProductName = "SoftBox"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProductName(t *testing.T) {
|
||||||
|
if ProductName != "SoftBox" {
|
||||||
|
t.Fatalf("ProductName = %q, want %q", ProductName, "SoftBox")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package storage persists local SoftBox JSON state without UI dependencies.
|
||||||
|
package storage
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"softbox.local/core/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
installedAppFileName = "installed-app.json"
|
||||||
|
installedAppBackupFileName = "installed-app.json.backup"
|
||||||
|
installedAppSchemaVersion = 1
|
||||||
|
transactionFileName = "install-transaction.json"
|
||||||
|
transactionBackupFileName = "install-transaction.json.backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInstalledAppInvalid = errors.New("installed app record is invalid")
|
||||||
|
ErrStorageLayoutUnsafe = errors.New("installed app storage layout is unsafe")
|
||||||
|
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstalledFile records one installed payload file.
|
||||||
|
type InstalledFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstalledApp is the local installed-app.json v1 protocol.
|
||||||
|
type InstalledApp struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Files []InstalledFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallationSnapshot contains disk facts without deriving UI status.
|
||||||
|
type InstallationSnapshot struct {
|
||||||
|
Record *InstalledApp
|
||||||
|
RecoveryPending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstalledAppStore atomically reads and writes records below apps/<id>/.
|
||||||
|
type InstalledAppStore struct {
|
||||||
|
appsRoot string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInstalledAppStore creates a store rooted at the SoftBoxData apps folder.
|
||||||
|
func NewInstalledAppStore(appsRoot string) *InstalledAppStore {
|
||||||
|
return &InstalledAppStore{appsRoot: appsRoot}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write validates and atomically replaces one installed-app.json.
|
||||||
|
func (store *InstalledAppStore) Write(record InstalledApp) error {
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
|
if err := record.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
appRoot, err := store.ensureAppRoot(record.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
document, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode installed app record: %w", err)
|
||||||
|
}
|
||||||
|
document = append(document, '\n')
|
||||||
|
return replaceInstalledAppFile(
|
||||||
|
appRoot,
|
||||||
|
filepath.Join(appRoot, installedAppFileName),
|
||||||
|
filepath.Join(appRoot, installedAppBackupFileName),
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns one installed record. found is false when neither current nor
|
||||||
|
// crash-recovery backup exists.
|
||||||
|
func (store *InstalledAppStore) Read(appID string) (record InstalledApp, found bool, err error) {
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
|
return store.readLocked(appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect reads the installed record and detects an unfinished install journal.
|
||||||
|
func (store *InstalledAppStore) Inspect(appID string) (InstallationSnapshot, error) {
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
|
record, found, err := store.readLocked(appID)
|
||||||
|
if err != nil {
|
||||||
|
return InstallationSnapshot{}, err
|
||||||
|
}
|
||||||
|
pending, err := store.recoveryPendingLocked(appID)
|
||||||
|
if err != nil {
|
||||||
|
return InstallationSnapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot := InstallationSnapshot{RecoveryPending: pending}
|
||||||
|
if found {
|
||||||
|
recordCopy := record
|
||||||
|
recordCopy.Files = append([]InstalledFile(nil), record.Files...)
|
||||||
|
snapshot.Record = &recordCopy
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *InstalledAppStore) readLocked(
|
||||||
|
appID string,
|
||||||
|
) (record InstalledApp, found bool, err error) {
|
||||||
|
appRoot, exists, err := store.inspectAppRoot(appID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return InstalledApp{}, false, err
|
||||||
|
}
|
||||||
|
target := filepath.Join(appRoot, installedAppFileName)
|
||||||
|
backup := filepath.Join(appRoot, installedAppBackupFileName)
|
||||||
|
document, err := readRegularFile(target)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
document, err = readRegularFile(backup)
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return InstalledApp{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return InstalledApp{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(document))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&record); err != nil {
|
||||||
|
return InstalledApp{}, false, fmt.Errorf("%w: decode: %v", ErrInstalledAppInvalid, err)
|
||||||
|
}
|
||||||
|
if err := ensureInstalledAppEOF(decoder); err != nil {
|
||||||
|
return InstalledApp{}, false, err
|
||||||
|
}
|
||||||
|
if err := record.validate(); err != nil {
|
||||||
|
return InstalledApp{}, false, err
|
||||||
|
}
|
||||||
|
if record.ID != appID {
|
||||||
|
return InstalledApp{}, false, fmt.Errorf(
|
||||||
|
"%w: record id %q does not match path id %q",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
record.ID,
|
||||||
|
appID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return record, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *InstalledAppStore) recoveryPendingLocked(appID string) (bool, error) {
|
||||||
|
appRoot, exists, err := store.inspectAppRoot(appID)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, name := range []string{transactionFileName, transactionBackupFileName} {
|
||||||
|
info, err := os.Lstat(filepath.Join(appRoot, name))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("inspect install transaction: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"%w: %s is not a regular file",
|
||||||
|
ErrStorageLayoutUnsafe,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *InstalledAppStore) ensureAppRoot(appID string) (string, error) {
|
||||||
|
if !appIDPattern.MatchString(appID) {
|
||||||
|
return "", fmt.Errorf("%w: invalid app id %q", ErrInstalledAppInvalid, appID)
|
||||||
|
}
|
||||||
|
if store.appsRoot == "" {
|
||||||
|
return "", fmt.Errorf("%w: empty apps root", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
absoluteRoot, err := filepath.Abs(store.appsRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", ErrStorageLayoutUnsafe, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(absoluteRoot, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("create apps root: %w", err)
|
||||||
|
}
|
||||||
|
if err := requireRealDirectory(absoluteRoot); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
appRoot := filepath.Join(absoluteRoot, appID)
|
||||||
|
if err := os.Mkdir(appRoot, 0o700); err != nil && !os.IsExist(err) {
|
||||||
|
return "", fmt.Errorf("create app root: %w", err)
|
||||||
|
}
|
||||||
|
if err := requireRealDirectory(appRoot); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return appRoot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *InstalledAppStore) inspectAppRoot(appID string) (string, bool, error) {
|
||||||
|
if !appIDPattern.MatchString(appID) {
|
||||||
|
return "", false, fmt.Errorf("%w: invalid app id %q", ErrInstalledAppInvalid, appID)
|
||||||
|
}
|
||||||
|
if store.appsRoot == "" {
|
||||||
|
return "", false, fmt.Errorf("%w: empty apps root", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
absoluteRoot, err := filepath.Abs(store.appsRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("%w: %v", ErrStorageLayoutUnsafe, err)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(absoluteRoot)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("inspect apps root: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return "", false, fmt.Errorf("%w: apps root is not a real directory", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
appRoot := filepath.Join(absoluteRoot, appID)
|
||||||
|
info, err = os.Lstat(appRoot)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("inspect app root: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return "", false, fmt.Errorf("%w: app root is not a real directory", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
return appRoot, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (record InstalledApp) validate() error {
|
||||||
|
if record.SchemaVersion != installedAppSchemaVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: schema_version=%d",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
record.SchemaVersion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !appIDPattern.MatchString(record.ID) {
|
||||||
|
return fmt.Errorf("%w: invalid id %q", ErrInstalledAppInvalid, record.ID)
|
||||||
|
}
|
||||||
|
if _, err := domain.ParseSemVer(record.Version); err != nil {
|
||||||
|
return fmt.Errorf("%w: version: %v", ErrInstalledAppInvalid, err)
|
||||||
|
}
|
||||||
|
if record.Architecture != "386" && record.Architecture != "amd64" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: architecture=%q",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
record.Architecture,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if record.Channel != "stable" {
|
||||||
|
return fmt.Errorf("%w: channel=%q", ErrInstalledAppInvalid, record.Channel)
|
||||||
|
}
|
||||||
|
if record.Files == nil {
|
||||||
|
return fmt.Errorf("%w: files must be an array", ErrInstalledAppInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPaths := make(map[string]struct{}, len(record.Files))
|
||||||
|
for index, installedFile := range record.Files {
|
||||||
|
if !validInstalledPath(installedFile.Path) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: files[%d].path=%q",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
index,
|
||||||
|
installedFile.Path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if installedFile.Size < 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: files[%d].size=%d",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
index,
|
||||||
|
installedFile.Size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !sha256Pattern.MatchString(installedFile.SHA256) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: files[%d].sha256",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
foldedPath := strings.ToLower(installedFile.Path)
|
||||||
|
if _, exists := seenPaths[foldedPath]; exists {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: duplicate file path %q",
|
||||||
|
ErrInstalledAppInvalid,
|
||||||
|
installedFile.Path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seenPaths[foldedPath] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInstalledPath(value string) bool {
|
||||||
|
if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(value)
|
||||||
|
return cleaned == value &&
|
||||||
|
cleaned != "." &&
|
||||||
|
!strings.HasPrefix(cleaned, "/") &&
|
||||||
|
cleaned != ".." &&
|
||||||
|
!strings.HasPrefix(cleaned, "../")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireRealDirectory(directory string) error {
|
||||||
|
info, err := os.Lstat(directory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect storage directory: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return fmt.Errorf("%w: %s is not a real directory", ErrStorageLayoutUnsafe, directory)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRegularFile(filePath string) ([]byte, error) {
|
||||||
|
info, err := os.Lstat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%w: %s is not a regular file", ErrStorageLayoutUnsafe, filePath)
|
||||||
|
}
|
||||||
|
return os.ReadFile(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureInstalledAppEOF(decoder *json.Decoder) error {
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("%w: trailing JSON value", ErrInstalledAppInvalid)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: trailing data: %v", ErrInstalledAppInvalid, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceInstalledAppFile(directory, target, backup string, document []byte) error {
|
||||||
|
temporary, err := os.CreateTemp(directory, ".installed-app-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create installed app temp file: %w", err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
|
||||||
|
if err := temporary.Chmod(0o600); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("protect installed app temp file: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(document); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("write installed app temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return fmt.Errorf("sync installed app temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close installed app temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
movedTarget := false
|
||||||
|
hadBackup := false
|
||||||
|
if info, err := os.Lstat(target); err == nil {
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%w: installed app target is not regular", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
if err := removeRegularBackup(backup); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(target, backup); err != nil {
|
||||||
|
return fmt.Errorf("backup installed app record: %w", err)
|
||||||
|
}
|
||||||
|
movedTarget = true
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("inspect installed app record: %w", err)
|
||||||
|
} else {
|
||||||
|
backupInfo, backupErr := os.Lstat(backup)
|
||||||
|
switch {
|
||||||
|
case backupErr == nil:
|
||||||
|
if backupInfo.Mode()&os.ModeSymlink != 0 || !backupInfo.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: installed app backup is not regular",
|
||||||
|
ErrStorageLayoutUnsafe,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hadBackup = true
|
||||||
|
case !os.IsNotExist(backupErr):
|
||||||
|
return fmt.Errorf("inspect installed app backup: %w", backupErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(temporaryPath, target); err != nil {
|
||||||
|
if movedTarget {
|
||||||
|
_ = os.Rename(backup, target)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("activate installed app record: %w", err)
|
||||||
|
}
|
||||||
|
if movedTarget || hadBackup {
|
||||||
|
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove installed app backup: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRegularBackup(backup string) error {
|
||||||
|
info, err := os.Lstat(backup)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect installed app backup: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%w: installed app backup is not regular", ErrStorageLayoutUnsafe)
|
||||||
|
}
|
||||||
|
if err := os.Remove(backup); err != nil {
|
||||||
|
return fmt.Errorf("remove installed app backup: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstalledAppStoreWriteReadAndBackupFallback(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := NewInstalledAppStore(appsRoot)
|
||||||
|
record := validInstalledApp()
|
||||||
|
|
||||||
|
if err := store.Write(record); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
loaded, found, err := store.Read(record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read() error = %v", err)
|
||||||
|
}
|
||||||
|
if !found || loaded.Version != record.Version || len(loaded.Files) != 1 {
|
||||||
|
t.Fatalf("Read() = %#v, %t", loaded, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
appRoot := filepath.Join(appsRoot, record.ID)
|
||||||
|
target := filepath.Join(appRoot, installedAppFileName)
|
||||||
|
backup := filepath.Join(appRoot, installedAppBackupFileName)
|
||||||
|
if err := os.Rename(target, backup); err != nil {
|
||||||
|
t.Fatalf("simulate interrupted replacement: %v", err)
|
||||||
|
}
|
||||||
|
loaded, found, err = store.Read(record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read(backup) error = %v", err)
|
||||||
|
}
|
||||||
|
if !found || loaded.ID != record.ID {
|
||||||
|
t.Fatalf("Read(backup) = %#v, %t", loaded, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstalledAppStoreRejectsInvalidRecords(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*InstalledApp)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid id",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.ID = "../escape"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid version",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Version = "1.02.0"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid architecture",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Architecture = "arm64"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid channel",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Channel = "beta"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsafe file path",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Files[0].Path = "../escape.exe"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid hash",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Files[0].SHA256 = "short"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case folded duplicate",
|
||||||
|
mutate: func(record *InstalledApp) {
|
||||||
|
record.Files = append(record.Files, InstalledFile{
|
||||||
|
Path: "JSONPARSER.EXE",
|
||||||
|
Size: 42,
|
||||||
|
SHA256: record.Files[0].SHA256,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
record := validInstalledApp()
|
||||||
|
test.mutate(&record)
|
||||||
|
err := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")).Write(record)
|
||||||
|
if !errors.Is(err, ErrInstalledAppInvalid) {
|
||||||
|
t.Fatalf("Write() error = %v, want %v", err, ErrInstalledAppInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstalledAppStoreRejectsUnknownFieldsAndIDMismatch(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
appRoot := filepath.Join(appsRoot, "json-parser")
|
||||||
|
if err := os.MkdirAll(appRoot, 0o700); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
record := validInstalledApp()
|
||||||
|
encoded, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &object); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
object["unknown"] = true
|
||||||
|
encoded, err = json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal(object) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(appRoot, installedAppFileName),
|
||||||
|
encoded,
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
store := NewInstalledAppStore(appsRoot)
|
||||||
|
_, _, err = store.Read("json-parser")
|
||||||
|
if !errors.Is(err, ErrInstalledAppInvalid) {
|
||||||
|
t.Fatalf("Read(unknown) error = %v, want %v", err, ErrInstalledAppInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(object, "unknown")
|
||||||
|
object["id"] = "other-app"
|
||||||
|
encoded, err = json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal(mismatch) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(appRoot, installedAppFileName),
|
||||||
|
encoded,
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile(mismatch) error = %v", err)
|
||||||
|
}
|
||||||
|
_, _, err = store.Read("json-parser")
|
||||||
|
if !errors.Is(err, ErrInstalledAppInvalid) {
|
||||||
|
t.Fatalf("Read(mismatch) error = %v, want %v", err, ErrInstalledAppInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstalledAppStoreInspectDetectsRecovery(t *testing.T) {
|
||||||
|
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||||
|
store := NewInstalledAppStore(appsRoot)
|
||||||
|
record := validInstalledApp()
|
||||||
|
if err := store.Write(record); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
transactionPath := filepath.Join(appsRoot, record.ID, transactionFileName)
|
||||||
|
if err := os.WriteFile(transactionPath, []byte(`{"schema_version":1}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(transaction) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := store.Inspect(record.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Inspect() error = %v", err)
|
||||||
|
}
|
||||||
|
if snapshot.Record == nil || !snapshot.RecoveryPending {
|
||||||
|
t.Fatalf("snapshot = %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstalledAppStoreMissingRecord(t *testing.T) {
|
||||||
|
store := NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
|
||||||
|
_, found, err := store.Read("json-parser")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read() error = %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatal("found = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInstalledApp() InstalledApp {
|
||||||
|
return InstalledApp{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
ID: "json-parser",
|
||||||
|
Version: "1.2.0",
|
||||||
|
Architecture: "amd64",
|
||||||
|
Channel: "stable",
|
||||||
|
Files: []InstalledFile{
|
||||||
|
{
|
||||||
|
Path: "JsonParser.exe",
|
||||||
|
Size: 42,
|
||||||
|
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,13 +45,13 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前项目处于:**MVP 起步(Phase 0 工程骨架尚未建立,仓库只有文档)**。
|
当前项目处于:**M2 已完成(清单验签、ZIP 安全解压、原子切换/回滚三项高风险原型已验证),准备进入 Phase 2 清单与软件列表**。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
1. Phase 0:monorepo 骨架 + 双目标编译 + 空 Gio 窗口。
|
1. 已完成 Phase 0:monorepo 骨架 + 双目标编译 + 空 Gio 窗口。
|
||||||
2. Phase 1:三大高风险原型(清单验签、ZIP 安全解压、原子切换回滚)。
|
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
|
||||||
3. Phase 2-3:清单/列表 → 下载/安装。
|
3. 下一步 Phase 2-3:清单/列表 → 下载/安装。
|
||||||
4. Phase 4-5:启动/更新/自更新 → 授权。
|
4. Phase 4-5:启动/更新/自更新 → 授权。
|
||||||
5. Phase 6:Win7 加固与双通道发布。
|
5. Phase 6:Win7 加固与双通道发布。
|
||||||
|
|
||||||
@@ -133,17 +133,17 @@ MVP 不做:
|
|||||||
|
|
||||||
## 验证命令
|
## 验证命令
|
||||||
|
|
||||||
统一启动与验证入口收敛到根目录 `./init.sh` / `./init.ps1`;T-001 完成前脚本处于未配置状态。骨架建立后的标准命令:
|
统一启动与验证入口收敛到根目录 `./init.sh` / `./init.ps1`;脚本会同步工作区依赖并运行完整 Phase 0 闸门。也可直接运行 `bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`。标准分项命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# core 测试(Linux 无头可跑)
|
# core 测试(Linux 无头可跑)
|
||||||
cd core && go vet ./... && go test -count=1 ./...
|
cd core && go vet ./... && go test -count=1 ./...
|
||||||
|
|
||||||
# 现代版构建
|
# 现代版构建
|
||||||
cd app-modern && GOOS=windows GOARCH=amd64 go build ./cmd/softbox
|
cd app-modern && GOTOOLCHAIN=go1.25.0 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-H=windowsgui" ./cmd/softbox
|
||||||
|
|
||||||
# Win7 版构建(强制 Go 1.20)
|
# Win7 版构建(强制 Go 1.20)
|
||||||
cd app-win7 && GOTOOLCHAIN=go1.20.14 GOOS=windows GOARCH=amd64 go build ./cmd/softbox
|
cd app-win7 && GOTOOLCHAIN=go1.20.14 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-H=windowsgui" ./cmd/softbox
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
| Windows API | golang.org/x/sys/windows + 动态加载(LoadLibrary) | 已定 | Win10 专属 API 动态加载并降级,不进导入表 |
|
| Windows API | golang.org/x/sys/windows + 动态加载(LoadLibrary) | 已定 | Win10 专属 API 动态加载并降级,不进导入表 |
|
||||||
| EXE 签名 | Authenticode | 已定 | 主程序与 Updater 均签名;证书采购待确认 |
|
| EXE 签名 | Authenticode | 已定 | 主程序与 Updater 均签名;证书采购待确认 |
|
||||||
| 测试 | go test(core 无头可测) + fake/stub 注入 | 已定 | domain/application 不依赖 Gio 与 Windows API |
|
| 测试 | go test(core 无头可测) + fake/stub 注入 | 已定 | domain/application 不依赖 Gio 与 Windows API |
|
||||||
| CI | 待定(Gitea Actions 或本地脚本) | 待定 | 需同时编译 modern 与 win7 双目标 |
|
| CI | Gitea Actions 模板 + 本地 Phase 0 脚本 | 部分已定 | `.gitea/workflows/phase0-build.yml` 复用本地入口;远端 runner 可用性待确认 |
|
||||||
| 静态检查 | go vet(+ 待定 golangci-lint) | 部分已定 | vet 必跑;lint 工具后续确认 |
|
| 静态检查 | go vet(+ 待定 golangci-lint) | 部分已定 | vet 必跑;lint 工具后续确认 |
|
||||||
|
|
||||||
## 二、决策记录与演进
|
## 二、决策记录与演进
|
||||||
@@ -35,17 +35,21 @@
|
|||||||
|
|
||||||
## 三、构建与运行命令
|
## 三、构建与运行命令
|
||||||
|
|
||||||
> 工程骨架由 T-001 建立;骨架完成前以下命令不可运行,`init.sh`/`init.ps1` 会主动失败提示。骨架完成后,T-001 必须把真实命令回填到本表、`00-ai-start-here.md`、`current-state.md` 和 init 脚本顶部三个变量。
|
> 工程骨架由 T-001 建立;根目录 `init.sh`/`init.ps1` 统一执行依赖同步与 core 基础验证,并打印双目标构建命令。
|
||||||
|
|
||||||
| 用途 | 命令(计划) |
|
| 用途 | 命令 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| Phase 0 完整闸门(Unix) | `bash scripts/verify_phase0.sh` |
|
||||||
|
| Phase 0 完整闸门(Windows) | `./scripts/verify_phase0.ps1` |
|
||||||
| 同步工作区依赖 | `go work sync` |
|
| 同步工作区依赖 | `go work sync` |
|
||||||
| core 测试 | `cd core && go test -count=1 ./...` |
|
| core 测试 | `cd core && go test -count=1 ./...` |
|
||||||
| core 静态检查 | `cd core && go vet ./...` |
|
| core 静态检查 | `cd core && go vet ./...` |
|
||||||
| 现代版构建 | `cd app-modern && GOOS=windows GOARCH=amd64 go build -o ../dist/SoftBox.exe ./cmd/softbox` |
|
| 现代版构建 | `cd app-modern && GOTOOLCHAIN=go1.25.0 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-H=windowsgui" -o ../dist/SoftBox.exe ./cmd/softbox` |
|
||||||
| Win7 版构建 | `cd app-win7 && GOTOOLCHAIN=go1.20.14 GOOS=windows GOARCH=amd64 go build -o ../dist/SoftBox-win7.exe ./cmd/softbox` |
|
| Win7 版构建 | `cd app-win7 && GOTOOLCHAIN=go1.20.14 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-H=windowsgui" -o ../dist/SoftBox-win7.exe ./cmd/softbox` |
|
||||||
| 本地运行(开发) | Windows 下直接 `go run ./cmd/softbox`(WSL 中构建 EXE 后到 Windows 侧运行) |
|
| 本地运行(开发) | Windows 下直接 `go run ./cmd/softbox`(WSL 中构建 EXE 后到 Windows 侧运行) |
|
||||||
|
|
||||||
|
根 `go.work` 纳入三个模块并使用 modern 所需的 Go 1.25 workspace 版本;`app-win7/go.work` 是就近的 Go 1.20 workspace,只纳入 `app-win7` 与 `core`,确保 Go 1.20.14 不需要解析 modern 模块。
|
||||||
|
|
||||||
工具链矩阵:
|
工具链矩阵:
|
||||||
|
|
||||||
| 构建目标 | 工具链 | Gio | 支持系统 | 维护策略 |
|
| 构建目标 | 工具链 | Gio | 支持系统 | 维护策略 |
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ UI 固定交互模式:
|
|||||||
|
|
||||||
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
|
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
|
||||||
|
|
||||||
|
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。
|
||||||
|
|
||||||
|
T-204 图标链路为 `Catalog icon digest + DPI → IconFetcher → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 内存字节 → 后台 DecodeIcon → UI ApplyIcon(paint.ImageOp)`。磁盘与远端都重新校验;断网只使用已验证磁盘缓存。两个详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。
|
||||||
|
|
||||||
## 三、仓库目录结构
|
## 三、仓库目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -103,7 +107,10 @@ soft_quay/
|
|||||||
|
|
||||||
- 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。
|
- 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。
|
||||||
- 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。
|
- 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。
|
||||||
|
- Catalog 正式加载顺序为 HTTPS 获取 → 验签 → 严格字段/Schema/channel 校验 → 缓存替换 → 目标过滤;签名正确但结构或目标通道不匹配的远端内容不能挤掉最后可消费缓存。
|
||||||
|
- 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、非整数数字、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。
|
||||||
- channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。
|
- channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。
|
||||||
|
- `category` 提供稳定单分类,`tags` 用于搜索和多标签展示;hidden 项从远端目录隐藏,deprecated 与不兼容项保留可见原因但不提供安装包操作。
|
||||||
|
|
||||||
### 4.2 本地动态数据(JSON + 原子写入)
|
### 4.2 本地动态数据(JSON + 原子写入)
|
||||||
|
|
||||||
@@ -128,6 +135,13 @@ soft_quay/
|
|||||||
|
|
||||||
每次状态写入使用临时文件 + 原子替换;崩溃后可识别 staging、backup 和未完成事务。
|
每次状态写入使用临时文件 + 原子替换;崩溃后可识别 staging、backup 和未完成事务。
|
||||||
|
|
||||||
|
T-202 将本地识别拆为两层:
|
||||||
|
|
||||||
|
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在。
|
||||||
|
- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
|
||||||
|
|
||||||
|
磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。
|
||||||
|
|
||||||
### 4.3 事件模型
|
### 4.3 事件模型
|
||||||
|
|
||||||
后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。
|
后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。
|
||||||
@@ -146,6 +160,12 @@ soft_quay/
|
|||||||
|
|
||||||
必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
|
必须防止:绝对路径、`../` 穿越、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
|
||||||
|
|
||||||
|
Phase 1 ZIP 原型采用“两阶段解压”:先完整预检中央目录、协议顶层、路径、类型、重复项、entrypoint 与资源上限,全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。原型默认限制见 [api.md](api.md),T-302 正式整合时复核。
|
||||||
|
|
||||||
|
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
|
||||||
|
|
||||||
|
该原型已覆盖进程在关键持久化步骤之间退出的恢复;真实断电时的目录项落盘顺序、杀毒软件/文件锁干扰仍需 T-302/T-601 在 Windows VM/真机做故障注入,当前结论不替代硬件级断电验证。
|
||||||
|
|
||||||
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。
|
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。
|
||||||
|
|
||||||
授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
|
授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
|
||||||
|
|||||||
+93
-5
@@ -29,8 +29,9 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
"category": "开发工具",
|
||||||
"tags": ["工具", "JSON"],
|
"tags": ["工具", "JSON"],
|
||||||
"icon": "sha256:...",
|
"icon": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
"homepage": "https://example.com",
|
"homepage": "https://example.com",
|
||||||
"tutorial": "https://example.com/tutorial",
|
"tutorial": "https://example.com/tutorial",
|
||||||
"min_os": "windows-7-sp1",
|
"min_os": "windows-7-sp1",
|
||||||
@@ -53,10 +54,40 @@
|
|||||||
|
|
||||||
行为要求:
|
行为要求:
|
||||||
|
|
||||||
- 网络成功:验签通过才替换本地缓存;验签失败**拒绝**,继续用最后一次验证成功的缓存。
|
- 网络成功:按“HTTPS 获取 → 验签 → Schema/字段/目标 channel 校验 → 替换缓存”处理;任一步失败都**拒绝**新内容,继续用最后一次验证且客户端可消费的缓存。
|
||||||
- 网络失败:用缓存;清单过期给提示,但保留已安装软件的启动能力。
|
- 网络失败:用缓存;清单过期给提示,但保留已安装软件的启动能力。
|
||||||
- 下架:显式 `status: deprecated | hidden`,不用名称前缀。
|
- 下架:显式 `status: deprecated | hidden`,不用名称前缀。
|
||||||
- 过滤:按 `min_os` 与 `architectures` 过滤;不兼容软件可见说明但不可下载。
|
- 分类:`category` 是单一稳定分类,`tags` 是搜索/多标签展示数据;两者都不得为空。
|
||||||
|
- 过滤:先校验 manifest `channel`,再按 `min_os`、`architectures` 和 `packages` 过滤;不兼容软件可见说明但不可下载。
|
||||||
|
- 状态:`active` 可安装;`deprecated` 可见但不可新装/更新;`hidden` 不进入目录结果,但本地已安装记录仍由本地状态模块保留。
|
||||||
|
- URL:Catalog、package、homepage、tutorial 只接受无用户信息、无 fragment 的绝对 HTTPS URL。
|
||||||
|
- 架构:MVP Schema 接受 `386` / `amd64`;一个 app 的 `architectures` 必须与 `packages` 键一一对应。当前两个客户端发布目标仍是 amd64。
|
||||||
|
- 系统版本:`min_os` v1 只允许 `windows-7-sp1`、`windows-10`、`windows-11`;更高系统可消费更低最低版本的软件。
|
||||||
|
- Schema:客户端协议文件为 `schemas/manifest.schema.json`;标准包元数据为 `schemas/app.schema.json`。运行时还会执行 JSON Schema 难以表达的重复 ID、架构映射和 channel 目标一致性检查。
|
||||||
|
|
||||||
|
### 1.1 Catalog 签名域
|
||||||
|
|
||||||
|
客户端采用以下签名域,供发布器实现对齐:
|
||||||
|
|
||||||
|
1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字直接拒绝。
|
||||||
|
2. 读取顶层 `signature`(标准 Base64 编码的 64 字节 Ed25519 签名),然后从对象中移除该字段。
|
||||||
|
3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字仅允许 JSON 整数并保持其合法十进制写法;不保留无意义空白。
|
||||||
|
4. Ed25519 直接签名/验证上述规范 JSON 字节。
|
||||||
|
|
||||||
|
T-201 已把该签名域接入正式客户端加载链路并用客户端测试向量覆盖。`softbox-catalog` 发布端仍必须补跨实现向量测试;密钥 ID/轮换字段尚未定稿,在单公钥协议升级前不得另造签名域。
|
||||||
|
|
||||||
|
### 1.2 图标内容引用与本地缓存
|
||||||
|
|
||||||
|
Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 URL。最终分发字段仍由发布端定稿;客户端通过注入的 IconFetcher 把引用解析为图标字节,不得在 UI 中拼接或猜测 URL。
|
||||||
|
|
||||||
|
客户端缓存合约:
|
||||||
|
|
||||||
|
1. 请求键为 `(icon digest, DPI)`;DPI 接受 48~768 的整数值。
|
||||||
|
2. 加载顺序为内存 → 磁盘 → 注入 Fetcher;磁盘文件名为 `<digest>-<dpi>.icon`。
|
||||||
|
3. 远端和磁盘字节都必须复核 SHA-256,并通过图片解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。
|
||||||
|
4. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。
|
||||||
|
5. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。
|
||||||
|
6. 后台完成 `IconCache.Load` 与 `DecodeIcon` 后调用 Gio `ApplyIcon`;Layout 只复用内存 `paint.ImageOp`。
|
||||||
|
|
||||||
## 2. 标准软件包协议 v1(ZIP)
|
## 2. 标准软件包协议 v1(ZIP)
|
||||||
|
|
||||||
@@ -108,10 +139,67 @@ json-parser_1.4.2_windows_amd64.zip
|
|||||||
|
|
||||||
绝对路径;`../` 穿越;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。
|
绝对路径;`../` 穿越;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。
|
||||||
|
|
||||||
|
T-102 Phase 1 原型进一步固定:
|
||||||
|
|
||||||
|
- ZIP 名称只接受 UTF-8 `/` 分隔的规范相对路径;拒绝反斜杠、盘符、冒号/NTFS ADS、NUL、`.`/`..` 和大小写折叠后的重复输出路径。
|
||||||
|
- 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。
|
||||||
|
- 拒绝符号链接、设备/管道等特殊文件和加密条目。
|
||||||
|
- 原型默认上限:10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。T-302 按真实包体分布复核后再冻结。
|
||||||
|
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
|
||||||
|
|
||||||
### 2.4 安装记录 installed-app.json(本地)
|
### 2.4 安装记录 installed-app.json(本地)
|
||||||
|
|
||||||
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
记录实际安装的软件 ID、版本、架构、channel 和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "json-parser",
|
||||||
|
"version": "1.4.2",
|
||||||
|
"architecture": "amd64",
|
||||||
|
"channel": "stable",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "JsonParser.exe",
|
||||||
|
"size": 3456789,
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、安全相对路径以外的文件名、重复路径和非法 SHA-256 均拒绝。
|
||||||
|
- `files` 必须是数组;v1 可以为空,完整文件清单由 T-302 安装整合时从已验证包写入。
|
||||||
|
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
|
||||||
|
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
|
||||||
|
|
||||||
|
### 2.4.1 本地可见状态推导
|
||||||
|
|
||||||
|
状态事实由后台/存储层预先收集,UI Layout 不扫描磁盘。单一可见状态按以下优先级推导:
|
||||||
|
|
||||||
|
1. 存在 `install-transaction.json` 或其 backup → `rollback_pending`。
|
||||||
|
2. 存在活跃任务状态 → `queued|downloading|verifying|extracting|installing|failed`。
|
||||||
|
3. 已检测到进程运行 → `running`。
|
||||||
|
4. Catalog 判定不可兼容 → `incompatible`。
|
||||||
|
5. 无 installed-app.json → `not_installed`。
|
||||||
|
6. 本地 SemVer 低于 Catalog → `update_available`;否则 → `installed`。Catalog 中已隐藏/下架且无可比较版本时,保留 `installed`。
|
||||||
|
|
||||||
|
### 2.5 安装切换事务(Phase 1 原型)
|
||||||
|
|
||||||
|
`apps/<id>/install-transaction.json` 用于断电恢复:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"phase": "staging_activated",
|
||||||
|
"had_current": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollback_required`、`committed`。日志使用临时文件 + 同目录 backup 原子替换;恢复时同时检查日志与 current/staging/backup 实际状态。未完成健康检查的 current 不视为可信:有旧版时恢复 backup,首次安装则撤销 current。
|
||||||
|
|
||||||
## 3. 许可证(服务端签发 → 本地离线验证)
|
## 3. 许可证(服务端签发 → 本地离线验证)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -190,8 +278,8 @@ V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare
|
|||||||
|
|
||||||
## 待实现时确认
|
## 待实现时确认
|
||||||
|
|
||||||
- 清单签名封装格式(签名域、密钥轮换字段)定稿后同步 `schemas/`。
|
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
|
||||||
- 错误码完整枚举表。
|
- 错误码完整枚举表。
|
||||||
- 图标资源的分发方式(内嵌哈希 vs 独立 URL)。
|
- 图标资源从内容哈希到下载位置/分辨率变体的发布端映射格式。
|
||||||
- 撤销名单的结构与宽限期时长。
|
- 撤销名单的结构与宽限期时长。
|
||||||
- machine_hash 的标识来源清单与加权算法(平台层内部文档)。
|
- machine_hash 的标识来源清单与加权算法(平台层内部文档)。
|
||||||
|
|||||||
+23
-21
@@ -13,45 +13,47 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-16
|
- 日期:2026-07-16
|
||||||
- 阶段:MVP 起步(Phase 0 未开始,仓库只有 harness 文档,无任何 Go 代码)
|
- 阶段:Phase 2 已完成(T-201~T-204);下一步 Phase 3 的 T-301 下载队列
|
||||||
- 技术栈:已定稿于 `03-tech-stack.md`(Go + Gio,双工具链矩阵),尚未落地
|
- 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入
|
||||||
- 生产代码:无;`core/`、`app-modern/`、`app-win7/`、`schemas/`、`testdata/` 均待 T-001 创建
|
- 生产代码:core 已有 Catalog/本地状态/存储、无 IO 软件列表模型与按 digest+DPI 的可信图标缓存;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标
|
||||||
- 测试:无
|
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、列表筛选和图标内存/磁盘/离线/损坏恢复;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;ZIP/安装恢复矩阵保持通过
|
||||||
- 数据:无;Catalog 清单与测试样例待建
|
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
|
||||||
- 标准启动路径:`./init.sh` / `./init.ps1`(**尚未配置**,顶部三个命令仍为占位符,运行会主动失败提示——T-001 负责替换)
|
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||||
- 标准验证路径:同上,未配置
|
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||||
- 当前 blocker:无;下一步即领取 T-001
|
- 当前 blocker:无;下一步按路线图落成并领取 T-301
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||||
| `docs/tasks/` | 已有 | 任务目录;T-001 已落成任务文件待领取 |
|
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 Phase 2 任务均已完成;T-301 待按路线图落成 |
|
||||||
| `scripts/` | 已有 | harness 治理脚本(validate_agent_context 等);构建脚本待建 |
|
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||||
| `core/` | 待建 | 共享业务核心(T-001) |
|
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、列表模型、图标缓存与 Phase 1 安装安全原型 |
|
||||||
| `app-modern/` | 待建 | 现代版模块(T-001) |
|
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和内存图标 |
|
||||||
| `app-win7/` | 待建 | Win7 遗留版模块(T-001) |
|
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和内存图标 |
|
||||||
| `schemas/` | 待建 | 协议 JSON Schema(T-201) |
|
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json` 与 `installed-app.schema.json` |
|
||||||
| `testdata/` | 待建 | 假数据测试样例(T-101 起) |
|
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
|
|
||||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||||
|
|
||||||
- 已完成:无。
|
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 下一个可领取任务:`T-001 初始化 monorepo 骨架`(`docs/tasks/T-001.md`)。
|
- 下一个可领取任务:按路线图落成并领取 `T-301 下载队列`。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 目前仅文档治理检查可运行:
|
# 统一依赖同步 + 完整 Phase 0 验证:
|
||||||
python3 scripts/validate_agent_context.py
|
./init.sh
|
||||||
python3 scripts/validate_harness_governance.py
|
# Windows PowerShell 使用 ./init.ps1
|
||||||
|
|
||||||
# Go 构建 / 测试:骨架未建立,暂不可运行(见 T-001)
|
# 直接运行验证闸门:
|
||||||
|
bash scripts/verify_phase0.sh
|
||||||
|
# Windows PowerShell 使用 ./scripts/verify_phase0.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开始编码前检查
|
## 开始编码前检查
|
||||||
|
|||||||
+17
-2
@@ -23,8 +23,8 @@
|
|||||||
|
|
||||||
| 视图 | 职责 | MVP |
|
| 视图 | 职责 | MVP |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 软件列表(主视图) | 全部/已安装/可更新/下载中/最近使用切换;搜索、分类、多标签筛选;滚动位置稳定 | P0 |
|
| 软件列表(主视图) | T-203 已实现全部/已安装/可更新切换、名称/ID/tag 即时搜索、单分类筛选、稳定滚动与明确空状态;下载中/最近使用和多标签复选随对应用例后续接入 | P0 |
|
||||||
| 软件详情(弹层或右栏) | 版本、简介、教程链接、授权状态;下载/更新/启动/取消操作 | P0 |
|
| 软件详情(弹层或右栏) | T-204 已实现右栏、关闭、版本/分类/简介/tags/状态/不可用原因/教程/主页展示与内存图标;真实操作和授权事实随对应模块接入 | P0 |
|
||||||
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
|
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
|
||||||
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
|
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
|
||||||
| 授权 | 许可证导入、已授权软件列表、machine 信息、换绑/申诉入口 | P0 |
|
| 授权 | 许可证导入、已授权软件列表、machine 信息、换绑/申诉入口 | P0 |
|
||||||
@@ -53,6 +53,21 @@
|
|||||||
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
|
- 未验证/不兼容的软件显示原因,操作按钮禁用,不静默失败。
|
||||||
- Win7 版允许减少动画、阴影和高成本渲染,但交互语义与现代版一致,并明显展示 Legacy 标识。
|
- Win7 版允许减少动画、阴影和高成本渲染,但交互语义与现代版一致,并明显展示 Legacy 标识。
|
||||||
|
|
||||||
|
T-203 已落地的列表交互约束:
|
||||||
|
|
||||||
|
- 共享 `core/application.CatalogListModel` 只消费预先准备的内存快照;搜索覆盖名称、稳定 ID 和 tags,分类与 all/installed/updates 可组合。
|
||||||
|
- modern/win7 都使用惰性 `layout.List`;500 项有限 viewport 测试必须证明不会布局全部行。
|
||||||
|
- Editor、视图按钮、分类按钮、软件行的键盘顺序与视觉顺序一致;搜索和软件行有可见焦点反馈,所有按钮最小高度 44dp。
|
||||||
|
- 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。
|
||||||
|
- 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。
|
||||||
|
|
||||||
|
T-204 已落地的详情/图标约束:
|
||||||
|
|
||||||
|
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
||||||
|
- modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。
|
||||||
|
- 后台把已验证图标解码为 `image.Image` 后调用 `ApplyIcon`;该方法预建 `paint.ImageOp`,列表与详情 Layout 只绘制内存操作。
|
||||||
|
- 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。
|
||||||
|
|
||||||
## 导航规则
|
## 导航规则
|
||||||
|
|
||||||
- 列表 → 详情:点击软件项;详情保留返回/关闭。
|
- 列表 → 详情:点击软件项;详情保留返回/关闭。
|
||||||
|
|||||||
+12
-4
@@ -3,12 +3,12 @@ id: T-001
|
|||||||
title: 初始化 monorepo 骨架(core / app-modern / app-win7 + go.work)
|
title: 初始化 monorepo 骨架(core / app-modern / app-win7 + go.work)
|
||||||
phase: 0
|
phase: 0
|
||||||
deps: []
|
deps: []
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-16
|
created: 2026-07-16
|
||||||
issue: null
|
issue: null
|
||||||
context_ref: null
|
context_ref: 0184595cccc3a516628fc6077c558b84606ad806
|
||||||
claim_branch: null
|
claim_branch: null
|
||||||
work_branch: null
|
work_branch: agent/codex/T-001
|
||||||
write_paths:
|
write_paths:
|
||||||
- docs/tasks/T-001.md
|
- docs/tasks/T-001.md
|
||||||
- go.work
|
- go.work
|
||||||
@@ -60,4 +60,12 @@ write_paths:
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
(做完在此记录:改了哪些文件、跑的验证命令与结果、阻塞、关键决策。)
|
- 2026-07-16:创建根 `go.work`、`core` / `app-modern` / `app-win7` 三个模块、最小命令入口、core 真实单元测试及预留包目录。
|
||||||
|
- 2026-07-16:根 workspace 使用 Go 1.25 并纳入三个模块;额外创建 `app-win7/go.work`(Go 1.20,仅含 win7 + core),使 Go 1.20.14 不必解析 modern 模块,同时保持 app → core 的依赖方向。
|
||||||
|
- 2026-07-16:替换 `init.sh` / `init.ps1` 占位命令并同步 `00-ai-start-here.md`、`03-tech-stack.md`、`current-state.md`。
|
||||||
|
- 验证通过:`go work sync`。
|
||||||
|
- 验证通过:`cd core && go vet ./... && go test -count=1 ./...`。
|
||||||
|
- 验证通过:`cd app-modern && GOTOOLCHAIN=go1.25.0 GOOS=windows GOARCH=amd64 go build -o ../dist/SoftBox.exe ./cmd/softbox`。
|
||||||
|
- 验证通过:`cd app-win7 && GOTOOLCHAIN=go1.20.14 GOOS=windows GOARCH=amd64 go build -o ../dist/SoftBox-win7.exe ./cmd/softbox`。本机镜像下载 toolchain 失败后,使用 Go 官方 `golang.org/dl/go1.20.14` 下载并验证同一版本工具链。
|
||||||
|
- 验证通过:`./init.ps1` 与 Git Bash `./init.sh`。
|
||||||
|
- 验证通过:`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
id: T-002
|
||||||
|
title: 建立 domain 状态模型与 application 事件总线
|
||||||
|
phase: 0
|
||||||
|
deps: [T-001]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: cf9fc01c68476ba3ee4f69525958f2e62ba2d6ce
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-002
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-002.md
|
||||||
|
- core/domain/
|
||||||
|
- core/application/
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-001 只建立了可编译骨架。后续清单、下载、安装、启动和 UI 都需要共享、稳定的软件状态模型,后台任务也需要通过统一事件通道与 UI 解耦。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/domain` 定义 `docs/04-architecture.md` 已列出的 12 个软件状态及显式迁移表。
|
||||||
|
2. 提供状态合法性与迁移校验 API,非法迁移返回包含起止状态的稳定错误。
|
||||||
|
3. 在 `core/application` 定义 `docs/api.md` 已列出的事件类型与通用 `Event` 信封。
|
||||||
|
4. 建立带缓冲事件通道、上下文取消与关闭信号的 `Runtime` 骨架;关闭后拒绝新事件。
|
||||||
|
5. 使用表驱动测试覆盖合法/非法状态迁移、未知状态、事件发布、取消与关闭。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 软件状态枚举与架构文档一致,不新增未定义状态。
|
||||||
|
- 表驱动测试至少覆盖 `not_installed → installed`、`running → downloading` 等非法迁移。
|
||||||
|
- application 事件类型与 `docs/api.md` 一致;未知类型不能发布。
|
||||||
|
- runtime 可以发布/消费事件,阻塞发布可由 context 取消,关闭后返回稳定错误。
|
||||||
|
- `cd core && go vet ./... && go test -count=1 ./...` 通过。
|
||||||
|
- modern 与 win7 两个目标继续可编译。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现清单、下载、安装、授权等具体用例。
|
||||||
|
- 不引入 Gio、Windows API 或第三方依赖。
|
||||||
|
- 不修改 app UI 与平台层。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-002` 分支串行执行。新增写路径前必须确认不越过本文件声明范围。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/domain/status.go` 落地架构文档中的 12 个软件状态、显式迁移表、状态合法性与迁移校验错误。
|
||||||
|
- 2026-07-16:表驱动测试覆盖正常下载/安装/运行/回滚路径,并覆盖跳过安装链、运行中下载、未知状态和同状态迁移等非法情况。
|
||||||
|
- 2026-07-16:在 `core/application` 落地 `docs/api.md` 已定义的 12 个事件类型、通用 Event 信封及支持缓冲、context 取消、幂等关闭的 Runtime 事件总线骨架。
|
||||||
|
- 验证通过:`cd core && go vet ./... && go test -count=1 ./...`。
|
||||||
|
- 验证通过:Go 1.20.14 + `GOWORK=off` 执行 core vet/test,确认共享核心兼容基线。
|
||||||
|
- 验证通过:modern Go 1.25.0 与 win7 Go 1.20.14 两个 Windows amd64 目标重新编译。
|
||||||
|
- 验证通过:`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py`。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
id: T-003
|
||||||
|
title: 建立 Gio 空窗口 AppShell 与平台层 stub
|
||||||
|
phase: 0
|
||||||
|
deps: [T-001]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 0e20dd76b215015030c184436fb216f34e9b27e2
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-003
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-003.md
|
||||||
|
- app-modern/cmd/softbox/
|
||||||
|
- app-modern/ui/gio/
|
||||||
|
- app-modern/platform/windows/
|
||||||
|
- app-win7/cmd/softbox/
|
||||||
|
- app-win7/ui/gio/
|
||||||
|
- app-win7/platform/windows/
|
||||||
|
- scripts/check_core_boundaries.py
|
||||||
|
- go.work.sum
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
两个应用模块当前只有控制台占位入口,尚不能打开桌面窗口;平台层也只有空包。需要建立 modern 与 Win7 各自适配 Gio 版本的最小 AppShell,并为后续 Windows 能力注入提供可在非 Windows 环境编译的接口/stub。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. modern 使用 Gio v0.10.1 API,win7 使用 Gio v0.6.0 API,各自建立窗口事件循环和 `ui/gio.AppShell`。
|
||||||
|
2. AppShell 只展示高对比标题、版本标识与占位说明,使用 8dp 间距节奏,不做业务 IO、动画或交互。
|
||||||
|
3. 两个 `platform/windows` 包定义最小 `Platform` 接口,分别提供 Windows 实现与 `!windows` stub。
|
||||||
|
4. 新增离线边界检查脚本,通过 `go list -json` 拒绝 core 直接 import Gio、Windows 专属包或 SQLite。
|
||||||
|
5. 生成并提交两个 app 模块所需的依赖校验文件。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- modern 与 win7 两个 Windows amd64 目标都能编译为 GUI EXE。
|
||||||
|
- 两个窗口均有 AppShell 骨架;Win7 版明确显示 Legacy 标识。
|
||||||
|
- Gio 代码只位于 `ui/gio` 与 `cmd` 装配层;Layout 不包含磁盘、网络或哈希操作。
|
||||||
|
- `platform/windows` 在 Windows 有实现,在非 Windows 有 stub,包级测试可在当前环境运行。
|
||||||
|
- `python scripts/check_core_boundaries.py` 与 `--self-test` 通过,并能检测禁止依赖前缀。
|
||||||
|
- core vet/test 与治理检查继续通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现软件列表、搜索、下载队列或其他业务页面。
|
||||||
|
- 不让 modern 与 win7 共享 Gio 控件代码;只保持布局语义一致。
|
||||||
|
- 不新增 Windows API 调用或第三方依赖。
|
||||||
|
- 不引入装饰动画、图标或品牌资产。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-003` 分支串行执行。UI 采用 `ui-ux-pro-max` 的工具型桌面应用原则,但以 Gio/Windows 任务边界和仓库架构为最高约束。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:modern 使用 Gio v0.10.1、win7 使用 Gio v0.6.0 建立各自窗口事件循环与 `AppShell`;两个版本共享相同布局语义,Win7 标题和页脚明确显示 Legacy。
|
||||||
|
- 2026-07-16:依据 `ui-ux-pro-max` 的工具型产品建议,采用高对比语义色、16sp 正文、8dp 间距节奏与 44dp 交互基线;本任务没有交互控件,因此不添加动画、图标或装饰效果。
|
||||||
|
- 2026-07-16:两个 `platform/windows` 包建立 `Platform` 接口、Windows 实现与 `!windows` stub,并添加包级契约测试。
|
||||||
|
- 2026-07-16:新增 `scripts/check_core_boundaries.py`,通过 `go list -json` 检查 core 的普通/测试 import,拒绝 Gio、Windows、SQLite 与 app 反向依赖;`--self-test` 验证前缀匹配器。
|
||||||
|
- 2026-07-16:`go mod tidy` 会忽略 workspace 本地替换并尝试下载 `softbox.local/core`;为避免把 `replace ../core` 写入 go.mod,保留 go.work 作为唯一替换源,由实际 test/build 生成 `go.work.sum`。
|
||||||
|
- 验证通过:modern Go 1.25.0 执行 `go vet ./...`、UI/platform 测试与 Windows amd64 构建。
|
||||||
|
- 验证通过:win7 Go 1.20.14 执行 `go vet ./...`、UI/platform 测试与 Windows amd64 构建。
|
||||||
|
- 验证通过:两个 EXE 在当前 Windows 环境启动后保持运行,随后由 smoke 脚本关闭。
|
||||||
|
- 验证通过:`python scripts/check_core_boundaries.py --self-test`、`python scripts/check_core_boundaries.py`、core vet/test 与治理检查。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
id: T-004
|
||||||
|
title: 建立 Phase 0 双目标编译与依赖版本闸门
|
||||||
|
phase: 0
|
||||||
|
deps: [T-001]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 6f920eb4578c17c0962b6e593177cd2a3ca57f1b
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-004
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-004.md
|
||||||
|
- scripts/check_go_versions.py
|
||||||
|
- scripts/verify_phase0.ps1
|
||||||
|
- scripts/verify_phase0.sh
|
||||||
|
- .gitea/workflows/phase0-build.yml
|
||||||
|
- init.ps1
|
||||||
|
- init.sh
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Phase 0 已有可编译骨架、core 状态/事件模型和两个 Gio 窗口,但验证命令仍分散在文档与人工操作中。需要一个可重复执行的本地/CI 闸门,防止 modern、Win7 或 core 兼容性在后续任务中悄然破坏。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 新增 Windows PowerShell 与 Unix shell 两个等价入口,统一运行治理检查、core 边界检查、Go 1.20 core vet/test、modern UI/platform 测试与构建、win7 UI/platform 测试与构建。
|
||||||
|
2. 新增 Python 标准库依赖版本检查:校验 go/workspace 指令与 Gio 锁定版本,并用 Go 1.20.14 的 `go list -m -json all` 拒绝 core/win7 依赖声明 Go 1.21+。
|
||||||
|
3. Windows 构建统一使用 `CGO_ENABLED=0`、`-trimpath` 与 Windows GUI subsystem linker flag。
|
||||||
|
4. 根 init 脚本的 VERIFY 命令改为调用 Phase 0 闸门。
|
||||||
|
5. 新增 Gitea Actions workflow 模板,在 push/PR 使用同一 Unix 验证入口;runner 未启用时以本地同命令为验收证据。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- `scripts/verify_phase0.ps1` 在当前 Windows 环境全绿。
|
||||||
|
- Git Bash `scripts/verify_phase0.sh` 在当前环境全绿。
|
||||||
|
- 闸门包含 core vet/test、core 边界、modern 构建、win7 Go 1.20.14 构建、UI/platform 测试和治理检查。
|
||||||
|
- 版本检查能拒绝 core/win7 的 Go 1.21+ 模块,并验证 Gio v0.10.1/v0.6.0 锁定。
|
||||||
|
- `.gitea/workflows/phase0-build.yml` 只读权限、关闭 checkout 凭据持久化,调用同一本地入口。
|
||||||
|
- `./init.ps1` 与 Git Bash `./init.sh` 通过新的完整 VERIFY 路径。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不启用或配置远端 runner,不宣称远端 CI 已运行。
|
||||||
|
- 不升级 Go、Gio 或其他依赖版本。
|
||||||
|
- 不实现 Phase 1 业务功能。
|
||||||
|
- 不写入密钥、token 或真实发布配置。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-004` 分支串行执行。CI 与本地必须复用同一脚本,不得维护两套不同验证逻辑。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:新增 `scripts/verify_phase0.ps1` 与 `scripts/verify_phase0.sh`,统一运行治理、core 边界、Go 1.20 core vet/test、modern/win7 adapter 测试与双目标 GUI 构建。
|
||||||
|
- 2026-07-16:新增 `scripts/check_go_versions.py`,校验 root/core/modern/win7 的 go 指令与 Gio 锁定版本,并强制使用 Go 1.20.14 扫描 core/win7 完整模块图。
|
||||||
|
- 2026-07-16:版本检查确认 13 个模块记录均声明 Go 1.20 或更低;内置 self-check 明确接受 1.20、拒绝 1.21。
|
||||||
|
- 2026-07-16:Windows 构建统一使用 `CGO_ENABLED=0`、`-trimpath`、`-H=windowsgui`;根 init 脚本 VERIFY 已切换到完整 Phase 0 闸门。
|
||||||
|
- 2026-07-16:新增 `.gitea/workflows/phase0-build.yml`,使用只读权限、禁用 checkout 凭据持久化并复用 Unix 本地入口。远端 runner 未启用/未验证,不宣称远端 CI 已跑绿。
|
||||||
|
- 验证通过:`./scripts/verify_phase0.ps1`。
|
||||||
|
- 验证通过:Git Bash `bash scripts/verify_phase0.sh`。
|
||||||
|
- 验证通过:`./init.ps1` 与 Git Bash `./init.sh`,均执行完整 Phase 0 闸门。
|
||||||
|
- 验证结果:core Go 1.20.14 vet/test、modern Go 1.25.0 Windows amd64 GUI 构建、win7 Go 1.20.14 Windows amd64 GUI 构建、两个 adapter 测试、治理与边界检查全部通过。
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
id: T-101
|
||||||
|
title: 签名 Catalog 验签与缓存回退原型
|
||||||
|
phase: 1
|
||||||
|
deps: [T-002]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 45c242ecec153aa2d409a46883da9845503f185c
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-101
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-101.md
|
||||||
|
- core/catalog/
|
||||||
|
- testdata/catalog/
|
||||||
|
- testdata/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Catalog 是后续下载与执行链路的信任根。当前协议只约定 Ed25519 和顶层 `signature`,尚未验证签名域、JSON 歧义处理、缓存替换与断网回退行为。若这里错误,篡改清单可能进入安装链路。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/catalog` 建立 Ed25519 verifier,签名域为“移除顶层 `signature` 后的受限规范 JSON”。
|
||||||
|
2. 解析时拒绝重复字段、尾随 JSON、非对象根、非整数数字、缺失/非法长度签名,避免不同解析器对同一文档产生歧义。
|
||||||
|
3. 建立 Fetcher/Cache 接口与 Loader:远端验签成功后才写缓存;远端获取或验签失败时只使用再次验签通过的缓存。
|
||||||
|
4. 提供文件缓存原型,使用同目录临时文件 + current/backup 改名,并能读取中断后遗留的 backup。
|
||||||
|
5. 在 `testdata/catalog` 放置假数据与恶意样例;测试使用明确标注的专用测试密钥,不提交生产私钥。
|
||||||
|
6. 将签名域与原型结论同步到 `docs/api.md`、`docs/04-architecture.md`。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 合法测试清单验签通过。
|
||||||
|
- 篡改内容复用合法签名、伪造签名、重复字段、浮点/指数数字均被拒绝。
|
||||||
|
- 远端验签失败不会覆盖最后有效缓存;断网可返回再次验签通过的缓存。
|
||||||
|
- 缓存自身被篡改时拒绝,不得当作离线可信数据。
|
||||||
|
- 文件缓存更新与 backup 恢复测试通过。
|
||||||
|
- `cd core && go vet ./... && go test -count=1 ./...` 通过,Phase 0 双目标闸门继续通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现 HTTPS 客户端、Catalog 字段 Schema、OS/架构过滤或 UI 接入(T-201)。
|
||||||
|
- 不定稿密钥轮换、撤销名单和 package 独立签名域。
|
||||||
|
- 不引入第三方 JSON canonicalization 或加密依赖。
|
||||||
|
- 不提交生产私钥、真实 URL 或真实签名。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-101` 分支串行执行。签名格式结论标记为 Phase 1 原型,T-201 正式接入时再与发布端共同冻结。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/catalog` 建立受限 JSON 解析/规范化与 Ed25519 verifier;拒绝重复字段、尾随值、无效 UTF-8、非对象根、非整数数字和非法签名编码/长度。
|
||||||
|
- 2026-07-16:签名域确定为移除顶层 `signature` 后的受限规范 JSON;对象键排序、数组保序、字符串标准 JSON 转义、数字仅允许整数。该结论已同步 `docs/api.md` 与 `docs/04-architecture.md`,并明确为 T-201 前的 Phase 1 原型。
|
||||||
|
- 2026-07-16:建立 Fetcher/Cache/Loader;远端只有验签成功才写缓存,获取失败或验签失败时只返回再次验签成功的缓存;缓存写失败作为非致命 warning 暴露。
|
||||||
|
- 2026-07-16:建立 FileCache 临时文件 + current/backup 切换原型,并支持 current 缺失时读取中断遗留 backup。
|
||||||
|
- 2026-07-16:新增公开虚构的 Catalog testdata;测试密钥由测试代码中的明确测试 seed 运行时派生,仓库不含生产私钥。
|
||||||
|
- 定向测试通过:合法、篡改、伪造、重复字段、浮点、指数、缺签名、尾随 JSON、无效 UTF-8、断网回退、坏缓存、缓存写失败与中断 backup 恢复。
|
||||||
|
- 验证通过:Go 1.20.14 `go vet ./...`、`go test -count=1 ./...`。
|
||||||
|
- 验证通过:`./scripts/verify_phase0.ps1`,包含 core 边界/版本检查和 modern/win7 双目标构建。
|
||||||
|
- 验证通过:`python scripts/validate_harness_governance.py`。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
id: T-102
|
||||||
|
title: ZIP 软件包安全解压原型
|
||||||
|
phase: 1
|
||||||
|
deps: [T-002]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 0d6ed05d7e69f4a8e2a20e93051e1fe48a868940
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-102
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-102.md
|
||||||
|
- core/installer/
|
||||||
|
- testdata/zip/
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
软件包最终会把远端 ZIP 内容写入 staging 并切换为可执行程序。仅依赖 `archive/zip` 默认行为无法防止路径穿越、Windows 盘符/ADS、符号链接、重复覆盖和 zip bomb,必须在写磁盘前完整预检。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/installer` 建立 `Extractor`,调用者显式提供条目数、总解压体积和压缩比限制。
|
||||||
|
2. 两阶段处理:先验证整个中央目录和 entrypoint,全部通过后才创建新的 staging 目录并解压 `payload/` 内容。
|
||||||
|
3. 拒绝绝对路径、盘符/冒号、反斜杠、空/NUL/非规范路径、`..`、大小写折叠重复路径、符号链接、特殊文件、加密条目和协议外顶层条目。
|
||||||
|
4. 对每个条目及全包执行压缩比检查,实际复制时再次用剩余总量限制读取,失败清理本次创建的 staging。
|
||||||
|
5. entrypoint 必须是 payload 内安全相对路径,并对应 ZIP 中的普通文件。
|
||||||
|
6. 使用表驱动测试在运行时生成攻击 ZIP;`testdata/zip` 记录样例策略与限制结论。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 合法包只把 `payload/` 内容解压到新 staging,不复制 `app.json` / `files.json`。
|
||||||
|
- 绝对路径、`../`、反斜杠穿越、盘符/ADS、符号链接、特殊文件和重复路径全部拒绝。
|
||||||
|
- 条目数、总解压体积、单条/总体压缩比超限全部拒绝。
|
||||||
|
- entrypoint 绝对/穿越/payload 外逃或不存在全部拒绝。
|
||||||
|
- 任一预检/复制失败不留下可被误用的 staging 目录。
|
||||||
|
- Go 1.20 core vet/test 和完整双目标闸门通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现 SHA-256、Catalog/package 身份比对、app.json/files.json Schema(T-302/T-201)。
|
||||||
|
- 不执行任何包内脚本,不处理 prerequisites。
|
||||||
|
- 不切换 current/backup(T-103)。
|
||||||
|
- 默认限制是 Phase 1 原型值,T-302 正式整合时按真实包规模复核。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-102` 分支串行执行。Extractor 文档明确要求调用者只传入已完成签名与 SHA-256 校验的 ZIP。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/installer` 建立 Limits/Extractor,采用完整预检后再创建 staging 的两阶段流程;只解压 `payload/`,不复制根部元数据。
|
||||||
|
- 2026-07-16:路径规则拒绝无效 UTF-8、NUL、绝对/UNC、盘符/冒号/ADS、反斜杠、非规范路径与 `..`;大小写折叠后重复路径也拒绝,避免 Windows 覆盖歧义。
|
||||||
|
- 2026-07-16:拒绝符号链接、特殊文件、加密条目、协议外顶层文件;要求 app.json 存在且 entrypoint 是 payload 内精确匹配的普通文件。
|
||||||
|
- 2026-07-16:原型默认限制为 10,000 条目、4 GiB 总展开、单条/总体 200:1 压缩比;实际复制再次按剩余额度限流并核对 header 展开大小。
|
||||||
|
- 2026-07-16:任一复制、CRC 或关闭错误会删除本次新建 staging;已有 destination 直接拒绝,不覆盖未知内容。
|
||||||
|
- 2026-07-16:攻击 ZIP 由表驱动测试运行时生成,策略记录于 `testdata/zip/README.md`;标准库会先解析中央目录再暴露条目列表,如需抵御超大中央目录的预解析内存压力,T-302 应增加 ZIP 文件/中央目录预扫描限制。
|
||||||
|
- 定向测试通过:合法 payload、绝对/盘符/ADS/`../`/反斜杠、加密、symlink、特殊文件、重复路径、协议外文件、条目数、总展开、压缩比、entrypoint 外逃/缺失、已有 staging 和 CRC 损坏清理。
|
||||||
|
- 验证通过:Go 1.20.14 core vet/test。
|
||||||
|
- 验证通过:`./scripts/verify_phase0.ps1`,包含 modern/win7 双目标构建与治理检查。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
id: T-103
|
||||||
|
title: staging/current/backup 原子切换与崩溃恢复原型
|
||||||
|
phase: 1
|
||||||
|
deps: [T-102]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 0ffeff63e138fcb800f0eee0a8a1d4167f20df09
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-103
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-103.md
|
||||||
|
- core/installer/
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
安全解压只产生 staging;要让更新可用,还必须在 Windows 文件系统上把 staging 切换为 current,并保证任一步骤崩溃或健康检查失败后都能从磁盘恢复已知安全版本。仅靠内存状态无法覆盖断电/进程退出。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/installer` 建立固定目录布局与持久化事务日志 `install-transaction.json`。
|
||||||
|
2. 切换阶段:`prepared → current_backed_up → staging_activated → committed`;健康失败进入 `rollback_required`。
|
||||||
|
3. 每次阶段更新使用同目录临时文件 + journal backup 原子替换;目录切换只使用同一 app 根目录内的 rename。
|
||||||
|
4. 成功健康检查后先写 committed,再清理 backup/journal;失败时把新 current 移出、恢复 backup,失败产物不保留为可启动 current。
|
||||||
|
5. `Recover` 读取主 journal 或中断遗留 journal backup,结合 current/staging/backup 实际存在状态,安全完成回滚/撤销/提交清理。
|
||||||
|
6. 测试通过内部 failpoint 在关键 rename/phase 后模拟进程崩溃,再用新的 Switcher 实例从磁盘恢复。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 有旧 current 的成功更新:新版本成为 current,backup/staging/journal 清理。
|
||||||
|
- 健康检查失败:旧 current 恢复且返回稳定失败;无旧版本时不留下 current。
|
||||||
|
- 在备份后、激活后、committed 后模拟崩溃,新实例 Recover 均得到确定结果。
|
||||||
|
- journal 主文件缺失但 backup 存在时仍可恢复。
|
||||||
|
- 不一致/路径为 symlink/缺 staging/已有未处理 backup 时拒绝,不猜测或删除目录外内容。
|
||||||
|
- Go 1.20 core vet/test 与完整双目标闸门通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现进程退出检测、真实 EXE 健康协议、installed-app.json(T-202/T-302)。
|
||||||
|
- 不跨卷移动目录;调用者必须把 staging/current/backup 放在同一 app 根目录。
|
||||||
|
- 不清理 `data/`、`licenses/` 或 app 根目录外路径。
|
||||||
|
- 不保留多个历史 backup;多版本备份策略留后续任务。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-103` 分支串行执行。恢复策略以“未健康检查的新版本不成为可信 current”为最高原则。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/installer` 建立固定 app layout、安装事务日志、Switcher 与 Recover;所有 rename/remove 只允许 root 下的 current/staging/backup。
|
||||||
|
- 2026-07-16:事务阶段落地为 `prepared → current_backed_up → staging_activated → committed`,健康失败进入 `rollback_required`;journal 使用临时文件、fsync、主文件/backup 替换。
|
||||||
|
- 2026-07-16:健康成功先写 committed 再清理旧 backup;健康失败把新 current 移出并恢复旧版,首次安装失败则不留下 current。
|
||||||
|
- 2026-07-16:Recover 使用新实例仅凭 journal(或 journal backup)与目录现实恢复;未健康检查的新版本默认回滚/撤销,committed 状态只做清理不降级。
|
||||||
|
- 定向测试通过:成功更新、健康失败回滚、首次安装失败、current rename 后崩溃、staging rename 后崩溃、committed 后崩溃、rollback_required 后崩溃、journal backup、orphan backup、缺 staging、已有 backup、目录/journal symlink。
|
||||||
|
- 2026-07-16:测试模拟的是进程在持久化步骤之间退出;真实断电下目录项落盘顺序与文件锁行为仍需 T-302/T-601 在 Windows VM/真机故障注入验证,不把本原型等同于硬件级断电证明。
|
||||||
|
- 验证通过:Go 1.20.14 core vet/test。
|
||||||
|
- 验证通过:`./scripts/verify_phase0.ps1`,包含 modern/win7 双目标构建、版本/边界与治理检查。
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: T-201
|
||||||
|
title: Catalog 模块正式接入
|
||||||
|
phase: 2
|
||||||
|
deps: [T-101]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 9da72ca0d8563fb5dca8a5675461e49dc656b74e
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-201
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-201.md
|
||||||
|
- core/catalog/
|
||||||
|
- schemas/
|
||||||
|
- testdata/catalog/
|
||||||
|
- testdata/README.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Phase 1 已验证 Catalog 顶层签名与最后有效缓存回退,但仍只有“可信 JSON 字节”,没有正式的 HTTPS 获取、协议字段校验、modern/win7 通道约束、OS/架构过滤和上下架处理。若在结构或通道校验前写缓存,签名正确但客户端无法消费的清单也可能替换最后可用版本。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/catalog` 建立 manifest/app/package 强类型模型和严格解析校验,拒绝未知字段、重复软件 ID、非法枚举、非 HTTPS 包 URL及不一致的架构映射。
|
||||||
|
2. Loader 支持在缓存替换前执行文档 validator;正式 Client 固定按“验签 → Schema/通道校验 → 缓存 → 目标过滤”加载。
|
||||||
|
3. 增加有体积上限的 HTTPS Fetcher,并保留 Fetcher/Cache 注入以便无网络测试。
|
||||||
|
4. 目标过滤同时处理 manifest channel、`min_os`、`architectures`、package 架构与 `status`:hidden 不展示,deprecated 与 incompatible 可见但不可安装并给出稳定原因码。
|
||||||
|
5. 在 `schemas/` 落地 manifest 与 app.json 的 JSON Schema;同步协议与架构文档。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 签名、结构、通道全部通过后才替换缓存;结构非法或通道错误时回退最后验证且可消费的缓存。
|
||||||
|
- HTTP 获取只允许 HTTPS、拒绝非 2xx/超限响应。
|
||||||
|
- modern/win7、最低 OS、386/amd64 与 package 映射过滤正确。
|
||||||
|
- active 可安装;deprecated 可见不可安装;hidden 不进入结果;不兼容项可见并返回稳定原因。
|
||||||
|
- `manifest.schema.json` 与 `app.schema.json` 可由标准 JSON 解析器读取,示例字段与 `docs/api.md` 一致。
|
||||||
|
- Go 1.20 core vet/test、完整双目标闸门和治理校验通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现 installed-app.json、SemVer 比较和本地 12 状态识别(T-202)。
|
||||||
|
- 不实现 Gio 列表、详情或图标资源下载(T-203/T-204)。
|
||||||
|
- 不冻结密钥轮换字段或图标 URL;发布端跨实现签名向量仍需在独立仓库对齐。
|
||||||
|
- 不引入第三方 Schema、HTTP 或 SemVer 依赖。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-201` 分支串行执行。正式 Client 必须保护“最后可消费缓存”,不能把 validator 放到缓存写入之后。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:建立 manifest/app/package 强类型模型、严格 Parser 与正式 Client;加载顺序固定为“HTTPS 获取 → Ed25519 验签 → 字段/Schema/channel 校验 → 缓存替换 → 目标过滤”。
|
||||||
|
- 2026-07-16:Loader 增加缓存写入前 DocumentValidator;测试证明签名正确但 channel/结构不匹配的远端清单不会覆盖最后可消费缓存。
|
||||||
|
- 2026-07-16:增加有响应体上限和 HTTPS 重定向约束的 HTTPFetcher;错误状态、非 HTTPS 与超限响应均返回稳定错误。
|
||||||
|
- 2026-07-16:实现 modern/win7、Windows 7 SP1/10/11、386/amd64 过滤;active 可安装,deprecated 与 incompatible 可见不可安装并带稳定原因,hidden 不进入目录结果。
|
||||||
|
- 2026-07-16:正式协议补充必填 `category`,与 `tags` 分别承担单分类与搜索标签;图标仍仅定义 `sha256:` 内容引用,未越权确定分发 URL。
|
||||||
|
- 2026-07-16:新增 `schemas/manifest.schema.json`、`schemas/app.schema.json`,并同步 `docs/api.md`、`docs/04-architecture.md`、`docs/current-state.md` 与公开虚构 testdata。
|
||||||
|
- 定向验证通过:`go -C core test -count=1 ./catalog`。
|
||||||
|
- Schema 语法验证通过:`python -m json.tool schemas/manifest.schema.json`、`python -m json.tool schemas/app.schema.json`。
|
||||||
|
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
|
||||||
|
- 提交前检查通过:`git diff --check`。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
id: T-202
|
||||||
|
title: 本地安装状态识别
|
||||||
|
phase: 2
|
||||||
|
deps: [T-201, T-103]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 2e21c9f327bc56ab719fafee7ca5303f90747b4c
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-202
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-202.md
|
||||||
|
- core/domain/
|
||||||
|
- core/storage/
|
||||||
|
- core/catalog/
|
||||||
|
- schemas/
|
||||||
|
- docs/api.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
Catalog 已能给出可信远端版本和兼容性,但客户端还不能从 `apps/<id>/installed-app.json` 恢复实际安装版本,也没有统一的 SemVer 比较与 12 种用户状态推导。若 UI 自行拼接磁盘、版本和任务状态,状态优先级会漂移且容易在 Layout 中引入 IO。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/domain` 实现 SemVer 2.0.0 解析/比较,正确处理 prerelease,忽略 build metadata 的排序影响。
|
||||||
|
2. 在 `core/storage` 定义 installed-app.json v1 模型和严格校验,使用临时文件 + 同目录 backup 原子替换,主文件缺失时可读取中断遗留 backup。
|
||||||
|
3. 本地 Inspector 识别安装记录与 `install-transaction.json`/backup 是否存在,只返回快照,不推导 UI 状态。
|
||||||
|
4. 在 domain 建立纯 `ResolveAppStatus`:按恢复事务、活跃操作、运行状态、兼容性、本地版本与 Catalog 版本推导已有 12 种 AppStatus。
|
||||||
|
5. Catalog 版本字段校验复用 domain SemVer,避免协议解析与本地比较出现两套版本规则。
|
||||||
|
6. 落地 `schemas/installed-app.schema.json`,同步协议、架构与当前状态文档。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- installed-app.json 可严格读写;未知字段、ID 不匹配、非法版本/架构/channel/文件路径/哈希被拒绝。
|
||||||
|
- 写入使用同目录临时文件 + backup 替换;主文件缺失时能读取中断遗留 backup。
|
||||||
|
- 未完成安装事务能被识别为 `rollback_pending` 输入事实。
|
||||||
|
- SemVer 覆盖 major/minor/patch、prerelease precedence、build metadata 和非法格式。
|
||||||
|
- 表驱动测试逐一得到 12 个 AppStatus;Catalog 版本更高时为 update_available。
|
||||||
|
- Go 1.20 core vet/test、完整双目标闸门和治理校验通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现下载/安装任务持久化或进程检测;这些状态只消费后续用例提供的事实。
|
||||||
|
- 不实现 Gio 列表或在 Layout 中扫描磁盘(T-203)。
|
||||||
|
- 不修改 T-103 的切换/恢复策略;这里只读识别其持久事务文件。
|
||||||
|
- 不引入第三方 SemVer 或存储依赖。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-202` 分支串行执行。存储 IO 与状态推导必须分层,后续 UI 只能消费快照/状态结果。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/domain` 实现完整 SemVer 2.0.0 解析与 precedence 比较;支持任意长度数字、prerelease 排序,build metadata 不影响更新判断,非法前导零/标识符被拒绝。
|
||||||
|
- 2026-07-16:Catalog 的 `min_box_version` 与 app `version` 校验改为复用 domain SemVer,消除远端解析与本地更新判断的双规则。
|
||||||
|
- 2026-07-16:在 `core/storage` 建立 installed-app.json v1 模型、严格 reader/writer 与 Inspector;写入采用同目录临时文件 + backup 原子替换,主文件缺失时读取并重验 backup。
|
||||||
|
- 2026-07-16:安装记录校验覆盖未知字段、路径 ID 不匹配、架构/channel、文件安全相对路径、Windows 大小写折叠重复路径、size 与 SHA-256;transaction 主文件或 backup 可被识别为恢复待处理事实。
|
||||||
|
- 2026-07-16:增加纯 `ResolveAppStatus`,状态优先级为恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → SemVer 更新;表驱动测试逐一覆盖全部 12 个 AppStatus。
|
||||||
|
- 2026-07-16:新增 `schemas/installed-app.schema.json`,同步三份 Schema 的严格 SemVer pattern,并更新 `docs/api.md`、`docs/04-architecture.md`、`docs/current-state.md`。
|
||||||
|
- 定向验证通过:`go -C core test -count=1 ./domain ./storage ./catalog`。
|
||||||
|
- Schema 语法验证通过:`python -m json.tool` 解析 manifest/app/installed-app 三份 Schema。
|
||||||
|
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
|
||||||
|
- 提交前检查通过:`git diff --check`。
|
||||||
|
- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
id: T-203
|
||||||
|
title: 主界面软件列表
|
||||||
|
phase: 2
|
||||||
|
deps: [T-202, T-003]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: 819b1cda1bd155c5cb76e7b05d45507f67bdb42d
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-203
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-203.md
|
||||||
|
- core/application/
|
||||||
|
- app-modern/ui/gio/
|
||||||
|
- app-modern/cmd/softbox/
|
||||||
|
- app-win7/ui/gio/
|
||||||
|
- app-win7/cmd/softbox/
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
可信 Catalog 与本地状态已经可用,但两个客户端仍只显示空 AppShell。主视图需要在不把磁盘/网络 IO 放进 Layout 的前提下,支持数百项、搜索、分类和全部/已安装/可更新切换;若控件按列表索引保存,筛选或排序后点击状态会错位。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/application` 建立共享、无 IO 的 CatalogListModel,列表项使用稳定软件 ID;支持名称/ID/tag 搜索、分类集合与 all/installed/updates 三种视图。
|
||||||
|
2. 过滤结果保持 Catalog 原顺序;查询和分类变化不重建源数据,Gio 只消费当前可见切片。
|
||||||
|
3. modern/win7 分别实现 Gio AppShell:顶部搜索与分类、左侧视图切换、中部惰性 `layout.List`、有帮助文案的空状态和底部状态栏。
|
||||||
|
4. 每个 AppRow 的 Clickable 按软件 ID 存入 map,不按可见索引;每帧先 drain 点击,再执行布局。
|
||||||
|
5. 使用扁平、高对比、44dp 最小交互目标和 4/8dp 间距体系;Legacy 减少装饰但保持同一语义。
|
||||||
|
6. 测试共享筛选规则、ID 控件稳定性,并用数百项 + 有限 viewport 证明 layout.List 只布局可见行。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 搜索覆盖名称、ID 与 tags,忽略大小写和首尾空白。
|
||||||
|
- 分类筛选与 all/installed/updates 视图可组合;无结果显示清晰空状态。
|
||||||
|
- 两个 Gio 版本均使用惰性 layout.List;500 项在有限 viewport 中不会布局全部行。
|
||||||
|
- 行控件状态按 app ID 保持,过滤/切换视图后不串行。
|
||||||
|
- Layout 代码不读文件、不访问网络、不计算哈希。
|
||||||
|
- modern 与 win7 UI 测试/构建、Go 1.20 core vet/test、完整双目标闸门和治理校验通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不实现详情弹层与图标缓存(T-204)。
|
||||||
|
- 不发起下载、安装、启动或真实 Catalog 网络装配;主操作仅展示当前状态语义。
|
||||||
|
- 不实现下载中/最近使用视图或多标签复选;路线图 MVP 本任务只冻结 all/installed/updates。
|
||||||
|
- 不添加动画、阴影、emoji 图标或新 UI 依赖。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-203` 分支串行执行。`ui-ux-pro-max` 的本轮约束是高对比扁平界面、可见焦点/空状态、44dp 目标、虚拟化 50+ 项和键盘顺序与视觉顺序一致。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:按 `ui-ux-pro-max` 先生成 SoftBox 设计系统并检索虚拟列表、键盘导航、空状态与焦点规则;最终采用高对比浅色、扁平无阴影、4/8dp 间距、44dp 控件和明显 Legacy 标识。
|
||||||
|
- 2026-07-16:在 `core/application` 建立无 IO `CatalogListModel`;搜索覆盖名称/ID/tags,单分类与 all/installed/updates 可组合,过滤保持 Catalog 原顺序并保存 selected app ID。
|
||||||
|
- 2026-07-16:modern Gio v0.10.1 与 win7 Gio v0.6.0 分别实现搜索栏、横向分类、左侧视图切换、惰性软件列表、状态/原因文案、两类空状态和恢复筛选操作。
|
||||||
|
- 2026-07-16:Editor/按钮/软件行均提供键盘焦点反馈;自定义行补充 semantic button/description,未使用 emoji、阴影或额外 UI 依赖。
|
||||||
|
- 2026-07-16:行 Clickable 以软件 ID 为键保存;筛选与 Catalog 快照更新后同 ID 控件保持原实例,移除的软件控件被释放。
|
||||||
|
- 2026-07-16:两个适配各用 500 项 + 有限 viewport 测试证明 `layout.List` 只布局可见子集;空 AppShell 仍保持窗口尺寸测试。
|
||||||
|
- 2026-07-16:同步 `docs/routes.md`、`docs/04-architecture.md` 与 `docs/current-state.md`;真实 Catalog 网络/密钥装配仍保持在 Layout 外。
|
||||||
|
- 定向验证通过:`go -C core test -count=1 ./application`、modern/win7 `go test -count=1 ./ui/gio`。
|
||||||
|
- Layout IO 扫描通过:`rg` 检查两个 `ui/gio` 下无 `os`、`net/http`、文件读写、filepath 或 sha256 调用。
|
||||||
|
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
|
||||||
|
- 提交前检查通过:`git diff --check`。
|
||||||
|
- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: T-204
|
||||||
|
title: 软件详情与图标缓存
|
||||||
|
phase: 2
|
||||||
|
deps: [T-203]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-16
|
||||||
|
issue: null
|
||||||
|
context_ref: db8d93e84337489a1a138a6f624df359add3ed36
|
||||||
|
claim_branch: null
|
||||||
|
work_branch: agent/codex/T-204
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-204.md
|
||||||
|
- core/catalog/
|
||||||
|
- core/application/
|
||||||
|
- app-modern/ui/gio/
|
||||||
|
- app-win7/ui/gio/
|
||||||
|
- docs/api.md
|
||||||
|
- docs/routes.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
软件行已经可选择,但还没有详情视图,也没有可信图标的后台缓存链路。若 Gio Layout 自行读文件、解码或请求图标,会直接违反每帧无 IO 约束;若磁盘图标不复核 Catalog 内容哈希,损坏或替换的缓存也可能被展示。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 在 `core/catalog` 建立 IconCache:以 `sha256:<digest>` + DPI 为键,按内存 → 磁盘 → 注入 Fetcher 顺序加载。
|
||||||
|
2. 远端图标必须满足大小上限、SHA-256 与可解码图片/尺寸上限后才能原子写入磁盘;磁盘命中同样复核哈希与图片头。
|
||||||
|
3. 磁盘文件按 digest + DPI 隔离;新进程在 Fetcher 离线时仍可读取已验证缓存。
|
||||||
|
4. `CatalogListItem` 补充 icon/homepage/tutorial 数据与 selected item 查询。
|
||||||
|
5. modern/win7 在选中软件时显示右侧详情面板,含关闭、版本、分类、简介、tags、状态、不可用原因和教程/主页信息。
|
||||||
|
6. UI 只提供 `ApplyIcon(appID, image.Image)` 接收后台已解码结果;Layout 仅绘制内存图像,未命中时显示字母占位。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 同一 digest+DPI 首次远端加载,随后内存命中;新 cache 实例断网时可从磁盘命中。
|
||||||
|
- 不同 DPI 使用不同磁盘键;哈希不符、超限、不可解码/超尺寸图标不写缓存。
|
||||||
|
- 损坏磁盘缓存不会被返回;有网络时可重新获取修复,离线时返回明确错误。
|
||||||
|
- 点击行后详情右栏可见且可关闭;列表/详情均能显示后台 ApplyIcon 的内存图像或稳定占位。
|
||||||
|
- 两个 Gio Layout 中无磁盘、网络、哈希或图片解码。
|
||||||
|
- Go 1.20 core vet/test、modern/win7 UI 测试与构建、完整闸门和治理校验通过。
|
||||||
|
|
||||||
|
## 边界(不改什么)
|
||||||
|
|
||||||
|
- 不决定图标 URL/变体的最终 Catalog 字段;Fetcher 由装配层按发布端协议注入。
|
||||||
|
- 不实现网络请求装配、图片缩放生成、LRU/容量淘汰或 CDN 缓存策略。
|
||||||
|
- 不实现真实下载/安装/启动动作和许可证详情;详情只展示当前可用事实与后续操作语义。
|
||||||
|
- 不在 UI 中调用 image.Decode、os、http 或 sha256。
|
||||||
|
|
||||||
|
## 协作约束
|
||||||
|
|
||||||
|
未启用 Gitea;本任务在 `agent/codex/T-204` 分支串行执行。延续 `ui-ux-pro-max` 的扁平高对比、44dp、清晰返回/关闭、上下文保留和无昂贵 Layout 工作约束。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-16:在 `core/catalog` 建立 IconCache;键为规范化 SHA-256 digest + DPI,加载顺序为 memory → verified disk → injected fetcher,返回 source 与非致命磁盘写 warning。
|
||||||
|
- 2026-07-16:远端和磁盘统一复核内容哈希、图片完整解码、默认 2 MiB 与 2048×2048 上限;只把验证成功字节用同目录临时文件原子写入,symlink/非普通缓存项拒绝。
|
||||||
|
- 2026-07-16:测试覆盖首次 remote、随后 memory、新实例断网 disk、96/144 DPI 分盘、哈希不符、非法图片、字节/尺寸超限、坏磁盘在线修复与坏磁盘离线明确失败。
|
||||||
|
- 2026-07-16:`CatalogListItem` 增加 IconRef/Homepage/Tutorial 与 SelectedItem 查询;筛选/滚动上下文不因打开或关闭详情而重置。
|
||||||
|
- 2026-07-16:modern/win7 均实现右侧详情、明确“关闭”文字按钮、版本/分类/简介/tags/状态/不可用原因/教程/主页展示;未接入的安装/启动/授权没有伪装为可执行动作。
|
||||||
|
- 2026-07-16:两个 Gio 适配增加 `ApplyIcon(appID, image.Image)`,在调用时预建 `paint.ImageOp`;列表和详情每帧只复用内存绘制操作,未命中使用非 emoji 字母占位。
|
||||||
|
- 2026-07-16:按 `ui-ux-pro-max` 交付复查确认关闭按钮有可访问文字名、返回保持上下文,详情延续高对比扁平/44dp 交互体系。
|
||||||
|
- 2026-07-16:同步 `docs/api.md`、`docs/routes.md`、`docs/04-architecture.md` 与 `docs/current-state.md`;图标下载位置/分辨率映射仍明确留给发布端协议,客户端不猜 URL。
|
||||||
|
- 定向验证通过:`go -C core test -count=1 ./catalog ./application`、modern/win7 `go test -count=1 ./ui/gio`。
|
||||||
|
- Layout IO/解码扫描通过:`rg` 检查两个 `ui/gio` 下无 `image.Decode`、`os`、`net/http`、文件读写、filepath 或 sha256 调用。
|
||||||
|
- 完整验证通过:`./scripts/verify_phase0.ps1`,包含 Go 1.20.14 core vet/test、治理/边界/版本检查及 modern/win7 双目标测试与构建。
|
||||||
|
- 提交前检查通过:`git diff --check`。
|
||||||
|
- 工作区中的未跟踪 `soft_quay.code-workspace` 与本任务无关,已保留且未纳入提交。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
use (
|
||||||
|
./app-modern
|
||||||
|
./app-win7
|
||||||
|
./core
|
||||||
|
)
|
||||||
|
|
||||||
|
replace softbox.local/core v0.0.0 => ./core
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.6.0/go.mod h1:eUvGo6FAzA7jUqeSu5a+M1W03yc9r1nanIBS8A5+Nng=
|
||||||
|
gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0=
|
||||||
|
gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k=
|
||||||
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
|
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
|
||||||
|
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||||
|
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
|
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
@@ -4,32 +4,17 @@
|
|||||||
# - Windows 原生 PowerShell:用本文件 ./init.ps1
|
# - Windows 原生 PowerShell:用本文件 ./init.ps1
|
||||||
# - WSL / Git Bash / macOS / Linux:用 ./init.sh
|
# - WSL / Git Bash / macOS / Linux:用 ./init.sh
|
||||||
# 一条命令完成:依赖安装 -> 基础验证 -> 打印启动命令。
|
# 一条命令完成:依赖安装 -> 基础验证 -> 打印启动命令。
|
||||||
# 复制到新项目后,必须先替换下面三个命令,让每轮会话用同一条路径启动,不靠记忆。
|
# 三个命令是仓库统一入口;换工具链时同步更新文档与这里。
|
||||||
# 本文件不绑定任何技术栈;换技术栈时只替换这三个命令,脚本结构不用动。
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
[Console]::OutputEncoding = $Utf8
|
||||||
|
$OutputEncoding = $Utf8
|
||||||
Set-Location -Path $PSScriptRoot
|
Set-Location -Path $PSScriptRoot
|
||||||
|
|
||||||
# 按你的项目实际情况替换这三个命令。未替换前脚本会主动失败。
|
$InstallCmd = "go work sync"
|
||||||
$InstallCmd = "__REPLACE_INSTALL_CMD__" # 依赖安装,如 uv sync / poetry install / npm install
|
$VerifyCmd = "./scripts/verify_phase0.ps1"
|
||||||
$VerifyCmd = "__REPLACE_VERIFY_CMD__" # 基础验证 / smoke,如 python -m pytest / go test ./...
|
$StartCmd = '$env:CGO_ENABLED="0"; $env:GOOS="windows"; $env:GOARCH="amd64"; $env:GOTOOLCHAIN="go1.25.0"; go build -C app-modern -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox.exe ./cmd/softbox; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; $env:GOTOOLCHAIN="go1.20.14"; go build -C app-win7 -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox-win7.exe ./cmd/softbox'
|
||||||
$StartCmd = "__REPLACE_START_CMD__" # 开发启动,如 uvicorn app:app --reload / npm run dev
|
|
||||||
|
|
||||||
function Assert-Configured {
|
|
||||||
param(
|
|
||||||
[string]$Name,
|
|
||||||
[string]$Value
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($Value -like "__REPLACE_*") {
|
|
||||||
Write-Error "请先在 init.ps1 中替换 $Name。同步更新 docs/03-tech-stack.md、docs/00-ai-start-here.md 和 docs/current-state.md 中的命令。"
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert-Configured -Name "InstallCmd" -Value $InstallCmd
|
|
||||||
Assert-Configured -Name "VerifyCmd" -Value $VerifyCmd
|
|
||||||
Assert-Configured -Name "StartCmd" -Value $StartCmd
|
|
||||||
|
|
||||||
Write-Host "==> 当前目录: $($PWD.Path)"
|
Write-Host "==> 当前目录: $($PWD.Path)"
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,35 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# 标准启动与验证入口(Unix shell 版),与 init.ps1 等价,二选一:
|
# 标准启动与验证入口(Unix shell 版),与 init.ps1 等价,二选一:
|
||||||
# - WSL / Git Bash / macOS / Linux:用本文件 ./init.sh
|
# - WSL / Git Bash / macOS / Linux:用 ./init.sh
|
||||||
# - Windows 原生 PowerShell:用 ./init.ps1
|
# - Windows 原生 PowerShell:用 ./init.ps1
|
||||||
# 一条命令完成:依赖安装 -> 基础验证 -> 打印启动命令。
|
# 一条命令完成:依赖安装 -> 基础验证 -> 打印启动命令。
|
||||||
# 复制到新项目后,必须先替换下面三个变量,让每轮会话用同一条路径启动,不靠记忆。
|
# 三个命令是仓库统一入口;换工具链时同步更新文档与这里。
|
||||||
# 本文件不绑定任何技术栈;换技术栈时只替换这三个变量,脚本结构不用动。
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
# 按你的项目实际情况替换这三个命令。未替换前脚本会主动失败。
|
INSTALL_CMD="go work sync"
|
||||||
INSTALL_CMD=(__REPLACE_INSTALL_CMD__) # 依赖安装,如 uv sync、poetry install、npm install
|
VERIFY_CMD="bash scripts/verify_phase0.sh"
|
||||||
VERIFY_CMD=(__REPLACE_VERIFY_CMD__) # 基础验证 / smoke,如 python -m pytest、go test ./...
|
START_CMD="GOTOOLCHAIN=go1.25.0 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go -C app-modern build -trimpath -ldflags=-H=windowsgui -o ../dist/SoftBox.exe ./cmd/softbox && GOTOOLCHAIN=go1.20.14 CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go -C app-win7 build -trimpath -ldflags=-H=windowsgui -o ../dist/SoftBox-win7.exe ./cmd/softbox"
|
||||||
START_CMD=(__REPLACE_START_CMD__) # 开发启动,如 uvicorn app:app --reload、npm run dev
|
|
||||||
|
|
||||||
ensure_configured() {
|
|
||||||
local name="$1"
|
|
||||||
local first="$2"
|
|
||||||
if [[ "$first" == __REPLACE_* ]]; then
|
|
||||||
echo "ERROR: 请先在 init.sh 中替换 ${name}。"
|
|
||||||
echo " 同步更新 docs/03-tech-stack.md、docs/00-ai-start-here.md 和 docs/current-state.md 中的命令。"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_configured "INSTALL_CMD" "${INSTALL_CMD[0]}"
|
|
||||||
ensure_configured "VERIFY_CMD" "${VERIFY_CMD[0]}"
|
|
||||||
ensure_configured "START_CMD" "${START_CMD[0]}"
|
|
||||||
|
|
||||||
echo "==> 当前目录: $PWD"
|
echo "==> 当前目录: $PWD"
|
||||||
|
|
||||||
echo "==> 同步依赖"
|
echo "==> 同步依赖"
|
||||||
"${INSTALL_CMD[@]}"
|
eval "$INSTALL_CMD"
|
||||||
|
|
||||||
echo "==> 运行基础验证"
|
echo "==> 运行基础验证"
|
||||||
"${VERIFY_CMD[@]}"
|
eval "$VERIFY_CMD"
|
||||||
|
|
||||||
echo "==> 启动命令"
|
echo "==> 启动命令"
|
||||||
printf ' %q' "${START_CMD[@]}"
|
printf ' %s\n' "$START_CMD"
|
||||||
printf '\n'
|
|
||||||
|
|
||||||
if [ "${RUN_START_COMMAND:-0}" = "1" ]; then
|
if [ "${RUN_START_COMMAND:-0}" = "1" ]; then
|
||||||
echo "==> 启动应用"
|
echo "==> 启动应用"
|
||||||
exec "${START_CMD[@]}"
|
eval "$START_CMD"
|
||||||
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "如果希望 init.sh 直接启动应用,请设置 RUN_START_COMMAND=1。"
|
echo "如果希望 init.sh 直接启动应用,请设置 RUN_START_COMMAND=1。"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://softbox.invalid/schemas/app.schema.json",
|
||||||
|
"title": "SoftBox Package app.json v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"vendor",
|
||||||
|
"version",
|
||||||
|
"channel",
|
||||||
|
"min_os",
|
||||||
|
"architecture",
|
||||||
|
"entrypoint",
|
||||||
|
"working_directory",
|
||||||
|
"product_id",
|
||||||
|
"supports_trial",
|
||||||
|
"requires_admin",
|
||||||
|
"data_policy",
|
||||||
|
"update_policy"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"const": "stable"
|
||||||
|
},
|
||||||
|
"min_os": {
|
||||||
|
"enum": ["windows-7-sp1", "windows-10", "windows-11"]
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"enum": ["386", "amd64"]
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"$ref": "#/$defs/safeRelativePath"
|
||||||
|
},
|
||||||
|
"working_directory": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"const": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/safeRelativePath"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"supports_trial": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requires_admin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"data_policy": {
|
||||||
|
"const": "local-app-data"
|
||||||
|
},
|
||||||
|
"update_policy": {
|
||||||
|
"const": "managed-by-softbox"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"safeRelativePath": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://softbox.invalid/schemas/installed-app.schema.json",
|
||||||
|
"title": "SoftBox installed-app.json v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"id",
|
||||||
|
"version",
|
||||||
|
"architecture",
|
||||||
|
"channel",
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"
|
||||||
|
},
|
||||||
|
"architecture": {
|
||||||
|
"enum": ["386", "amd64"]
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"const": "stable"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"file": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["path", "size", "sha256"],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9A-Fa-f]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://softbox.invalid/schemas/manifest.schema.json",
|
||||||
|
"title": "SoftBox Catalog Manifest v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"channel",
|
||||||
|
"generated_at",
|
||||||
|
"min_box_version",
|
||||||
|
"apps",
|
||||||
|
"signature"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"enum": ["modern", "win7"]
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"min_box_version": {
|
||||||
|
"$ref": "#/$defs/semver"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"$ref": "#/$defs/ed25519Signature"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"semver": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"
|
||||||
|
},
|
||||||
|
"ed25519Signature": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9+/]{86}==$"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9A-Fa-f]{64}$"
|
||||||
|
},
|
||||||
|
"httpsUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"pattern": "^https://"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["url", "size", "sha256", "signature"],
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"$ref": "#/$defs/httpsUrl"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"$ref": "#/$defs/sha256"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"$ref": "#/$defs/ed25519Signature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"version",
|
||||||
|
"channel",
|
||||||
|
"status",
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"min_os",
|
||||||
|
"architectures",
|
||||||
|
"entry_exe",
|
||||||
|
"requires_admin",
|
||||||
|
"packages"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"$ref": "#/$defs/semver"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"const": "stable"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": ["active", "deprecated", "hidden"]
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[0-9A-Fa-f]{64}$"
|
||||||
|
},
|
||||||
|
"homepage": {
|
||||||
|
"$ref": "#/$defs/httpsUrl"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"$ref": "#/$defs/httpsUrl"
|
||||||
|
},
|
||||||
|
"min_os": {
|
||||||
|
"enum": ["windows-7-sp1", "windows-10", "windows-11"]
|
||||||
|
},
|
||||||
|
"architectures": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"enum": ["386", "amd64"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry_exe": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*\\\\)(?!.*:)(?!\\.\\.?(/|$)).+$"
|
||||||
|
},
|
||||||
|
"requires_admin": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"386": {
|
||||||
|
"$ref": "#/$defs/package"
|
||||||
|
},
|
||||||
|
"amd64": {
|
||||||
|
"$ref": "#/$defs/package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reject UI, Windows, SQLite, and app-layer imports from the core module."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
FORBIDDEN_IMPORT_PREFIXES = (
|
||||||
|
"gioui.org",
|
||||||
|
"golang.org/x/sys/windows",
|
||||||
|
"github.com/mattn/go-sqlite3",
|
||||||
|
"modernc.org/sqlite",
|
||||||
|
"softbox.local/app-modern",
|
||||||
|
"softbox.local/app-win7",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_forbidden(import_path):
|
||||||
|
return any(
|
||||||
|
import_path == prefix or import_path.startswith(prefix + "/")
|
||||||
|
for prefix in FORBIDDEN_IMPORT_PREFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_json_stream(text):
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
index = 0
|
||||||
|
while index < len(text):
|
||||||
|
while index < len(text) and text[index].isspace():
|
||||||
|
index += 1
|
||||||
|
if index >= len(text):
|
||||||
|
return
|
||||||
|
value, index = decoder.raw_decode(text, index)
|
||||||
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
def list_core_packages(repo_root):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["GOWORK"] = "off"
|
||||||
|
result = subprocess.run(
|
||||||
|
["go", "list", "-json", "./..."],
|
||||||
|
cwd=str(repo_root / "core"),
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(result.stderr, file=sys.stderr, end="")
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
|
return list(decode_json_stream(result.stdout))
|
||||||
|
|
||||||
|
|
||||||
|
def run_self_test():
|
||||||
|
cases = (
|
||||||
|
("gioui.org/layout", True),
|
||||||
|
("golang.org/x/sys/windows/registry", True),
|
||||||
|
("modernc.org/sqlite/lib", True),
|
||||||
|
("softbox.local/app-modern/ui/gio", True),
|
||||||
|
("context", False),
|
||||||
|
("crypto/sha256", False),
|
||||||
|
)
|
||||||
|
failures = [
|
||||||
|
import_path
|
||||||
|
for import_path, expected in cases
|
||||||
|
if is_forbidden(import_path) != expected
|
||||||
|
]
|
||||||
|
if failures:
|
||||||
|
print(
|
||||||
|
"ERROR: boundary matcher self-test failed for {}".format(
|
||||||
|
", ".join(failures)
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print("core boundary matcher self-test passed.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if sys.argv[1:] == ["--self-test"]:
|
||||||
|
run_self_test()
|
||||||
|
return
|
||||||
|
if len(sys.argv) != 1:
|
||||||
|
print(
|
||||||
|
"usage: python scripts/check_core_boundaries.py [--self-test]",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
for package in list_core_packages(repo_root):
|
||||||
|
imports = set(package.get("Imports", []))
|
||||||
|
imports.update(package.get("TestImports", []))
|
||||||
|
imports.update(package.get("XTestImports", []))
|
||||||
|
for import_path in sorted(imports):
|
||||||
|
if is_forbidden(import_path):
|
||||||
|
violations.append((package["ImportPath"], import_path))
|
||||||
|
|
||||||
|
if violations:
|
||||||
|
for package, import_path in violations:
|
||||||
|
print(
|
||||||
|
"ERROR: {} imports forbidden dependency {}".format(
|
||||||
|
package, import_path
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"core boundary check passed: {} forbidden prefixes absent.".format(
|
||||||
|
len(FORBIDDEN_IMPORT_PREFIXES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate toolchain pins and Go 1.20 compatibility of core/win7 modules."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
MAX_LEGACY_GO_VERSION = (1, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def version_tuple(value):
|
||||||
|
match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?", value)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("invalid Go version {!r}".format(value))
|
||||||
|
return tuple(int(part or 0) for part in match.groups())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_json_stream(text):
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
index = 0
|
||||||
|
while index < len(text):
|
||||||
|
while index < len(text) and text[index].isspace():
|
||||||
|
index += 1
|
||||||
|
if index >= len(text):
|
||||||
|
return
|
||||||
|
value, index = decoder.raw_decode(text, index)
|
||||||
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
def read_text(path):
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def go_directive(path):
|
||||||
|
match = re.search(r"(?m)^go\s+(\d+\.\d+(?:\.\d+)?)\s*$", read_text(path))
|
||||||
|
if not match:
|
||||||
|
raise ValueError("{} has no go directive".format(path))
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def required_version(path, module_path):
|
||||||
|
pattern = r"(?m)^\s*{}\s+(v[^\s]+)".format(re.escape(module_path))
|
||||||
|
match = re.search(pattern, read_text(path))
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
"{} does not require {}".format(path, module_path)
|
||||||
|
)
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_go(repo_root, cwd, arguments, go_work):
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["GOWORK"] = str(go_work) if go_work else "off"
|
||||||
|
result = subprocess.run(
|
||||||
|
["go"] + arguments,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(result.stderr, file=sys.stderr, end="")
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pins(repo_root):
|
||||||
|
expected_go_directives = {
|
||||||
|
repo_root / "go.work": "1.25.0",
|
||||||
|
repo_root / "core" / "go.mod": "1.20",
|
||||||
|
repo_root / "app-modern" / "go.mod": "1.25.0",
|
||||||
|
repo_root / "app-win7" / "go.mod": "1.20",
|
||||||
|
repo_root / "app-win7" / "go.work": "1.20",
|
||||||
|
}
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
for path, expected in expected_go_directives.items():
|
||||||
|
actual = go_directive(path)
|
||||||
|
if actual != expected:
|
||||||
|
violations.append(
|
||||||
|
"{} go directive is {}, want {}".format(
|
||||||
|
path.relative_to(repo_root), actual, expected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
gio_pins = {
|
||||||
|
repo_root / "app-modern" / "go.mod": "v0.10.1",
|
||||||
|
repo_root / "app-win7" / "go.mod": "v0.6.0",
|
||||||
|
}
|
||||||
|
for path, expected in gio_pins.items():
|
||||||
|
actual = required_version(path, "gioui.org")
|
||||||
|
if actual != expected:
|
||||||
|
violations.append(
|
||||||
|
"{} pins gioui.org {}, want {}".format(
|
||||||
|
path.relative_to(repo_root), actual, expected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def validate_go20_toolchain(repo_root):
|
||||||
|
output = run_go(repo_root, repo_root, ["version"], None).strip()
|
||||||
|
if "go1.20.14" not in output:
|
||||||
|
return ["compatibility scan uses {!r}, want go1.20.14".format(output)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def validate_module_versions(repo_root):
|
||||||
|
module_sets = (
|
||||||
|
("core", repo_root / "core", None),
|
||||||
|
(
|
||||||
|
"win7",
|
||||||
|
repo_root / "app-win7",
|
||||||
|
repo_root / "app-win7" / "go.work",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
violations = []
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for label, cwd, go_work in module_sets:
|
||||||
|
output = run_go(
|
||||||
|
repo_root,
|
||||||
|
cwd,
|
||||||
|
["list", "-m", "-json", "all"],
|
||||||
|
go_work,
|
||||||
|
)
|
||||||
|
for module in decode_json_stream(output):
|
||||||
|
checked += 1
|
||||||
|
go_version = module.get("GoVersion")
|
||||||
|
if not go_version:
|
||||||
|
continue
|
||||||
|
if version_tuple(go_version)[:2] > MAX_LEGACY_GO_VERSION:
|
||||||
|
violations.append(
|
||||||
|
"{} module {} declares go {}, exceeds 1.20".format(
|
||||||
|
label, module["Path"], go_version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return violations, checked
|
||||||
|
|
||||||
|
|
||||||
|
def self_check():
|
||||||
|
if version_tuple("1.20")[:2] > MAX_LEGACY_GO_VERSION:
|
||||||
|
raise AssertionError("Go 1.20 should be accepted")
|
||||||
|
if version_tuple("1.20.14")[:2] > MAX_LEGACY_GO_VERSION:
|
||||||
|
raise AssertionError("Go 1.20.14 should be accepted")
|
||||||
|
if not version_tuple("1.21")[:2] > MAX_LEGACY_GO_VERSION:
|
||||||
|
raise AssertionError("Go 1.21 should be rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
self_check()
|
||||||
|
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
violations = validate_pins(repo_root)
|
||||||
|
violations.extend(validate_go20_toolchain(repo_root))
|
||||||
|
module_violations, checked = validate_module_versions(repo_root)
|
||||||
|
violations.extend(module_violations)
|
||||||
|
|
||||||
|
if violations:
|
||||||
|
for violation in violations:
|
||||||
|
print("ERROR: " + violation, file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Go version check passed: pins valid; {} module records are Go 1.20-compatible.".format(
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
[Console]::OutputEncoding = $Utf8
|
||||||
|
$OutputEncoding = $Utf8
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location -Path $Root
|
||||||
|
|
||||||
|
function Assert-NativeSuccess {
|
||||||
|
param([string]$Step)
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$Step failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Step {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[scriptblock]$Command
|
||||||
|
)
|
||||||
|
Write-Host "==> $Name"
|
||||||
|
& $Command
|
||||||
|
Assert-NativeSuccess -Step $Name
|
||||||
|
}
|
||||||
|
|
||||||
|
$GoPath = (& go env GOPATH).Trim()
|
||||||
|
Assert-NativeSuccess -Step "Locate GOPATH"
|
||||||
|
$GoBin = Join-Path $GoPath "bin"
|
||||||
|
if (Test-Path (Join-Path $GoBin "go1.20.14.exe")) {
|
||||||
|
$env:PATH = "$GoBin;$env:PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $Root "dist") | Out-Null
|
||||||
|
|
||||||
|
Invoke-Step "Sync root workspace" {
|
||||||
|
$env:GOTOOLCHAIN = "go1.25.0"
|
||||||
|
Remove-Item Env:GOWORK -ErrorAction SilentlyContinue
|
||||||
|
go work sync
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Validate harness governance" {
|
||||||
|
python scripts/validate_agent_context.py
|
||||||
|
Assert-NativeSuccess -Step "Validate agent context"
|
||||||
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
|
Assert-NativeSuccess -Step "Run governance tests"
|
||||||
|
python scripts/validate_harness_governance.py
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Validate core architecture boundary" {
|
||||||
|
python scripts/check_core_boundaries.py --self-test
|
||||||
|
Assert-NativeSuccess -Step "Self-test core boundary matcher"
|
||||||
|
python scripts/check_core_boundaries.py
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Validate Go and dependency pins" {
|
||||||
|
$env:GOTOOLCHAIN = "go1.20.14"
|
||||||
|
python scripts/check_go_versions.py
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Vet and test core with Go 1.20.14" {
|
||||||
|
$env:GOTOOLCHAIN = "go1.20.14"
|
||||||
|
$env:GOWORK = "off"
|
||||||
|
go -C core vet ./...
|
||||||
|
Assert-NativeSuccess -Step "Vet core"
|
||||||
|
go -C core test -count=1 ./...
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Test and build modern target with Go 1.25.0" {
|
||||||
|
$env:GOTOOLCHAIN = "go1.25.0"
|
||||||
|
$env:GOWORK = (Resolve-Path "go.work").Path
|
||||||
|
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
go -C app-modern test -count=1 ./ui/gio ./platform/windows
|
||||||
|
Assert-NativeSuccess -Step "Test modern adapters"
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
go -C app-modern build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox.exe ./cmd/softbox
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Test and build Win7 target with Go 1.20.14" {
|
||||||
|
$env:GOTOOLCHAIN = "go1.20.14"
|
||||||
|
$env:GOWORK = (Resolve-Path "app-win7/go.work").Path
|
||||||
|
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
go -C app-win7 test -count=1 ./ui/gio ./platform/windows
|
||||||
|
Assert-NativeSuccess -Step "Test Win7 adapters"
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
go -C app-win7 build -trimpath "-ldflags=-H=windowsgui" -o ../dist/SoftBox-win7.exe ./cmd/softbox
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Phase 0 verification passed."
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if python3 --version >/dev/null 2>&1; then
|
||||||
|
PYTHON=python3
|
||||||
|
elif python --version >/dev/null 2>&1; then
|
||||||
|
PYTHON=python
|
||||||
|
else
|
||||||
|
echo "ERROR: Python 3 is required." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
GO_PATH="$(go env GOPATH)"
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
GO_PATH="$(cygpath -u "$GO_PATH")"
|
||||||
|
fi
|
||||||
|
GO_BIN="$GO_PATH/bin"
|
||||||
|
if [ -x "$GO_BIN/go1.20.14" ] || [ -x "$GO_BIN/go1.20.14.exe" ]; then
|
||||||
|
export PATH="$GO_BIN:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
echo "==> Sync root workspace"
|
||||||
|
GOTOOLCHAIN=go1.25.0 go work sync
|
||||||
|
|
||||||
|
echo "==> Validate harness governance"
|
||||||
|
"$PYTHON" scripts/validate_agent_context.py
|
||||||
|
"$PYTHON" -m unittest discover -s tests -p "test_*.py"
|
||||||
|
"$PYTHON" scripts/validate_harness_governance.py
|
||||||
|
|
||||||
|
echo "==> Validate core architecture boundary"
|
||||||
|
"$PYTHON" scripts/check_core_boundaries.py --self-test
|
||||||
|
"$PYTHON" scripts/check_core_boundaries.py
|
||||||
|
|
||||||
|
echo "==> Validate Go and dependency pins"
|
||||||
|
GOTOOLCHAIN=go1.20.14 "$PYTHON" scripts/check_go_versions.py
|
||||||
|
|
||||||
|
echo "==> Vet and test core with Go 1.20.14"
|
||||||
|
GOTOOLCHAIN=go1.20.14 GOWORK=off go -C core vet ./...
|
||||||
|
GOTOOLCHAIN=go1.20.14 GOWORK=off go -C core test -count=1 ./...
|
||||||
|
|
||||||
|
echo "==> Test and build modern target with Go 1.25.0"
|
||||||
|
GOTOOLCHAIN=go1.25.0 GOWORK="$ROOT_DIR/go.work" CGO_ENABLED=0 \
|
||||||
|
go -C app-modern test -count=1 ./ui/gio ./platform/windows
|
||||||
|
GOTOOLCHAIN=go1.25.0 GOWORK="$ROOT_DIR/go.work" CGO_ENABLED=0 \
|
||||||
|
GOOS=windows GOARCH=amd64 \
|
||||||
|
go -C app-modern build -trimpath -ldflags="-H=windowsgui" \
|
||||||
|
-o ../dist/SoftBox.exe ./cmd/softbox
|
||||||
|
|
||||||
|
echo "==> Test and build Win7 target with Go 1.20.14"
|
||||||
|
GOTOOLCHAIN=go1.20.14 GOWORK="$ROOT_DIR/app-win7/go.work" CGO_ENABLED=0 \
|
||||||
|
go -C app-win7 test -count=1 ./ui/gio ./platform/windows
|
||||||
|
GOTOOLCHAIN=go1.20.14 GOWORK="$ROOT_DIR/app-win7/go.work" CGO_ENABLED=0 \
|
||||||
|
GOOS=windows GOARCH=amd64 \
|
||||||
|
go -C app-win7 build -trimpath -ldflags="-H=windowsgui" \
|
||||||
|
-o ../dist/SoftBox-win7.exe ./cmd/softbox
|
||||||
|
|
||||||
|
echo "Phase 0 verification passed."
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# 测试数据
|
||||||
|
|
||||||
|
本目录只保存公开、虚构、不可用于生产的数据与攻击样例。
|
||||||
|
|
||||||
|
- 不放生产私钥、真实注册码、真实机器标识或真实下载地址。
|
||||||
|
- 测试若需要签名,使用测试代码中明确标注的专用测试密钥。
|
||||||
|
- 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。
|
||||||
|
- `catalog/manifest-valid-payload.json` 同时作为 manifest v1 强类型解析与目标过滤的公开虚构样例;包哈希与签名只保证格式合法,不对应真实下载物。
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"channel": "modern",
|
||||||
|
"generated_at": "2026-07-16T00:00:00Z",
|
||||||
|
"min_box_version": "1.0.0",
|
||||||
|
"apps": [],
|
||||||
|
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user