This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new ec6cc51 feat(tray): switch an agent's provider from the tray menu
ec6cc51 is described below
commit ec6cc518afb279be853778aad9c3a94207135b3b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 00:52:03 2026 +0800
feat(tray): switch an agent's provider from the tray menu
---
controllers/agent.go | 38 +++++++-----
controllers/tray.go | 142 ++++++++++++++++++++++++++++++++++++++++++++
controllers/util.go | 15 +++++
desktop/api.go | 117 ++++++++++++++++++++++++++++++++++++
desktop/providers.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++
desktop/tray.go | 5 ++
main.go | 6 ++
routers/router.go | 5 ++
service/localtoken.go | 87 +++++++++++++++++++++++++++
9 files changed, 561 insertions(+), 14 deletions(-)
diff --git a/controllers/agent.go b/controllers/agent.go
index cfd9e9c..0b0dc79 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -16,6 +16,7 @@ package controllers
import (
"encoding/json"
+ "errors"
"net"
"path/filepath"
"sort"
@@ -167,33 +168,42 @@ func (c *ApiController) UpdateAgentRouting() {
return
}
- if err := checkAgentProtocol(form.AgentId, form.Mode,
append([]string{form.Provider}, form.Fallbacks...)); err != nil {
+ if err := saveAgentRouting(form.AgentId, form.Provider, form.Fallbacks,
form.Mode); err != nil {
c.ResponseError(err.Error())
return
}
+ c.ResponseOk(form.Provider)
+}
- if err := object.SetAgentRouting(form.AgentId, form.Provider,
form.Fallbacks, form.Mode); err != nil {
- c.ResponseError(err.Error())
- return
+// saveAgentRouting stores one agent's routing and writes it into the
+// configuration of every installation Gateway can write. Both the routing form
+// and the tray's one-click switch end here, so an agent reached from either
+// place is left in the same state.
+func saveAgentRouting(agentId string, providerId string, fallbacks []string,
mode string) error {
+ if err := checkAgentProtocol(agentId, mode,
append([]string{providerId}, fallbacks...)); err != nil {
+ return err
+ }
+
+ if err := object.SetAgentRouting(agentId, providerId, fallbacks, mode);
err != nil {
+ return err
}
// An agent that runs in a sandbox is given this host's own address, so
the
// management port has to answer there before that endpoint is written.
if err := service.SyncLanAccess(); err != nil {
- c.ResponseError("the routing was saved, but the agent cannot
reach Gateway from its sandbox: " + err.Error())
- return
+ return errors.New("the routing was saved, but the agent cannot
reach Gateway from its sandbox: " + err.Error())
}
- if form.Provider == "" {
- if failure := restoreAgentProvider(form.AgentId); failure != ""
{
- c.ResponseError("the routing was cleared, but the agent
configuration was not restored: " + failure)
- return
+ if providerId == "" {
+ if failure := restoreAgentProvider(agentId); failure != "" {
+ return errors.New("the routing was cleared, but the
agent configuration was not restored: " + failure)
}
- } else if failure := reapplyAgentProvider(form.AgentId); failure != "" {
- c.ResponseError("the routing was saved, but the agent
configuration was not rewritten: " + failure)
- return
+ return nil
}
- c.ResponseOk(form.Provider)
+ if failure := reapplyAgentProvider(agentId); failure != "" {
+ return errors.New("the routing was saved, but the agent
configuration was not rewritten: " + failure)
+ }
+ return nil
}
// PatchAgent enables monitoring for one discovered installation.
diff --git a/controllers/tray.go b/controllers/tray.go
new file mode 100644
index 0000000..b2925c6
--- /dev/null
+++ b/controllers/tray.go
@@ -0,0 +1,142 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "encoding/json"
+ "sort"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/object"
+)
+
+// trayAgent is one agent the tray offers a provider menu for. Installations
are
+// discovered per path while the routing is stored per agent id, so the menu is
+// one entry per id however many copies are installed.
+type trayAgent struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+}
+
+// trayProvider is one provider a tray menu entry can be switched to.
+type trayProvider struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Disabled bool `json:"disabled"`
+}
+
+// GetTrayMenu is everything the desktop tray draws: the agents installed here,
+// the provider each is bound to, and the providers they can be switched to. It
+// is one call because the menu is rebuilt as a whole.
+func (c *ApiController) GetTrayMenu() {
+ if c.RequireLocalAdmin() {
+ return
+ }
+
+ installations, err := agent.Scan(false)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ routings, err := object.GetAgents()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ agents := []*trayAgent{}
+ seen := map[string]bool{}
+ for _, installation := range installations {
+ if seen[installation.AgentId] {
+ continue
+ }
+ seen[installation.AgentId] = true
+
+ item := &trayAgent{
+ AgentId: installation.AgentId,
+ Name: installation.Name,
+ }
+ if stored, ok := routings[installation.AgentId]; ok {
+ item.Provider = stored.Provider
+ }
+ agents = append(agents, item)
+ }
+ sort.Slice(agents, func(i, j int) bool { return agents[i].Name <
agents[j].Name })
+
+ stored, err := object.GetProviders("")
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ providers := []*trayProvider{}
+ for _, provider := range stored {
+ name := provider.DisplayName
+ if name == "" {
+ name = provider.Name
+ }
+ providers = append(providers, &trayProvider{
+ Id: provider.GetId(),
+ Name: name,
+ Disabled: provider.Status == "disabled",
+ })
+ }
+ sort.Slice(providers, func(i, j int) bool { return providers[i].Name <
providers[j].Name })
+
+ c.ResponseOk(map[string]interface{}{"agents": agents, "providers":
providers})
+}
+
+// SetAgentProvider binds one agent to one provider, leaving the rest of its
+// routing alone. It is the tray's one click: the fallbacks and the mode chosen
+// on the pages survive a switch made from the menu.
+func (c *ApiController) SetAgentProvider() {
+ if c.RequireLocalAdmin() {
+ return
+ }
+
+ var form struct {
+ AgentId string `json:"agentId"`
+ Provider string `json:"provider"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if !agent.IsKnownAgentId(form.AgentId) {
+ c.ResponseError("unknown agent: " + form.AgentId)
+ return
+ }
+
+ fallbacks := []string{}
+ mode := object.ModeGateway
+ routing, err := object.GetAgent(form.AgentId)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if routing != nil {
+ fallbacks = routing.Fallbacks
+ if routing.Mode != "" {
+ mode = routing.Mode
+ }
+ }
+
+ if err := saveAgentRouting(form.AgentId, form.Provider, fallbacks,
mode); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(form.Provider)
+}
diff --git a/controllers/util.go b/controllers/util.go
index 8971eea..2c2b930 100644
--- a/controllers/util.go
+++ b/controllers/util.go
@@ -14,6 +14,10 @@
package controllers
+import (
+ "github.com/apache/casbin-gateway/service"
+)
+
type Response struct {
Status string `json:"status"`
Msg string `json:"msg"`
@@ -65,3 +69,14 @@ func (c *ApiController) RequireAdmin() bool {
return false
}
+
+// RequireLocalAdmin admits the signed-in admin, and a program on this machine
+// that holds the local token. The tray is the second one: it offers the same
+// switch the pages do, from a process that never signed in.
+func (c *ApiController) RequireLocalAdmin() bool {
+ if service.IsLocalRequest(c.Ctx.Request) {
+ return false
+ }
+
+ return c.RequireAdmin()
+}
diff --git a/desktop/api.go b/desktop/api.go
new file mode 100644
index 0000000..d1a20b9
--- /dev/null
+++ b/desktop/api.go
@@ -0,0 +1,117 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// localTokenPath mirrors service.LocalTokenPath, resolved against the same
+// directory the server runs in. It is what the tray sends instead of a
session:
+// the server writes it where only this account can read it.
+const localTokenPath = "tmp/local-token"
+
+const localTokenHeader = "X-Casbin-Gateway-Local-Token"
+
+const apiTimeout = 10 * time.Second
+
+// trayMenu is what the server says the provider submenus should contain.
+type trayMenu struct {
+ Agents []struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ } `json:"agents"`
+ Providers []struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ Disabled bool `json:"disabled"`
+ } `json:"providers"`
+}
+
+// serverUrl is the server's own address. It is not gatewayUrl(): that one may
+// be pointed at the frontend dev server, which has no API of its own.
+func serverUrl() string {
+ return fmt.Sprintf("http://127.0.0.1:%d", httpPort())
+}
+
+func fetchTrayMenu() (*trayMenu, error) {
+ menu := &trayMenu{}
+ if err := callApi(http.MethodGet, "/api/get-tray-menu", nil, menu); err
!= nil {
+ return nil, err
+ }
+ return menu, nil
+}
+
+func setAgentProvider(agentId string, providerId string) error {
+ body, err := json.Marshal(map[string]string{"agentId": agentId,
"provider": providerId})
+ if err != nil {
+ return err
+ }
+ return callApi(http.MethodPost, "/api/set-agent-provider", body, nil)
+}
+
+// callApi unwraps the {status, msg, data} envelope every Gateway API answers
+// with, so a rejected call is an error here rather than an empty menu.
+func callApi(method string, path string, body []byte, out interface{}) error {
+ token, err := os.ReadFile(filepath.Join(gatewayHome(),
filepath.FromSlash(localTokenPath)))
+ if err != nil {
+ return fmt.Errorf("the Gateway server has not left a local
token: %v", err)
+ }
+
+ var payload *bytes.Reader
+ if body == nil {
+ payload = bytes.NewReader(nil)
+ } else {
+ payload = bytes.NewReader(body)
+ }
+ request, err := http.NewRequest(method, serverUrl()+path, payload)
+ if err != nil {
+ return err
+ }
+ request.Header.Set(localTokenHeader, strings.TrimSpace(string(token)))
+ request.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: apiTimeout}
+ response, err := client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ envelope := struct {
+ Status string `json:"status"`
+ Msg string `json:"msg"`
+ Data json.RawMessage `json:"data"`
+ }{}
+ if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
+ return fmt.Errorf("%s answered %s: %v", path, response.Status,
err)
+ }
+ if envelope.Status != "ok" {
+ return errors.New(envelope.Msg)
+ }
+ if out == nil {
+ return nil
+ }
+ return json.Unmarshal(envelope.Data, out)
+}
diff --git a/desktop/providers.go b/desktop/providers.go
new file mode 100644
index 0000000..e46e209
--- /dev/null
+++ b/desktop/providers.go
@@ -0,0 +1,160 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "fyne.io/systray"
+)
+
+// menuInterval is how often the provider submenus are compared against the
+// server. A switch made on the pages reaches the menu within one.
+const menuInterval = 10 * time.Second
+
+// builtinTitle is the entry that unbinds an agent, which puts back the
provider
+// settings it had before Gateway first switched it.
+const builtinTitle = "Built-in model"
+
+// providerMenu is the tray's provider switcher: one submenu per agent
installed
+// here, each listing the providers that agent can be pointed at. The submenus
+// are redrawn only when what they should show changes, since redrawing one
+// closes it under the pointer.
+type providerMenu struct {
+ root *systray.MenuItem
+ drawn string
+ // agents holds only the submenu of each agent: removing one removes the
+ // provider items under it, and removing those again is what Windows
+ // answers with "invalid menu handle".
+ agents []*systray.MenuItem
+ // redrawNow carries a switch this menu just made, so the check mark
moves
+ // without waiting out the interval.
+ redrawNow chan struct{}
+}
+
+func newProviderMenu(root *systray.MenuItem) *providerMenu {
+ return &providerMenu{root: root, redrawNow: make(chan struct{}, 1)}
+}
+
+func (menu *providerMenu) watch() {
+ for {
+ menu.draw()
+ select {
+ case <-menu.redrawNow:
+ case <-time.After(menuInterval):
+ }
+ }
+}
+
+func (menu *providerMenu) draw() {
+ fetched, err := fetchTrayMenu()
+ if err != nil {
+ menu.clear("unavailable")
+ return
+ }
+ if len(fetched.Agents) == 0 {
+ menu.clear("no agent is installed")
+ return
+ }
+ if len(fetched.Providers) == 0 {
+ menu.clear("no provider is configured")
+ return
+ }
+
+ fingerprint := fingerprintOf(fetched)
+ if fingerprint == menu.drawn {
+ return
+ }
+ menu.remove()
+
+ menu.root.SetTitle("Switch Provider")
+ menu.root.Enable()
+ for _, entry := range fetched.Agents {
+ submenu := menu.root.AddSubMenuItem(entry.Name, "Choose where
"+entry.Name+" sends its requests")
+ menu.agents = append(menu.agents, submenu)
+
+ menu.add(submenu, entry.AgentId, "", builtinTitle, "Unbind
"+entry.Name+" and put its own settings back", entry.Provider == "")
+ for _, provider := range fetched.Providers {
+ title := provider.Name
+ if provider.Disabled {
+ title += " (disabled)"
+ }
+ menu.add(submenu, entry.AgentId, provider.Id, title,
"Send "+entry.Name+" to "+provider.Id, provider.Id == entry.Provider)
+ }
+ }
+ menu.drawn = fingerprint
+}
+
+func (menu *providerMenu) add(submenu *systray.MenuItem, agentId string,
providerId string, title string, tooltip string, checked bool) {
+ item := submenu.AddSubMenuItemCheckbox(title, tooltip, checked)
+
+ // Remove() closes the channel, so the goroutine of an item the next
redraw
+ // drops ends with it.
+ go func() {
+ for range item.ClickedCh {
+ if err := setAgentProvider(agentId, providerId); err !=
nil {
+ fmt.Fprintln(os.Stderr,
"casbin-gateway-desktop:", err)
+ }
+ // Either way the menu is redrawn from what the server
now says, so
+ // a switch that failed leaves the check mark where it
was.
+ menu.requestRedraw()
+ }
+ }()
+}
+
+// clear leaves the switcher greyed out, saying why there is nothing under it.
+// The reason is what the menu is drawn from while it is empty, so a Gateway
+// that stays down is not redrawn every poll.
+func (menu *providerMenu) clear(reason string) {
+ title := "Switch Provider (" + reason + ")"
+ if menu.drawn == title {
+ return
+ }
+
+ menu.remove()
+ menu.root.SetTitle(title)
+ menu.root.Disable()
+ menu.drawn = title
+}
+
+func (menu *providerMenu) remove() {
+ for _, item := range menu.agents {
+ item.Remove()
+ }
+ menu.agents = nil
+}
+
+func (menu *providerMenu) requestRedraw() {
+ select {
+ case menu.redrawNow <- struct{}{}:
+ default:
+ }
+}
+
+// fingerprintOf is what the submenus are drawn from, so an unchanged menu is
+// left standing.
+func fingerprintOf(fetched *trayMenu) string {
+ parts := []string{}
+ for _, entry := range fetched.Agents {
+ parts = append(parts,
entry.AgentId+"\x00"+entry.Name+"\x00"+entry.Provider)
+ }
+ for _, provider := range fetched.Providers {
+ parts = append(parts,
provider.Id+"\x00"+provider.Name+"\x00"+fmt.Sprint(provider.Disabled))
+ }
+ return strings.Join(parts, "\x01")
+}
diff --git a/desktop/tray.go b/desktop/tray.go
index 1133cf8..7d16d74 100644
--- a/desktop/tray.go
+++ b/desktop/tray.go
@@ -62,6 +62,10 @@ func onTrayReady() {
mOpen := systray.AddMenuItem("Open Casbin Gateway", "Show the
management window")
mBrowser := systray.AddMenuItem("Open in Browser", "Show the management
UI in the default browser")
systray.AddSeparator()
+ mProviders := systray.AddMenuItem("Switch Provider (unavailable)",
"Point an agent at another provider")
+ mProviders.Disable()
+ providers := newProviderMenu(mProviders)
+ systray.AddSeparator()
mStatus := systray.AddMenuItem("Starting...", "")
mStatus.Disable()
systray.AddSeparator()
@@ -86,6 +90,7 @@ func onTrayReady() {
ownsServer.Unlock()
go watchStatus(mStatus, port)
+ go providers.watch()
showWindow()
}()
diff --git a/main.go b/main.go
index 26bd251..32aee7c 100644
--- a/main.go
+++ b/main.go
@@ -74,6 +74,12 @@ func main() {
// this is the only process that can now give it back.
agentlink.Restore()
+ // The tray runs as a process of its own, so the credential it presents
in
+ // place of a session has to be on disk before it asks for anything.
+ if err := service.IssueLocalToken(); err != nil {
+ beego.Error("the desktop tray cannot be given a local token:",
err)
+ }
+
object.InitFlag()
object.InitAdapter()
object.CreateTables()
diff --git a/routers/router.go b/routers/router.go
index 651aecd..f2b0ee8 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -81,6 +81,11 @@ func initAPI() {
beego.Router("/api/apply-agent-provider", &controllers.ApiController{},
"POST:ApplyAgentProvider")
beego.Router("/api/restore-agent-provider",
&controllers.ApiController{}, "POST:RestoreAgentProvider")
beego.Router("/api/copy-agent-config", &controllers.ApiController{},
"POST:CopyAgentConfig")
+
+ // The desktop tray, which holds the local token instead of a session.
+ beego.Router("/api/get-tray-menu", &controllers.ApiController{},
"GET:GetTrayMenu")
+ beego.Router("/api/set-agent-provider", &controllers.ApiController{},
"POST:SetAgentProvider")
+
beego.Router("/api/get-skill-sources", &controllers.ApiController{},
"GET:GetSkillSources")
beego.Router("/api/add-skill-source", &controllers.ApiController{},
"POST:AddSkillSource")
beego.Router("/api/upload-skill-source", &controllers.ApiController{},
"POST:UploadSkillSource")
diff --git a/service/localtoken.go b/service/localtoken.go
new file mode 100644
index 0000000..3723c8c
--- /dev/null
+++ b/service/localtoken.go
@@ -0,0 +1,87 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package service
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/apache/casbin-gateway/util"
+)
+
+// LocalTokenHeader carries the credential a program on this machine presents
in
+// place of a session. The tray is one: it draws its menu from the same API the
+// pages use, but it is not a browser and has no session to send.
+const LocalTokenHeader = "X-Casbin-Gateway-Local-Token"
+
+// LocalTokenPath is relative to the working directory, which is where Gateway
+// keeps the rest of its state, so a tray started from the same directory finds
+// it without being told.
+const LocalTokenPath = "./tmp/local-token"
+
+var localToken struct {
+ sync.RWMutex
+ value string
+}
+
+// IssueLocalToken writes a fresh token where only this account can read it. It
+// is new on every start, so one left behind by a Gateway that is gone grants
+// nothing.
+func IssueLocalToken() error {
+ buffer := make([]byte, 32)
+ if _, err := rand.Read(buffer); err != nil {
+ return err
+ }
+ token := base64.RawURLEncoding.EncodeToString(buffer)
+
+ if err := os.MkdirAll(filepath.Dir(LocalTokenPath), 0o700); err != nil {
+ return err
+ }
+ if err := os.WriteFile(LocalTokenPath, []byte(token), 0o600); err !=
nil {
+ return err
+ }
+ // WriteFile only applies the mode to a file it creates, and this one
+ // outlives the run that created it.
+ if err := os.Chmod(LocalTokenPath, 0o600); err != nil {
+ return err
+ }
+
+ localToken.Lock()
+ localToken.value = token
+ localToken.Unlock()
+ return nil
+}
+
+// IsLocalRequest reports whether a request came from a program on this machine
+// holding that token. Loopback alone would not do: a page in the operator's
+// browser reaches loopback too, and cannot read the file.
+func IsLocalRequest(r *http.Request) bool {
+ if r == nil || !util.IsLoopbackRequest(r) {
+ return false
+ }
+
+ localToken.RLock()
+ expected := localToken.value
+ localToken.RUnlock()
+ if expected == "" {
+ return false
+ }
+ return
subtle.ConstantTimeCompare([]byte(r.Header.Get(LocalTokenHeader)),
[]byte(expected)) == 1
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]