Implement authorization import and revocation checks (T-503)

This commit is contained in:
ila
2026-07-20 09:07:30 +08:00
parent b4453130de
commit 76f6108496
36 changed files with 2197 additions and 68 deletions
+3
View File
@@ -59,6 +59,9 @@ func (shell *AppShell) ApplyEvent(event application.Event) error {
if handled, err := shell.applyCatalogEvent(event); err != nil || handled {
return err
}
if handled, err := shell.applyAuthorizationEvent(event); err != nil || handled {
return err
}
iconEvent, handled, err := application.ParseIconEvent(event)
if err != nil || !handled {
return err
+13
View File
@@ -0,0 +1,13 @@
package gio
import "softbox.local/core/application"
func (shell *AppShell) applyAuthorizationEvent(event application.Event) (bool, error) {
payload, handled, err := application.ParseAuthorizationEvent(event)
if err != nil || !handled {
return handled, err
}
shell.authorization = payload.Snapshot
shell.authorizationLoaded = true
return true, nil
}
+43
View File
@@ -0,0 +1,43 @@
package gio
import (
"testing"
"softbox.local/core/application"
)
func TestAuthorizationViewRendersSanitizedStateAndQueuesImport(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
State: application.AuthorizationStateGrace,
MachineHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Products: []application.AuthorizedProduct{{
ProductID: "product-test", Kind: application.LicenseKindNonPerpetual, RebindPolicy: "support-only",
}},
})); err != nil {
t.Fatal(err)
}
shell.viewLicense.Click()
nodes := adapterContractLayout(shell, adapterContractViewport)
if !shell.showLicense || !adapterContractHasSemantic(nodes, "授权") ||
!adapterContractHasSemantic(nodes, "导入许可证") ||
!adapterContractHasSemantic(nodes, "product-test · 非永久许可证(License v1 未提供试用到期信息)") {
t.Fatal("authorization state did not render its accessible content")
}
shell.importLicense.Click()
adapterContractLayout(shell, adapterContractViewport)
if !shell.TakeLicenseImportRequest() || shell.TakeLicenseImportRequest() {
t.Fatal("import click did not produce exactly one UI-only request")
}
if err := shell.ApplyEvent(application.NewAuthorizationEvent(application.AuthorizationSnapshot{
State: application.AuthorizationStateUnconfigured, Products: []application.AuthorizedProduct{},
})); err != nil {
t.Fatal(err)
}
shell.importLicense.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.TakeLicenseImportRequest() {
t.Fatal("unconfigured authorization accepted an import request")
}
}
+39 -10
View File
@@ -24,19 +24,25 @@ type AppShell struct {
viewAll widget.Clickable
viewInstalled widget.Clickable
viewUpdates widget.Clickable
viewLicense widget.Clickable
resetFilters widget.Clickable
closeDetail widget.Clickable
importLicense widget.Clickable
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]iconFailureState
catalogState catalogPresentationState
lastRendered int
detailRendered bool
categoryControls map[string]*widget.Clickable
rows map[string]*rowControls
icons map[string]paint.ImageOp
iconReferences map[string]string
iconRequests map[string]application.IconEventIdentity
iconApplied map[string]application.IconEventIdentity
iconFailures map[string]iconFailureState
catalogState catalogPresentationState
authorization application.AuthorizationSnapshot
authorizationLoaded bool
showLicense bool
licenseImportRequested bool
lastRendered int
detailRendered bool
}
// NewAppShell creates the catalog shell with an optional in-memory snapshot.
@@ -56,6 +62,7 @@ func NewAppShell(
iconRequests: make(map[string]application.IconEventIdentity),
iconApplied: make(map[string]application.IconEventIdentity),
iconFailures: make(map[string]iconFailureState),
authorization: application.AuthorizationSnapshot{Products: []application.AuthorizedProduct{}},
}
shell.search.SingleLine = true
shell.SetItems(items)
@@ -168,14 +175,26 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
}
shell.model.SetQuery(shell.search.Text())
for shell.viewAll.Clicked(gtx) {
shell.showLicense = false
shell.model.SetView(application.CatalogViewAll)
}
for shell.viewInstalled.Clicked(gtx) {
shell.showLicense = false
shell.model.SetView(application.CatalogViewInstalled)
}
for shell.viewUpdates.Clicked(gtx) {
shell.showLicense = false
shell.model.SetView(application.CatalogViewUpdates)
}
for shell.viewLicense.Clicked(gtx) {
shell.showLicense = true
shell.model.Select("")
}
for shell.importLicense.Clicked(gtx) {
if shell.canImportLicense() {
shell.licenseImportRequested = true
}
}
for category, control := range shell.categoryControls {
for control.Clicked(gtx) {
shell.model.SetCategory(category)
@@ -194,3 +213,13 @@ func (shell *AppShell) drainInput(gtx layout.Context) {
shell.model.Select("")
}
}
// TakeLicenseImportRequest consumes one UI-only import request. Composition
// must perform any picker, file I/O and verification in a background task.
func (shell *AppShell) TakeLicenseImportRequest() bool {
if shell == nil || !shell.licenseImportRequested {
return false
}
shell.licenseImportRequested = false
return true
}
+22
View File
@@ -65,12 +65,34 @@ func (shell *AppShell) layoutContent(
application.CatalogViewUpdates,
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return shell.layoutFilterButton(
gtx,
theme,
&shell.viewLicense,
"授权",
shell.showLicense,
)
}),
)
},
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if shell.showLicense {
return panel(
gtx,
shellColors.surface,
unit.Dp(10),
layout.UniformInset(unit.Dp(16)),
func(gtx layout.Context) layout.Dimensions {
return shell.layoutLicensePanel(gtx, theme)
},
)
}
selected, hasSelection := shell.model.SelectedItem()
return layout.Flex{}.Layout(
gtx,
+115
View File
@@ -0,0 +1,115 @@
package gio
import (
"fmt"
"gioui.org/io/semantic"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget/material"
"softbox.local/core/application"
)
func (shell *AppShell) layoutLicensePanel(gtx layout.Context, theme *material.Theme) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(
gtx,
layout.Rigid(material.H5(theme, "授权").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return licenseText(gtx, theme, shell.licenseStatusText())
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if shell.authorization.MachineHash == "" {
return licenseText(gtx, theme, "机器信息:授权配置完成后显示 machine_hash")
}
return licenseText(gtx, theme, "机器信息(machine_hash):"+shell.authorization.MachineHash)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !shell.canImportLicense() {
return licenseText(gtx, theme, "许可证导入:当前构建未配置可信授权来源")
}
return shell.layoutLicenseImportButton(gtx, theme)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(material.H6(theme, "已授权产品").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(shell.authorization.Products) == 0 {
return licenseText(gtx, theme, "暂无可用授权。试用功能需要服务端签发可验证的许可证,本机不会创建试用授权。")
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, licenseProductWidgets(shell.authorization.Products, theme)...)
}),
)
}
func (shell *AppShell) layoutLicenseImportButton(gtx layout.Context, theme *material.Theme) layout.Dimensions {
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(44))
button := material.Button(theme, &shell.importLicense, "导入许可证")
button.Background = shellColors.primary
button.Color = shellColors.onPrimary
button.Inset = layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10), Left: unit.Dp(14), Right: unit.Dp(14)}
semantic.Button.Add(gtx.Ops)
semantic.DescriptionOp("导入许可证文件,验证在后台完成").Add(gtx.Ops)
return button.Layout(gtx)
}
func licenseProductWidgets(products []application.AuthorizedProduct, theme *material.Theme) []layout.FlexChild {
widgets := make([]layout.FlexChild, 0, len(products)*3)
for _, product := range products {
product := product
widgets = append(widgets,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return licenseText(gtx, theme, fmt.Sprintf("%s · %s", product.ProductID, licenseKindText(product.Kind)))
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return licenseText(gtx, theme, "换绑/申诉:"+product.RebindPolicy+";不会在本机修改绑定")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
)
}
return widgets
}
func (shell *AppShell) canImportLicense() bool {
return shell.authorizationLoaded && shell.authorization.State != application.AuthorizationStateUnconfigured
}
func (shell *AppShell) licenseStatusText() string {
if !shell.authorizationLoaded {
return "授权状态:正在加载"
}
switch shell.authorization.State {
case application.AuthorizationStateReady:
return "授权状态:撤销名单有效,当前授权可用"
case application.AuthorizationStateGrace:
return "授权状态:撤销名单已过期,处于 7 天离线宽限期"
case application.AuthorizationStateNoLicense:
return "授权状态:未找到当前机器的有效许可证"
case application.AuthorizationStateRevoked:
return "授权状态:许可证已撤销,不能启动受保护软件"
case application.AuthorizationStateUnavailable:
return "授权状态:撤销验证不可用,不能启动受保护软件"
case application.AuthorizationStateImportFailed:
return "授权状态:导入失败;原许可证与授权状态未被信任地替换"
case application.AuthorizationStateUnconfigured:
return "授权状态:可信授权来源尚未配置"
default:
return "授权状态:不可用"
}
}
func licenseKindText(kind application.LicenseKind) string {
if kind == application.LicenseKindPerpetual {
return "正式许可证"
}
return "非永久许可证(License v1 未提供试用到期信息)"
}
func licenseText(gtx layout.Context, theme *material.Theme, value string) layout.Dimensions {
label := material.Body2(theme, value)
label.Color = shellColors.secondary
return label.Layout(gtx)
}