Sync API mutations into shared session state
This commit is contained in:
+85
-15
@@ -38,6 +38,7 @@ type Server struct {
|
|||||||
clipboard clipboard.Writer
|
clipboard clipboard.Writer
|
||||||
approvals *apiapproval.Broker
|
approvals *apiapproval.Broker
|
||||||
audit *apiaudit.Log
|
audit *apiaudit.Log
|
||||||
|
notify func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleBackend interface {
|
type lifecycleBackend interface {
|
||||||
@@ -54,6 +55,13 @@ type modelReplaceableLifecycle interface {
|
|||||||
Replace(vault.Model)
|
Replace(vault.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rankedBrowserMatch struct {
|
||||||
|
match *keepassgov1.BrowserLoginMatch
|
||||||
|
score int
|
||||||
|
resource apitokens.Resource
|
||||||
|
decision apitokens.Decision
|
||||||
|
}
|
||||||
|
|
||||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
model: model,
|
model: model,
|
||||||
@@ -78,6 +86,15 @@ func (s *Server) AuditLog() *apiaudit.Log {
|
|||||||
return s.audit
|
return s.audit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetChangeNotifier(notify func()) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
return s.approvals.Resolve(id, outcome)
|
return s.approvals.Resolve(id, outcome)
|
||||||
}
|
}
|
||||||
@@ -232,7 +249,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
if locked {
|
if locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -241,17 +258,14 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
type rankedMatch struct {
|
var matches []rankedBrowserMatch
|
||||||
match *keepassgov1.BrowserLoginMatch
|
|
||||||
score int
|
|
||||||
}
|
|
||||||
var matches []rankedMatch
|
|
||||||
for _, entry := range visibleModel(model).Entries {
|
for _, entry := range visibleModel(model).Entries {
|
||||||
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||||
if score == 0 {
|
if score == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matches = append(matches, rankedMatch{
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
||||||
|
matches = append(matches, rankedBrowserMatch{
|
||||||
match: &keepassgov1.BrowserLoginMatch{
|
match: &keepassgov1.BrowserLoginMatch{
|
||||||
Id: entry.ID,
|
Id: entry.ID,
|
||||||
Title: entry.Title,
|
Title: entry.Title,
|
||||||
@@ -260,10 +274,12 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
Path: append([]string(nil), entry.Path...),
|
Path: append([]string(nil), entry.Path...),
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
},
|
},
|
||||||
score: score,
|
score: score,
|
||||||
|
resource: resource,
|
||||||
|
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(matches, func(a, b rankedMatch) int {
|
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
||||||
switch {
|
switch {
|
||||||
case a.score != b.score:
|
case a.score != b.score:
|
||||||
return b.score - a.score
|
return b.score - a.score
|
||||||
@@ -275,9 +291,9 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return strings.Compare(a.match.GetId(), b.match.GetId())
|
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
out, err := s.authorizedBrowserMatches(ctx, token, matches)
|
||||||
for _, match := range matches {
|
if err != nil {
|
||||||
out = append(out, match.match)
|
return nil, err
|
||||||
}
|
}
|
||||||
switch len(out) {
|
switch len(out) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -302,6 +318,41 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
|||||||
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision == apitokens.DecisionAllow {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.decision != apitokens.DecisionPrompt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return browserMatchesWithinPath(matches, match.resource.Path), nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch {
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(path) > len(match.resource.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Equal(path, match.resource.Path[:len(path)]) {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
model, locked := s.snapshotModel()
|
model, locked := s.snapshotModel()
|
||||||
if locked {
|
if locked {
|
||||||
@@ -427,6 +478,7 @@ func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRe
|
|||||||
|
|
||||||
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
s.model.CreateGroup(req.GetParentPath(), req.GetName())
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.CreateGroupResponse{}, nil
|
return &keepassgov1.CreateGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +501,7 @@ func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.RenameGroupResponse{}, nil
|
return &keepassgov1.RenameGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +528,7 @@ func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.DeleteGroupResponse{}, nil
|
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +549,7 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
|
|||||||
}
|
}
|
||||||
s.model.UpsertEntry(entry)
|
s.model.UpsertEntry(entry)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
|
||||||
@@ -522,6 +577,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.DeleteEntryResponse{}, nil
|
return &keepassgov1.DeleteEntryResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +611,7 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +665,7 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
|||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,6 +708,7 @@ func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemp
|
|||||||
entry := entryFromProto(req.GetTemplate())
|
entry := entryFromProto(req.GetTemplate())
|
||||||
s.model.UpsertTemplate(entry)
|
s.model.UpsertTemplate(entry)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
@@ -672,6 +731,7 @@ func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemp
|
|||||||
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
||||||
}
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -703,6 +763,7 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,6 +816,7 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt
|
|||||||
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
||||||
s.model.Entries[index] = entry
|
s.model.Entries[index] = entry
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.UploadAttachmentResponse{}, nil
|
return &keepassgov1.UploadAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -813,6 +875,7 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt
|
|||||||
}
|
}
|
||||||
s.model.Entries[index] = entry
|
s.model.Entries[index] = entry
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
s.syncMutationLocked()
|
||||||
|
|
||||||
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
||||||
}
|
}
|
||||||
@@ -1140,14 +1203,21 @@ func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule)
|
|||||||
}
|
}
|
||||||
s.model.Entries[i] = token.Entry(entry.Path)
|
s.model.Entries[i] = token.Entry(entry.Path)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
s.syncMutationLocked()
|
||||||
lifecycle.Replace(s.model)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return status.Error(codes.NotFound, "api token entry not found")
|
return status.Error(codes.NotFound, "api token entry not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncMutationLocked() {
|
||||||
|
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
||||||
|
lifecycle.Replace(s.model)
|
||||||
|
}
|
||||||
|
if s.notify != nil {
|
||||||
|
s.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
||||||
|
|||||||
@@ -183,6 +183,48 @@ func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "codex-nextcloud",
|
||||||
|
Title: "Nextcloud (codex)",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-1",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "codex"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "joe-nextcloud",
|
||||||
|
Title: "Nextcloud",
|
||||||
|
Username: "jjulian",
|
||||||
|
Password: "secret-2",
|
||||||
|
URL: "https://nextcloud.example.invalid",
|
||||||
|
Path: []string{"keepass", "Joe", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://nextcloud.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "codex-nextcloud" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -940,6 +982,60 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lifecycle := &stubLifecycle{model: model}
|
||||||
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
|
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
go func() { _ = server.Serve(listener) }()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
server.Stop()
|
||||||
|
_ = listener.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
client := keepassgov1.NewVaultServiceClient(conn)
|
||||||
|
|
||||||
|
_, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{
|
||||||
|
Entry: &keepassgov1.Entry{
|
||||||
|
Id: "lifecycle-visible",
|
||||||
|
Title: "Lifecycle Visible",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := lifecycle.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Current() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := current.EntryByID("lifecycle-visible"); err != nil {
|
||||||
|
t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
ui.state.AuditLog = ui.auditLog
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
|
host.Server().SetChangeNotifier(func() {
|
||||||
|
ui.state.Dirty = true
|
||||||
|
ui.invalidate()
|
||||||
|
})
|
||||||
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user