Implement authorization import and revocation checks (T-503)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -164,14 +171,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)
|
||||
@@ -190,3 +209,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
|
||||
}
|
||||
|
||||
@@ -72,12 +72,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(16)}.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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user