diff --git a/app-modern/ui/gio/shell.go b/app-modern/ui/gio/shell.go index 800b379..3536296 100644 --- a/app-modern/ui/gio/shell.go +++ b/app-modern/ui/gio/shell.go @@ -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 + } +} diff --git a/app-modern/ui/gio/shell_test.go b/app-modern/ui/gio/shell_test.go index 1c0874c..c137ff9 100644 --- a/app-modern/ui/gio/shell_test.go +++ b/app-modern/ui/gio/shell_test.go @@ -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{ diff --git a/app-win7/ui/gio/shell.go b/app-win7/ui/gio/shell.go index dc3faec..cb66763 100644 --- a/app-win7/ui/gio/shell.go +++ b/app-win7/ui/gio/shell.go @@ -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 + } +} diff --git a/app-win7/ui/gio/shell_test.go b/app-win7/ui/gio/shell_test.go index 0e57186..5be62c2 100644 --- a/app-win7/ui/gio/shell_test.go +++ b/app-win7/ui/gio/shell_test.go @@ -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{ diff --git a/core/application/catalog_list.go b/core/application/catalog_list.go index ba52d7a..e89fc6b 100644 --- a/core/application/catalog_list.go +++ b/core/application/catalog_list.go @@ -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 || diff --git a/core/application/catalog_list_test.go b/core/application/catalog_list_test.go index cb4284b..564ae29 100644 --- a/core/application/catalog_list_test.go +++ b/core/application/catalog_list_test.go @@ -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() != "" { diff --git a/core/catalog/icon_cache.go b/core/catalog/icon_cache.go new file mode 100644 index 0000000..3c2c459 --- /dev/null +++ b/core/catalog/icon_cache.go @@ -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 +} diff --git a/core/catalog/icon_cache_test.go b/core/catalog/icon_cache_test.go new file mode 100644 index 0000000..0bba92e --- /dev/null +++ b/core/catalog/icon_cache_test.go @@ -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() +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3a3663b..61eca68 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 diff --git a/docs/api.md b/docs/api.md index e64ebed..6ce8ece 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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;磁盘文件名为 `-.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.`,盒子发送 `{"command": "prepare - 清单密钥 ID/轮换字段定稿后同步 `schemas/`。 - 错误码完整枚举表。 -- 图标资源的分发方式(内嵌哈希 vs 独立 URL)。 +- 图标资源从内容哈希到下载位置/分辨率变体的发布端映射格式。 - 撤销名单的结构与宽限期时长。 - machine_hash 的标识来源清单与加权算法(平台层内部文档)。 diff --git a/docs/current-state.md b/docs/current-state.md index 0d7a51e..a7be0b8 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 下载队列`。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index a706b8d..dbf355e 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 的字母占位,不阻塞列表或详情。 + ## 导航规则 - 列表 → 详情:点击软件项;详情保留返回/关闭。 diff --git a/docs/tasks/T-204.md b/docs/tasks/T-204.md new file mode 100644 index 0000000..3ea7155 --- /dev/null +++ b/docs/tasks/T-204.md @@ -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:` + 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` 与本任务无关,已保留且未纳入提交。