Add app details and icon cache (T-204)
This commit is contained in:
+223
-11
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
@@ -60,10 +61,13 @@ type AppShell struct {
|
||||
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.
|
||||
@@ -78,12 +82,22 @@ func NewAppShell(
|
||||
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)
|
||||
@@ -126,6 +140,7 @@ func NewTheme() *material.Theme {
|
||||
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 {
|
||||
@@ -177,6 +192,9 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
for shell.closeDetail.Clicked(gtx) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
@@ -320,14 +338,35 @@ func (shell *AppShell) layoutContent(
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
gtx,
|
||||
shellColors.surface,
|
||||
unit.Dp(10),
|
||||
layout.UniformInset(unit.Dp(16)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutCatalog(gtx, theme)
|
||||
},
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -407,7 +446,14 @@ func (shell *AppShell) layoutAppRow(
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLetterIcon(gtx, theme, item.Name)
|
||||
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 {
|
||||
@@ -454,14 +500,32 @@ func (shell *AppShell) layoutAppRow(
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLetterIcon(
|
||||
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(unit.Dp(48))
|
||||
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)
|
||||
@@ -470,7 +534,7 @@ func (shell *AppShell) layoutLetterIcon(
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
unit.Dp(8),
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -482,6 +546,114 @@ func (shell *AppShell) layoutLetterIcon(
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -708,3 +880,43 @@ func actionLabel(item application.CatalogListItem) string {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,38 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
+250
-35
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
@@ -60,10 +61,13 @@ type AppShell struct {
|
||||
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.
|
||||
@@ -78,12 +82,22 @@ func NewAppShell(
|
||||
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)
|
||||
@@ -126,6 +140,7 @@ func NewTheme() *material.Theme {
|
||||
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 {
|
||||
@@ -181,6 +196,9 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
|
||||
shell.search.SetText("")
|
||||
shell.model.ResetFilters()
|
||||
}
|
||||
for shell.closeDetail.Clicked(gtx) {
|
||||
shell.model.Select("")
|
||||
}
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutHeader(
|
||||
@@ -309,45 +327,70 @@ func (shell *AppShell) layoutContent(
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
selected, hasSelection := shell.model.SelectedItem()
|
||||
return layout.Flex{}.Layout(
|
||||
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(
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return panel(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
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(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()),
|
||||
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)
|
||||
}),
|
||||
)
|
||||
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(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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -388,7 +431,14 @@ func (shell *AppShell) layoutAppRow(
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutLetterIcon(gtx, theme, item.Name)
|
||||
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 {
|
||||
@@ -419,14 +469,32 @@ func (shell *AppShell) layoutAppRow(
|
||||
})
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutLetterIcon(
|
||||
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(unit.Dp(40))
|
||||
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)
|
||||
@@ -435,7 +503,7 @@ func (shell *AppShell) layoutLetterIcon(
|
||||
return panel(
|
||||
gtx,
|
||||
shellColors.primary,
|
||||
unit.Dp(5),
|
||||
radius,
|
||||
layout.UniformInset(unit.Dp(0)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -447,6 +515,113 @@ func (shell *AppShell) layoutLetterIcon(
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -626,3 +801,43 @@ func statusColor(status domain.AppStatus) color.NRGBA {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,38 @@ func TestAppShellKeepsRowControlsByAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@@ -23,6 +23,9 @@ type CatalogListItem struct {
|
||||
Version string
|
||||
Category string
|
||||
Tags []string
|
||||
IconRef string
|
||||
Homepage string
|
||||
Tutorial string
|
||||
Status domain.AppStatus
|
||||
Installed bool
|
||||
Installable bool
|
||||
@@ -141,6 +144,16 @@ 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 ||
|
||||
|
||||
@@ -64,6 +64,10 @@ func TestCatalogListModelPreservesStableOrderAndSelection(t *testing.T) {
|
||||
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() != "" {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -61,6 +61,8 @@ UI 固定交互模式:
|
||||
|
||||
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
|
||||
|
||||
+14
-1
@@ -76,6 +76,19 @@
|
||||
|
||||
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)
|
||||
|
||||
```text
|
||||
@@ -267,6 +280,6 @@ V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare
|
||||
|
||||
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
|
||||
- 错误码完整枚举表。
|
||||
- 图标资源的分发方式(内嵌哈希 vs 独立 URL)。
|
||||
- 图标资源从内容哈希到下载位置/分辨率变体的发布端映射格式。
|
||||
- 撤销名单的结构与宽限期时长。
|
||||
- machine_hash 的标识来源清单与加权算法(平台层内部文档)。
|
||||
|
||||
+10
-10
@@ -13,26 +13,26 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-16
|
||||
- 阶段:Phase 2 进行中;T-201~T-203 已完成,下一步 T-204 软件详情与图标缓存
|
||||
- 阶段:Phase 2 已完成(T-201~T-204);下一步 Phase 3 的 T-301 下载队列
|
||||
- 技术栈:根 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 已有 Catalog/本地状态/存储与无 IO 软件列表模型;modern/win7 AppShell 已实现搜索、分类、全部/已安装/可更新切换和惰性软件列表;安装安全原型保持可用
|
||||
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录与列表筛选;两个 app 覆盖 AppShell、500 项虚拟列表、ID 控件稳定性与平台 stub;ZIP/安装恢复矩阵保持通过
|
||||
- 生产代码:core 已有 Catalog/本地状态/存储、无 IO 软件列表模型与按 digest+DPI 的可信图标缓存;modern/win7 AppShell 已实现搜索/分类/视图、惰性列表、详情右栏与内存图标
|
||||
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、列表筛选和图标内存/磁盘/离线/损坏恢复;两个 app 覆盖 500 项虚拟列表、ID 控件稳定性、详情/ApplyIcon 与平台 stub;ZIP/安装恢复矩阵保持通过
|
||||
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json v1 Schema;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 记录运行时生成的 ZIP 攻击矩阵
|
||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||
- 当前 blocker:无;下一步按路线图落成并领取 T-204
|
||||
- 当前 blocker:无;下一步按路线图落成并领取 T-301
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 T-201~T-203 已完成;T-204 待按路线图落成 |
|
||||
| `docs/tasks/` | 已有 | Phase 0、Phase 1 与 Phase 2 任务均已完成;T-301 待按路线图落成 |
|
||||
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有状态/事件、正式 Catalog、本地安装状态/存储与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟软件列表 |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本虚拟软件列表 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、列表模型、图标缓存与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情和内存图标 |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情和内存图标 |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json` 与 `installed-app.schema.json` |
|
||||
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`、`T-202`、`T-203`。
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:按路线图落成并领取 `T-204 软件详情与图标缓存`。
|
||||
- 下一个可领取任务:按路线图落成并领取 `T-301 下载队列`。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+8
-1
@@ -24,7 +24,7 @@
|
||||
| 视图 | 职责 | MVP |
|
||||
| --- | --- | --- |
|
||||
| 软件列表(主视图) | T-203 已实现全部/已安装/可更新切换、名称/ID/tag 即时搜索、单分类筛选、稳定滚动与明确空状态;下载中/最近使用和多标签复选随对应用例后续接入 | P0 |
|
||||
| 软件详情(弹层或右栏) | 版本、简介、教程链接、授权状态;下载/更新/启动/取消操作 | P0 |
|
||||
| 软件详情(弹层或右栏) | T-204 已实现右栏、关闭、版本/分类/简介/tags/状态/不可用原因/教程/主页展示与内存图标;真实操作和授权事实随对应模块接入 | P0 |
|
||||
| 下载队列 | 所有任务的进度、速度、剩余时间;暂停/取消/重试 | P0 |
|
||||
| 设置 | 并发数、目录、代理、自动检查更新、beta 通道、日志级别、便携模式(V2) | P0(最小集) |
|
||||
| 授权 | 许可证导入、已授权软件列表、machine 信息、换绑/申诉入口 | P0 |
|
||||
@@ -61,6 +61,13 @@ T-203 已落地的列表交互约束:
|
||||
- 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。
|
||||
- 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。
|
||||
|
||||
T-204 已落地的详情/图标约束:
|
||||
|
||||
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
|
||||
- modern 与 Legacy 均显示版本、分类、简介、tags、状态、不可用原因、教程和主页文本;尚未接入的安装/启动/授权不伪装为已可执行操作。
|
||||
- 后台把已验证图标解码为 `image.Image` 后调用 `ApplyIcon`;该方法预建 `paint.ImageOp`,列表与详情 Layout 只绘制内存操作。
|
||||
- 图标未命中或离线缓存不可用时显示非 emoji 的字母占位,不阻塞列表或详情。
|
||||
|
||||
## 导航规则
|
||||
|
||||
- 列表 → 详情:点击软件项;详情保留返回/关闭。
|
||||
|
||||
@@ -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` 与本任务无关,已保留且未纳入提交。
|
||||
Reference in New Issue
Block a user