Implement unsafe icon cache diagnostics (T-611)
This commit is contained in:
@@ -9,6 +9,11 @@ import (
|
||||
|
||||
var ErrIconRequestStale = errors.New("icon request no longer matches catalog")
|
||||
|
||||
type iconFailureState struct {
|
||||
Identity application.IconEventIdentity
|
||||
Code application.IconFailureCode
|
||||
}
|
||||
|
||||
// ExpectIcon records the newest request identity on the UI goroutine.
|
||||
func (shell *AppShell) ExpectIcon(identity application.IconEventIdentity) error {
|
||||
validated, err := application.NewIconEventIdentity(
|
||||
@@ -45,6 +50,7 @@ func (shell *AppShell) CancelIconRequest(appID, requestID string) bool {
|
||||
return false
|
||||
}
|
||||
delete(shell.iconRequests, appID)
|
||||
delete(shell.iconFailures, appID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -73,7 +79,10 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
if !hasApplied || !sameIconResource(applied, identity) {
|
||||
shell.ApplyIcon(identity.AppID, nil)
|
||||
}
|
||||
shell.iconFailures[identity.AppID] = iconEvent.ErrorCode
|
||||
shell.iconFailures[identity.AppID] = iconFailureState{
|
||||
Identity: identity,
|
||||
Code: iconEvent.ErrorCode,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -81,7 +90,7 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
|
||||
// IconFailure exposes the last failure for diagnostics without raw network data.
|
||||
func (shell *AppShell) IconFailure(appID string) (application.IconFailureCode, bool) {
|
||||
failure, exists := shell.iconFailures[appID]
|
||||
return failure, exists
|
||||
return failure.Code, exists
|
||||
}
|
||||
|
||||
func canonicalIconReference(reference string) string {
|
||||
|
||||
@@ -230,6 +230,164 @@ func TestAppShellRejectsMalformedIconEventAndIgnoresOtherEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRendersOnlyUnsafeIconCacheDiagnostic(t *testing.T) {
|
||||
reference := testIconReference("55")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
identity := testIconIdentity(t, "request-unsafe", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, identity, application.IconFailureUnsafe)
|
||||
shell.model.Select(item.ID)
|
||||
|
||||
nodes := adapterContractLayout(shell, adapterContractViewport)
|
||||
failure := shell.iconFailures[item.ID]
|
||||
for _, want := range []string{
|
||||
"图标缓存安全警告",
|
||||
unsafeIconCacheMessage,
|
||||
unsafeIconCacheDiagnostic(failure),
|
||||
} {
|
||||
if !adapterContractHasSemantic(nodes, want) {
|
||||
t.Fatalf("unsafe cache detail is missing semantic text %q", want)
|
||||
}
|
||||
}
|
||||
if diagnostic := unsafeIconCacheDiagnostic(failure); strings.Contains(diagnostic, "sha256:") {
|
||||
t.Fatalf("unsafe cache diagnostic exposed the reference scheme: %q", diagnostic)
|
||||
}
|
||||
|
||||
unavailable := testIconIdentity(t, "request-unavailable", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(unavailable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, unavailable, application.IconFailureUnavailable)
|
||||
nodes = adapterContractLayout(shell, adapterContractViewport)
|
||||
if adapterContractHasSemantic(nodes, "图标缓存安全警告") {
|
||||
t.Fatal("ordinary icon failure rendered an unsafe-cache warning")
|
||||
}
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnavailable {
|
||||
t.Fatalf("IconFailure() = (%q, %t)", code, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppShellRetainsUnsafeDiagnosticOnlyForCurrentResource(t *testing.T) {
|
||||
reference := testIconReference("66")
|
||||
item := application.CatalogListItem{
|
||||
ID: "app-one",
|
||||
Name: "One",
|
||||
Version: "1.0.0",
|
||||
IconRef: reference,
|
||||
}
|
||||
shell := NewAppShell("Test", item)
|
||||
first := testIconIdentity(t, "request-first", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstFailure := applyTestIconFailure(
|
||||
t,
|
||||
shell,
|
||||
first,
|
||||
application.IconFailureUnsafe,
|
||||
)
|
||||
if got := shell.iconFailures[item.ID].Identity; got != first {
|
||||
t.Fatalf("stored failure identity = %+v, want %+v", got, first)
|
||||
}
|
||||
|
||||
shell.SetItems([]application.CatalogListItem{item})
|
||||
if code, exists := shell.IconFailure(item.ID); !exists ||
|
||||
code != application.IconFailureUnsafe {
|
||||
t.Fatal("same-reference snapshot discarded the unsafe diagnostic")
|
||||
}
|
||||
|
||||
latest := testIconIdentity(t, "request-latest", item.ID, reference, 96)
|
||||
if err := shell.ExpectIcon(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("new request retained the previous unsafe diagnostic")
|
||||
}
|
||||
if err := shell.ApplyEvent(firstFailure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("late failure restored a stale diagnostic")
|
||||
}
|
||||
ready, err := application.NewIconReadyEvent(
|
||||
latest,
|
||||
image.NewNRGBA(image.Rect(0, 0, 16, 16)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(ready); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("ready event retained an unsafe diagnostic")
|
||||
}
|
||||
|
||||
dpiRequest := testIconIdentity(t, "request-dpi", item.ID, reference, 144)
|
||||
if err := shell.ExpectIcon(dpiRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, dpiRequest, application.IconFailureUnsafe)
|
||||
if got := shell.iconFailures[item.ID].Identity.DPI; got != 144 {
|
||||
t.Fatalf("stored failure DPI = %d, want 144", got)
|
||||
}
|
||||
|
||||
newReference := testIconReference("77")
|
||||
changed := item
|
||||
changed.IconRef = newReference
|
||||
shell.SetItems([]application.CatalogListItem{changed})
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("IconRef change retained the unsafe diagnostic")
|
||||
}
|
||||
canceled := testIconIdentity(t, "request-canceled", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(canceled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !shell.CancelIconRequest(item.ID, canceled.RequestID) {
|
||||
t.Fatal("CancelIconRequest() did not cancel the current request")
|
||||
}
|
||||
applyTestIconFailure(t, shell, canceled, application.IconFailureUnsafe)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("canceled failure created an unsafe diagnostic")
|
||||
}
|
||||
|
||||
final := testIconIdentity(t, "request-final", item.ID, newReference, 96)
|
||||
if err := shell.ExpectIcon(final); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyTestIconFailure(t, shell, final, application.IconFailureUnsafe)
|
||||
shell.SetItems(nil)
|
||||
if _, exists := shell.IconFailure(item.ID); exists {
|
||||
t.Fatal("removed app retained the unsafe diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func applyTestIconFailure(
|
||||
t *testing.T,
|
||||
shell *AppShell,
|
||||
identity application.IconEventIdentity,
|
||||
code application.IconFailureCode,
|
||||
) application.Event {
|
||||
t.Helper()
|
||||
event, err := application.NewIconFailedEvent(identity, code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shell.ApplyEvent(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func testIconReference(pair string) string {
|
||||
return "sha256:" + strings.Repeat(pair, 32)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type AppShell struct {
|
||||
iconReferences map[string]string
|
||||
iconRequests map[string]application.IconEventIdentity
|
||||
iconApplied map[string]application.IconEventIdentity
|
||||
iconFailures map[string]application.IconFailureCode
|
||||
iconFailures map[string]iconFailureState
|
||||
lastRendered int
|
||||
detailRendered bool
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func NewAppShell(
|
||||
iconReferences: make(map[string]string),
|
||||
iconRequests: make(map[string]application.IconEventIdentity),
|
||||
iconApplied: make(map[string]application.IconEventIdentity),
|
||||
iconFailures: make(map[string]application.IconFailureCode),
|
||||
iconFailures: make(map[string]iconFailureState),
|
||||
}
|
||||
shell.search.SingleLine = true
|
||||
shell.SetItems(items)
|
||||
@@ -82,7 +82,7 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
nextReferences := make(map[string]string, len(items))
|
||||
nextRequests := make(map[string]application.IconEventIdentity, len(items))
|
||||
nextApplied := make(map[string]application.IconEventIdentity, len(items))
|
||||
nextFailures := make(map[string]application.IconFailureCode, len(items))
|
||||
nextFailures := make(map[string]iconFailureState, len(items))
|
||||
for _, item := range items {
|
||||
controls := shell.rows[item.ID]
|
||||
if controls == nil {
|
||||
@@ -101,7 +101,8 @@ func (shell *AppShell) SetItems(items []application.CatalogListItem) {
|
||||
if applied, exists := shell.iconApplied[item.ID]; exists && applied.Reference == reference {
|
||||
nextApplied[item.ID] = applied
|
||||
}
|
||||
if failure, exists := shell.iconFailures[item.ID]; exists {
|
||||
if failure, exists := shell.iconFailures[item.ID]; exists &&
|
||||
failure.Identity.Reference == reference {
|
||||
nextFailures[item.ID] = failure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"softbox.local/core/domain"
|
||||
)
|
||||
|
||||
const unsafeIconCacheMessage = "检测到不安全的图标缓存项。该缓存项未被使用,本次请求没有继续远端获取或自动修复。请完全退出 SoftBox 后,按故障排查文档由管理员人工处理。"
|
||||
|
||||
func (shell *AppShell) layoutDetail(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
@@ -42,6 +44,9 @@ func (shell *AppShell) layoutDetail(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return shell.layoutUnsafeIconCacheFailure(gtx, theme, item.ID)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -120,6 +125,59 @@ func (shell *AppShell) layoutDetail(
|
||||
)
|
||||
}
|
||||
|
||||
func (shell *AppShell) layoutUnsafeIconCacheFailure(
|
||||
gtx layout.Context,
|
||||
theme *material.Theme,
|
||||
appID string,
|
||||
) layout.Dimensions {
|
||||
failure, exists := shell.iconFailures[appID]
|
||||
if !exists || failure.Code != application.IconFailureUnsafe ||
|
||||
failure.Identity.AppID != appID {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return outlinedPanel(
|
||||
gtx,
|
||||
shellColors.destructive,
|
||||
shellColors.surface,
|
||||
unit.Dp(8),
|
||||
layout.UniformInset(unit.Dp(12)),
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||
gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
title := material.Body1(theme, "图标缓存安全警告")
|
||||
title.Color = shellColors.destructive
|
||||
return title.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Body2(theme, unsafeIconCacheMessage).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
diagnostic := material.Caption(theme, unsafeIconCacheDiagnostic(failure))
|
||||
diagnostic.Color = shellColors.secondary
|
||||
return diagnostic.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func unsafeIconCacheDiagnostic(failure iconFailureState) string {
|
||||
return fmt.Sprintf(
|
||||
"诊断码:%s\n应用 ID:%s\n缓存定位符:%s",
|
||||
failure.Code,
|
||||
failure.Identity.AppID,
|
||||
unsafeIconCacheLocator(failure.Identity),
|
||||
)
|
||||
}
|
||||
|
||||
func unsafeIconCacheLocator(identity application.IconEventIdentity) string {
|
||||
digest := strings.TrimPrefix(identity.Reference, "sha256:")
|
||||
return fmt.Sprintf("%s-%d.icon", digest, identity.DPI)
|
||||
}
|
||||
|
||||
func actionLabel(item application.CatalogListItem) string {
|
||||
if item.Reason != "" || item.Status == domain.StatusIncompatible {
|
||||
return "查看不可用原因"
|
||||
|
||||
Reference in New Issue
Block a user