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 07817f8 feat(update): upgrade to the latest build from the web UI
07817f8 is described below
commit 07817f85d60d5a95e979d96904ee2202b05ce8de
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 17:52:57 2026 +0800
feat(update): upgrade to the latest build from the web UI
---
.github/workflows/nightly.yml | 10 +-
README.md | 8 +-
README_zh.md | 8 +-
controllers/version.go | 56 ++++
main.go | 19 ++
routers/router.go | 3 +
util/daemon.go | 48 +--
version/check.go | 217 +++++++++++++
version/download.go | 196 ++++++++++++
version/info.go | 78 +++++
version/update.go | 377 +++++++++++++++++++++++
version/version.go | 145 +++++++++
web/src/App.tsx | 1 +
web/src/backend/MiscBackend.ts | 23 +-
web/src/components/shared/app-header.tsx | 4 +
web/src/components/shared/version-panel.tsx | 460 ++++++++++++++++++++++++++++
web/src/locales/en/data.json | 22 ++
web/src/locales/zh/data.json | 22 ++
web/src/types.ts | 51 +++
19 files changed, 1726 insertions(+), 22 deletions(-)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index ac22688..dae40bf 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -80,6 +80,14 @@ jobs:
set -euo pipefail
mkdir -p dist
+ # Stamped into the binary so that a running Gateway can tell which
+ # nightly it is, and whether the published one is a different commit.
+ stamp="github.com/apache/casbin-gateway/version"
+ ldflags="-s -w"
+ ldflags="${ldflags} -X ${stamp}.buildVersion=nightly"
+ ldflags="${ldflags} -X ${stamp}.buildCommit=${GITHUB_SHA}"
+ ldflags="${ldflags} -X ${stamp}.buildDate=$(date -u
+%Y-%m-%dT%H:%M:%SZ)"
+
# goos goarch arch-name-used-in-the-file-name
targets=(
"linux amd64 x86_64"
@@ -101,7 +109,7 @@ jobs:
echo "Building ${name}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 \
- go build -tags embed -trimpath -ldflags "-s -w" -o "${binary}" .
+ go build -tags embed -trimpath -ldflags "${ldflags}" -o
"${binary}" .
cp LICENSE NOTICE DISCLAIMER README.md "${stage}/"
diff --git a/README.md b/README.md
index 42d99c8..9e6e63d 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,12 @@ That is the whole installation. Gateway keeps its data in a
SQLite file inside i
The password behind that account is `admin` / `123`, and it only matters if
you open Gateway to the network — see [Serving other
machines](#serving-other-machines).
+### Keeping it up to date
+
+The version Gateway is running sits in the top-right corner, with the date it
was built, and it says **New** when the published build is a later one. Open it
and press **Update now**: Gateway downloads the build for this machine, checks
that it runs, puts it in place of itself and restarts into it. The page reloads
on the new version when it comes back, and nothing else has to be touched — the
data, the settings and the `casbin-gateway` command all stay where they are.
+
+Gateway installed some other way, or in a directory it cannot write to, says
so and shows the install command to run by hand instead.
+
### What to do next
| Page | What you get | What it needs |
@@ -102,7 +108,7 @@ Codex is the exception: its ChatGPT sign-in talks to an
endpoint no provider sta
- **Stop**: `casbin-gateway stop`. **Start again**: `casbin-gateway start`.
**Check**: `casbin-gateway status`. All three work from any directory — the
command is a wrapper that always starts Gateway in its install directory, where
its data lives.
- **Run in the foreground** instead, to watch it: `casbin-gateway`, stopped
with `Ctrl-C`. In the background its console output goes to
`logs/casbin-gateway.out`.
-- **Upgrade**: run the install command again. Your database and settings are
untouched.
+- **Upgrade**: press the version in the top-right corner and then **Update
now**, or run the install command again. Your database and settings are
untouched either way.
- **Remove**: delete `~/.local/share/casbin-gateway` and
`~/.local/bin/casbin-gateway` (on Windows, `%LOCALAPPDATA%\casbin-gateway` and
its PATH entry), plus the startup entry the installer names when it finishes.
Set `INSTALL_DIR` to install somewhere else, `NO_START=1` to install without
starting, or `NO_AUTOSTART=1` to install without starting at login.
diff --git a/README_zh.md b/README_zh.md
index 6dd64aa..1987973 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -59,6 +59,12 @@ irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/insta
安装到此为止。Gateway 把数据存在自己目录下的一个 SQLite 文件里,登录也走它自己的用户表。
+### 保持最新
+
+当前运行的版本号显示在右上角,后面跟着它的构建日期,有新版本时会标上**新**。点开它按**立即升级**:Gateway
会下载适配本机的构建产物,先确认它能跑起来,再替换掉自己并重启。新版本起来后页面会自动刷新,其他什么都不用动 —— 数据、设置和
`casbin-gateway` 命令都留在原处。
+
+如果 Gateway 不是这样装的,或者所在目录不可写,页面会直接说明,并给出手动升级的命令。
+
### 接下来做什么
| 页面 | 你能得到什么 | 需要什么 |
@@ -100,7 +106,7 @@ Codex 是例外:它的 ChatGPT 登录走的是没有任何 Provider 能替代
- **停止**:`casbin-gateway stop`。**再次启动**:`casbin-gateway
start`。**查看状态**:`casbin-gateway status`。三条命令在任意目录都能用 ——
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
- **前台运行**(想盯着它跑的时候):`casbin-gateway`,用 `Ctrl-C` 停止。后台运行时控制台输出写在
`logs/casbin-gateway.out`。
-- **升级**:再跑一遍安装命令。数据库和设置不受影响。
+- **升级**:点右上角的版本号,再点**立即升级**,或者再跑一遍安装命令。两种方式都不影响数据库和设置。
- **卸载**:删除 `~/.local/share/casbin-gateway` 和
`~/.local/bin/casbin-gateway`(Windows 上是 `%LOCALAPPDATA%\casbin-gateway` 及其
PATH 条目),以及安装脚本结束时告诉你的那个自启动条目。
设置 `INSTALL_DIR` 可以装到别的位置,`NO_START=1` 只安装不启动,`NO_AUTOSTART=1` 则不设置登录自启。
diff --git a/controllers/version.go b/controllers/version.go
new file mode 100644
index 0000000..d53a212
--- /dev/null
+++ b/controllers/version.go
@@ -0,0 +1,56 @@
+// 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 (
+ "github.com/apache/casbin-gateway/version"
+)
+
+// GetVersion tells the web UI which build this is and which one is published.
+// "refresh=1" asks GitHub again instead of answering from the cache.
+func (c *ApiController) GetVersion() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ c.ResponseOk(version.Describe(c.Input().Get("refresh") == "1"))
+}
+
+// UpdateGateway replaces this executable with the published build and restarts
+// into it. It answers as soon as the download starts, because the rest takes
+// long enough that the web UI follows it through GetUpdateStatus.
+func (c *ApiController) UpdateGateway() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ if err := version.StartUpdate(); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(version.UpdateStatus())
+}
+
+// GetUpdateStatus is polled while an update runs. Once the new Gateway is
+// starting this stops answering at all, which is the web UI's cue to wait for
+// the restarted one.
+func (c *ApiController) GetUpdateStatus() {
+ if c.RequireSignedIn() {
+ return
+ }
+
+ c.ResponseOk(version.UpdateStatus())
+}
diff --git a/main.go b/main.go
index 62db364..7a8d467 100644
--- a/main.go
+++ b/main.go
@@ -32,6 +32,7 @@ import (
"github.com/apache/casbin-gateway/run"
"github.com/apache/casbin-gateway/service"
"github.com/apache/casbin-gateway/util"
+ "github.com/apache/casbin-gateway/version"
"github.com/beego/beego"
"github.com/beego/beego/plugins/cors"
_ "github.com/beego/beego/session/redis"
@@ -47,12 +48,23 @@ func main() {
agenthook.ServeIfInvoked()
mcpserver.ServeIfInvoked()
+ // "version" prints the build and exits. An update runs it on what it
just
+ // downloaded, before that executable replaces this one.
+ if version.RunCommand(os.Args) {
+ return
+ }
+
// "start", "stop" and "status" manage a Gateway running in the
background,
// so that using it does not mean keeping a terminal open.
if util.RunCommand(os.Args, conf.GetHttpPort(), daemonLogPath) {
return
}
+ // The executable an earlier update replaced is only removable once
nothing
+ // is running from it, which is now.
+ version.CleanupBackup()
+ version.Configure(daemonLogPath)
+
object.InitFlag()
object.InitAdapter()
object.CreateTables()
@@ -81,6 +93,13 @@ func main() {
}
defer agentmonitor.Stop()
+ // An update ends this process without unwinding main, so what the
deferred
+ // calls above would have flushed has to be flushed there instead.
+ version.BeforeRestart = func() {
+ agentmonitor.Stop()
+ object.StopLlmRecordWriter()
+ }
+
beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "DELETE", "PUT",
"PATCH", "OPTIONS"},
diff --git a/routers/router.go b/routers/router.go
index 7f24ccd..5aabc2f 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -40,6 +40,9 @@ func initAPI() {
beego.Router("/api/get-casdoor-providers",
&controllers.ApiController{}, "GET:GetCasdoorProviders")
beego.Router("/api/get-gateway-status", &controllers.ApiController{},
"GET:GetGatewayStatus")
beego.Router("/api/get-relay-token", &controllers.ApiController{},
"GET:GetRelayToken")
+ beego.Router("/api/get-version", &controllers.ApiController{},
"GET:GetVersion")
+ beego.Router("/api/update-gateway", &controllers.ApiController{},
"POST:UpdateGateway")
+ beego.Router("/api/get-update-status", &controllers.ApiController{},
"GET:GetUpdateStatus")
beego.Router("/api/get-setting", &controllers.ApiController{},
"GET:GetSetting")
beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
diff --git a/util/daemon.go b/util/daemon.go
index e9d541c..513a700 100644
--- a/util/daemon.go
+++ b/util/daemon.go
@@ -83,24 +83,8 @@ func startDetached(port int, logPath string) {
return
}
- if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
- fmt.Printf("Casbin Gateway: cannot create %s: %v\n",
filepath.Dir(logPath), err)
- os.Exit(1)
- }
-
- logFile, err := os.OpenFile(logPath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ process, err := StartDetached(executable, "", logPath)
if err != nil {
- fmt.Printf("Casbin Gateway: cannot write %s: %v\n", logPath,
err)
- os.Exit(1)
- }
- defer logFile.Close()
-
- cmd := exec.Command(executable)
- cmd.Stdout, cmd.Stderr = logFile, logFile
- cmd.Env = append(os.Environ(), EnvDaemonChildKey+"=1")
- detach(cmd)
-
- if err := cmd.Start(); err != nil {
fmt.Printf("Casbin Gateway: could not start in the background:
%v\n", err)
os.Exit(1)
}
@@ -108,7 +92,7 @@ func startDetached(port int, logPath string) {
deadline := time.Now().Add(daemonStartTimeout)
for time.Now().Before(deadline) {
if isServing(port) {
- fmt.Printf("Casbin Gateway is running in the background
on http://localhost:%d (pid %d)\n", port, cmd.Process.Pid)
+ fmt.Printf("Casbin Gateway is running in the background
on http://localhost:%d (pid %d)\n", port, process.Pid)
fmt.Printf(" Log: %s Stop it with: casbin-gateway
stop\n", logPath)
return
}
@@ -119,6 +103,34 @@ func startDetached(port int, logPath string) {
os.Exit(1)
}
+// StartDetached launches the executable at path with no terminal attached,
+// sending the console output nobody is watching to logPath. The child serves
+// instead of spawning another copy of itself. An empty dir keeps this
process's
+// working directory, which is where Gateway's data lives.
+func StartDetached(path string, dir string, logPath string) (*os.Process,
error) {
+ if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
+ return nil, fmt.Errorf("cannot create %s: %w",
filepath.Dir(logPath), err)
+ }
+
+ logFile, err := os.OpenFile(logPath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("cannot write %s: %w", logPath, err)
+ }
+ defer logFile.Close()
+
+ cmd := exec.Command(path)
+ cmd.Dir = dir
+ cmd.Stdout, cmd.Stderr = logFile, logFile
+ cmd.Env = append(os.Environ(), EnvDaemonChildKey+"=1")
+ detach(cmd)
+
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+
+ return cmd.Process, nil
+}
+
func stopDaemon(port int) {
if !isServing(port) {
fmt.Printf("Casbin Gateway is not running on port %d\n", port)
diff --git a/version/check.go b/version/check.go
new file mode 100644
index 0000000..e43ca61
--- /dev/null
+++ b/version/check.go
@@ -0,0 +1,217 @@
+// 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 version
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ // Updates come from the same place scripts/install.sh takes its
binaries
+ // from: the rolling "nightly" pre-release. Official releases are source
+ // packages and carry no executable to install.
+ repository = "apache/casbin-gateway"
+ releaseTag = "nightly"
+ assetPrefix = "casbin-gateway-nightly"
+
+ // GitHub allows 60 unauthenticated calls an hour per address, and the
web
+ // UI asks on every page load, so the answer is kept for a while.
+ checkInterval = 10 * time.Minute
+ checkTimeout = 20 * time.Second
+)
+
+// Release is the published build this Gateway can move to.
+type Release struct {
+ Tag string `json:"tag"`
+ Name string `json:"name"`
+ Commit string `json:"commit"`
+ ShortCommit string `json:"shortCommit"`
+ PublishedAt string `json:"publishedAt"`
+ PageUrl string `json:"pageUrl"`
+ AssetName string `json:"assetName"`
+ AssetUrl string `json:"assetUrl"`
+ AssetSize int64 `json:"assetSize"`
+}
+
+func (r *Release) publishedTime() time.Time {
+ moment, err := time.Parse(time.RFC3339, r.PublishedAt)
+ if err != nil {
+ return time.Time{}
+ }
+
+ return moment
+}
+
+type githubRelease struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ TargetCommitish string `json:"target_commitish"`
+ PublishedAt string `json:"published_at"`
+ HtmlUrl string `json:"html_url"`
+ Assets []struct {
+ Name string `json:"name"`
+ Url string `json:"browser_download_url"`
+ Size int64 `json:"size"`
+ } `json:"assets"`
+}
+
+var (
+ cacheLock sync.Mutex
+ cached *Release
+ cachedErr error
+ cachedAt time.Time
+ httpClient = &http.Client{Timeout: checkTimeout}
+ reCommitSha = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
+)
+
+// LatestRelease returns the published build, from the cache unless force says
+// to ask GitHub again. A failed lookup is cached too, so a machine with no
+// internet does not spend twenty seconds on every page load.
+func LatestRelease(force bool) (*Release, error) {
+ cacheLock.Lock()
+ defer cacheLock.Unlock()
+
+ if !force && !cachedAt.IsZero() && time.Since(cachedAt) < checkInterval
{
+ return cached, cachedErr
+ }
+
+ cached, cachedErr = fetchLatestRelease()
+ cachedAt = time.Now()
+ return cached, cachedErr
+}
+
+func fetchLatestRelease() (*Release, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s",
repository, releaseTag)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ // GitHub rejects a request without one.
+ req.Header.Set("User-Agent", "casbin-gateway/"+Current().Version)
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GitHub answered HTTP %d for %s",
resp.StatusCode, url)
+ }
+
+ var payload githubRelease
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("GitHub answered something that is not a
release: %w", err)
+ }
+
+ release := &Release{
+ Tag: payload.TagName,
+ Name: payload.Name,
+ Commit: releaseCommit(&payload),
+ PublishedAt: payload.PublishedAt,
+ PageUrl: payload.HtmlUrl,
+ }
+ release.ShortCommit = ShortCommit(release.Commit)
+
+ wanted := AssetName(runtime.GOOS, runtime.GOARCH)
+ for _, asset := range payload.Assets {
+ if asset.Name != wanted {
+ continue
+ }
+ release.AssetName = asset.Name
+ release.AssetUrl = asset.Url
+ release.AssetSize = asset.Size
+ break
+ }
+
+ return release, nil
+}
+
+// releaseCommit finds the commit the nightly was built from. The release is
+// created with --target <sha>, so target_commitish normally holds it; the
notes
+// repeat it, which covers the case where GitHub hands back a branch name.
+func releaseCommit(payload *githubRelease) string {
+ if reCommitSha.MatchString(payload.TargetCommitish) {
+ return payload.TargetCommitish
+ }
+
+ return reCommitSha.FindString(payload.Body)
+}
+
+// AssetName is the archive published for a platform, empty where none is.
+// Only x86_64 archives are built; macOS runs them under Rosetta 2, so an Apple
+// Silicon machine is served by the same file.
+func AssetName(goos string, goarch string) string {
+ switch goos {
+ case "linux":
+ if goarch != "amd64" {
+ return ""
+ }
+ return assetPrefix + "-linux-x86_64.tar.gz"
+ case "darwin":
+ if goarch != "amd64" && goarch != "arm64" {
+ return ""
+ }
+ return assetPrefix + "-darwin-x86_64.tar.gz"
+ case "windows":
+ if goarch != "amd64" {
+ return ""
+ }
+ return assetPrefix + "-windows-x86_64.zip"
+ default:
+ return ""
+ }
+}
+
+// IsNewer reports whether the release is a build this one is not already on.
+// Commits settle it when both are known; otherwise the dates do, which also
+// keeps a locally built Gateway that is ahead of master from being told to
+// "update" backwards onto the published nightly.
+func IsNewer(release *Release, build Build) bool {
+ if release == nil || release.AssetUrl == "" {
+ return false
+ }
+
+ // A build carrying the released commit is that release, uncommitted
local
+ // changes included: replacing it would throw those away.
+ if release.Commit != "" && build.Commit != "" &&
strings.EqualFold(release.Commit, build.Commit) {
+ return false
+ }
+
+ return !buildIsAhead(release, build)
+}
+
+func buildIsAhead(release *Release, build Build) bool {
+ builtAt, publishedAt := build.Time(), release.publishedTime()
+ if builtAt.IsZero() || publishedAt.IsZero() {
+ return false
+ }
+
+ return builtAt.After(publishedAt)
+}
diff --git a/version/download.go b/version/download.go
new file mode 100644
index 0000000..0620c61
--- /dev/null
+++ b/version/download.go
@@ -0,0 +1,196 @@
+// 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 version
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// downloadTimeout covers the whole transfer rather than one read, because a
+// stalled download that never finishes is the failure worth reporting.
+const downloadTimeout = 30 * time.Minute
+
+// download writes the release archive to path, keeping Status in step so the
+// web UI can draw a progress bar.
+func download(release *Release, path string) error {
+ req, err := http.NewRequest("GET", release.AssetUrl, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "casbin-gateway/"+Current().Version)
+
+ client := &http.Client{Timeout: downloadTimeout}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("downloading %s answered HTTP %d",
release.AssetName, resp.StatusCode)
+ }
+
+ total := release.AssetSize
+ if total <= 0 {
+ total = resp.ContentLength
+ }
+
+ statusLock.Lock()
+ status.Total = total
+ statusLock.Unlock()
+
+ file, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ written, err := io.Copy(file, io.TeeReader(resp.Body,
&progressWriter{total: total}))
+ if err != nil {
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+
+ // A short file is a download that was cut off. Catching it here turns a
+ // broken installation into a message the reader can retry from.
+ if release.AssetSize > 0 && written != release.AssetSize {
+ return fmt.Errorf("downloaded %d of %d bytes of %s", written,
release.AssetSize, release.AssetName)
+ }
+
+ return nil
+}
+
+// progressWriter counts bytes on their way to disk and publishes the count.
+type progressWriter struct {
+ total int64
+ written int64
+}
+
+func (w *progressWriter) Write(chunk []byte) (int, error) {
+ w.written += int64(len(chunk))
+
+ statusLock.Lock()
+ status.Downloaded = w.written
+ if w.total > 0 {
+ status.Percent = int(w.written * 100 / w.total)
+ if status.Percent > 100 {
+ status.Percent = 100
+ }
+ }
+ statusLock.Unlock()
+
+ return len(chunk), nil
+}
+
+// extractBinary writes the one executable inside the archive to path. Only
that
+// entry is taken, by name, so nothing else in the archive can land on disk.
+func extractBinary(archive string, path string) error {
+ wanted := filepath.Base(path)
+
+ if strings.HasSuffix(archive, ".zip") {
+ return extractFromZip(archive, wanted, path)
+ }
+
+ return extractFromTarGz(archive, wanted, path)
+}
+
+func extractFromTarGz(archive string, wanted string, path string) error {
+ file, err := os.Open(archive)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ gzipReader, err := gzip.NewReader(file)
+ if err != nil {
+ return fmt.Errorf("%s is not a gzip archive: %w",
filepath.Base(archive), err)
+ }
+ defer gzipReader.Close()
+
+ reader := tar.NewReader(gzipReader)
+ for {
+ header, err := reader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+
+ if header.Typeflag != tar.TypeReg || filepath.Base(header.Name)
!= wanted {
+ continue
+ }
+
+ return writeBinary(path, reader)
+ }
+
+ return fmt.Errorf("%s holds no %s", filepath.Base(archive), wanted)
+}
+
+func extractFromZip(archive string, wanted string, path string) error {
+ reader, err := zip.OpenReader(archive)
+ if err != nil {
+ return fmt.Errorf("%s is not a zip archive: %w",
filepath.Base(archive), err)
+ }
+ defer reader.Close()
+
+ for _, entry := range reader.File {
+ if entry.FileInfo().IsDir() || filepath.Base(entry.Name) !=
wanted {
+ continue
+ }
+
+ content, err := entry.Open()
+ if err != nil {
+ return err
+ }
+ defer content.Close()
+
+ return writeBinary(path, content)
+ }
+
+ return fmt.Errorf("%s holds no %s", filepath.Base(archive), wanted)
+}
+
+func writeBinary(path string, content io.Reader) error {
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
0o755)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ written, err := io.Copy(file, io.LimitReader(content, maxBinarySize+1))
+ if err != nil {
+ return err
+ }
+ if written > maxBinarySize {
+ return fmt.Errorf("the executable in the archive is larger than
%d bytes", int64(maxBinarySize))
+ }
+ if written == 0 {
+ return fmt.Errorf("the executable in the archive is empty")
+ }
+
+ return file.Close()
+}
diff --git a/version/info.go b/version/info.go
new file mode 100644
index 0000000..e1a5d00
--- /dev/null
+++ b/version/info.go
@@ -0,0 +1,78 @@
+// 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 version
+
+import "runtime"
+
+// Info is everything the web UI needs to show what this Gateway is running and
+// what it could run instead.
+type Info struct {
+ Current Build `json:"current"`
+ Latest *Release `json:"latest"`
+ // UpdateAvailable means the published build is one this Gateway is not
on.
+ UpdateAvailable bool `json:"updateAvailable"`
+ // CanUpdate means the update can be done from here; when it is false,
+ // Blocked says why and the page falls back to the install command.
+ CanUpdate bool `json:"canUpdate"`
+ Blocked string `json:"blocked"`
+ // CheckError is a failed lookup rather than a failed update: the
version in
+ // hand is still worth showing.
+ CheckError string `json:"checkError"`
+ Update Status `json:"update"`
+ ReleaseUrl string `json:"releaseUrl"`
+ // InstallCommand is the manual way in, shown when CanUpdate is false.
+ InstallCommand string `json:"installCommand"`
+}
+
+// Describe answers the version page, asking GitHub again only when force says
+// to. A lookup that fails is reported alongside the current version instead of
+// replacing it.
+func Describe(force bool) Info {
+ build := Current()
+ info := Info{
+ Current: build,
+ Update: UpdateStatus(),
+ Blocked: BlockedReason(),
+ ReleaseUrl: "https://github.com/" + repository +
"/releases/tag/" + releaseTag,
+ InstallCommand: InstallCommand(),
+ }
+
+ release, err := LatestRelease(force)
+ if err != nil {
+ info.CheckError = err.Error()
+ return info
+ }
+
+ info.Latest = release
+ info.UpdateAvailable = IsNewer(release, build)
+ if release != nil && release.AssetUrl == "" && info.Blocked == "" {
+ // The platform is one that is built for, but this particular
release is
+ // missing its archive.
+ info.Blocked = BlockedPlatform
+ }
+ info.CanUpdate = info.UpdateAvailable && info.Blocked == ""
+
+ return info
+}
+
+// InstallCommand is the one-liner that installs the published build by hand,
+// for a Gateway that cannot replace its own executable.
+func InstallCommand() string {
+ if runtime.GOOS == "windows" {
+ return "irm https://raw.githubusercontent.com/" + repository +
"/master/scripts/install.ps1 | iex"
+ }
+
+ return "curl -fsSL https://raw.githubusercontent.com/" + repository +
"/master/scripts/install.sh | bash"
+}
diff --git a/version/update.go b/version/update.go
new file mode 100644
index 0000000..9cc85fa
--- /dev/null
+++ b/version/update.go
@@ -0,0 +1,377 @@
+// 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 version
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/util"
+)
+
+// Stage is where an update has got to. The web UI polls for it, so these
+// strings are part of the API.
+type Stage string
+
+const (
+ StageIdle Stage = "idle"
+ StageDownloading Stage = "downloading"
+ StageInstalling Stage = "installing"
+ StageRestarting Stage = "restarting"
+ StageFailed Stage = "failed"
+)
+
+// Reasons an installation cannot update itself. The web UI turns them into
+// something the reader can act on, so they are part of the API too.
+const (
+ BlockedNoExecutable = "no-executable"
+ BlockedPlatform = "unsupported-platform"
+ BlockedReadOnly = "read-only"
+)
+
+// Status is the progress of the running update, or of the last one that
failed.
+type Status struct {
+ Stage Stage `json:"stage"`
+ Percent int `json:"percent"`
+ Downloaded int64 `json:"downloaded"`
+ Total int64 `json:"total"`
+ Target string `json:"target"`
+ Error string `json:"error"`
+}
+
+// stagingDir holds the download and the unpacked executable. It sits next to
+// the running executable so that putting the new one in place is a rename on
+// one filesystem, which cannot half-finish.
+const stagingDir = ".update"
+
+// backupSuffix names the executable that was replaced. It is kept until the
+// updated Gateway has started, and deleted on its first run.
+const backupSuffix = ".old"
+
+// maxBinarySize bounds what is unpacked, so a corrupt archive cannot fill the
+// disk.
+const maxBinarySize = 512 << 20
+
+// restartDelay gives the API response time to reach the browser before this
+// process goes away.
+const restartDelay = 700 * time.Millisecond
+
+// The executable an update replaced is still running for a moment after the
new
+// one starts, and Windows will not delete a file that is open, so removing it
+// has to be attempted more than once.
+const (
+ cleanupAttempts = 15
+ cleanupInterval = 2 * time.Second
+)
+
+var (
+ statusLock sync.Mutex
+ status = Status{Stage: StageIdle}
+ logPath = "./logs/casbin-gateway.out"
+)
+
+// BeforeRestart is what the process about to be replaced does first, so that
an
+// update does not drop what it was still holding in memory.
+var BeforeRestart func()
+
+// Configure points an update at the log the restarted Gateway writes to.
+func Configure(daemonLogPath string) {
+ statusLock.Lock()
+ defer statusLock.Unlock()
+
+ logPath = daemonLogPath
+}
+
+// UpdateStatus is the progress of the update running in this process.
+func UpdateStatus() Status {
+ statusLock.Lock()
+ defer statusLock.Unlock()
+
+ return status
+}
+
+func setStatus(next Status) {
+ statusLock.Lock()
+ defer statusLock.Unlock()
+
+ status = next
+}
+
+func fail(err error) {
+ statusLock.Lock()
+ defer statusLock.Unlock()
+
+ status.Stage = StageFailed
+ status.Error = err.Error()
+}
+
+// BlockedReason says why this installation cannot replace its own executable,
+// and is empty when it can.
+func BlockedReason() string {
+ if AssetName(runtime.GOOS, runtime.GOARCH) == "" {
+ return BlockedPlatform
+ }
+
+ executable, err := executablePath()
+ if err != nil {
+ return BlockedNoExecutable
+ }
+
+ // Whether the executable can be replaced is a property of the directory
+ // holding it, not of the file: replacing it is a rename, not a write.
+ probe, err := os.CreateTemp(filepath.Dir(executable), ".update-probe-*")
+ if err != nil {
+ return BlockedReadOnly
+ }
+ probe.Close()
+ os.Remove(probe.Name())
+
+ return ""
+}
+
+// executablePath is where this executable actually lives, with any symlink
+// followed: replacing the link rather than its target would leave the real
+// executable behind.
+func executablePath() (string, error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+
+ resolved, err := filepath.EvalSymlinks(executable)
+ if err != nil {
+ return executable, nil
+ }
+
+ return resolved, nil
+}
+
+// StartUpdate downloads the published build, puts it in place of this one and
+// restarts into it. It returns as soon as the work is under way; UpdateStatus
+// reports the rest.
+func StartUpdate() error {
+ statusLock.Lock()
+ running := status.Stage == StageDownloading || status.Stage ==
StageInstalling || status.Stage == StageRestarting
+ statusLock.Unlock()
+
+ if running {
+ return errors.New("an update is already running")
+ }
+
+ if reason := BlockedReason(); reason != "" {
+ return errors.New(describeBlocked(reason))
+ }
+
+ release, err := LatestRelease(false)
+ if err != nil {
+ return err
+ }
+ if release == nil || release.AssetUrl == "" {
+ return fmt.Errorf("no build is published for %s/%s",
runtime.GOOS, runtime.GOARCH)
+ }
+ if !IsNewer(release, Current()) {
+ return errors.New("this Gateway is already the published build")
+ }
+
+ setStatus(Status{
+ Stage: StageDownloading,
+ Target: releaseLabel(release),
+ Total: release.AssetSize,
+ })
+
+ go func() {
+ if err := runUpdate(release); err != nil {
+ fail(err)
+ }
+ }()
+
+ return nil
+}
+
+func describeBlocked(reason string) string {
+ switch reason {
+ case BlockedPlatform:
+ return fmt.Sprintf("no build is published for %s/%s, so this
Gateway cannot update itself", runtime.GOOS, runtime.GOARCH)
+ case BlockedReadOnly:
+ return "the directory holding this executable cannot be written
to, so it cannot be replaced"
+ default:
+ return "this Gateway cannot find its own executable, so it
cannot replace it"
+ }
+}
+
+func releaseLabel(release *Release) string {
+ if release.ShortCommit == "" {
+ return release.Tag
+ }
+
+ return release.Tag + " " + release.ShortCommit
+}
+
+func runUpdate(release *Release) error {
+ executable, err := executablePath()
+ if err != nil {
+ return err
+ }
+
+ staging := filepath.Join(filepath.Dir(executable), stagingDir)
+ if err := os.RemoveAll(staging); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(staging, 0o755); err != nil {
+ return err
+ }
+ defer os.RemoveAll(staging)
+
+ archive := filepath.Join(staging, release.AssetName)
+ if err := download(release, archive); err != nil {
+ return err
+ }
+
+ statusLock.Lock()
+ status.Stage = StageInstalling
+ status.Percent = 100
+ statusLock.Unlock()
+
+ staged := filepath.Join(staging, filepath.Base(executable))
+ if err := extractBinary(archive, staged); err != nil {
+ return err
+ }
+ if err := os.Chmod(staged, 0o755); err != nil {
+ return err
+ }
+
+ // The archive is only known to hold a working Gateway once one has
run, and
+ // after the swap below there is no one left to notice that it does not.
+ if err := smokeTest(staged); err != nil {
+ return fmt.Errorf("the downloaded build does not run, so it was
not installed: %w", err)
+ }
+
+ if err := swap(executable, staged); err != nil {
+ return err
+ }
+
+ statusLock.Lock()
+ status.Stage = StageRestarting
+ statusLock.Unlock()
+
+ return restart(executable)
+}
+
+// smokeTest runs the downloaded executable with the one sub-command that exits
+// on its own, which catches a truncated download or an archive built for
+// another platform.
+func smokeTest(path string) error {
+ cmd := exec.Command(path, "version")
+ cmd.Dir = filepath.Dir(path)
+ // A copy of this environment would carry the daemon marker into a
process
+ // that is only meant to print a line and exit.
+ cmd.Env = []string{}
+
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(30 * time.Second):
+ _ = cmd.Process.Kill()
+ return errors.New("it did not answer within 30s")
+ }
+}
+
+// swap puts the new executable where the old one was. The old one is renamed
+// rather than deleted: it is the file this very process runs from, which
+// Windows will not let go of, and keeping it means the rename can be undone.
+func swap(executable string, staged string) error {
+ backup := executable + backupSuffix
+ _ = os.Remove(backup)
+
+ if err := os.Rename(executable, backup); err != nil {
+ return fmt.Errorf("could not move the running executable aside:
%w", err)
+ }
+
+ if err := os.Rename(staged, executable); err != nil {
+ // Nothing has been replaced yet, so putting the old one back
leaves the
+ // installation exactly as it was.
+ _ = os.Rename(backup, executable)
+ return fmt.Errorf("could not put the new executable in place:
%w", err)
+ }
+
+ return nil
+}
+
+// restart starts the updated Gateway and ends this process. The new one stops
+// whatever still holds the port, so the two never fight over it.
+func restart(executable string) error {
+ workingDir, err := os.Getwd()
+ if err != nil {
+ workingDir = filepath.Dir(executable)
+ }
+
+ statusLock.Lock()
+ daemonLog := logPath
+ statusLock.Unlock()
+
+ if _, err := util.StartDetached(executable, workingDir, daemonLog); err
!= nil {
+ return fmt.Errorf("the new executable is installed but did not
start: %w", err)
+ }
+
+ go func() {
+ time.Sleep(restartDelay)
+ if BeforeRestart != nil {
+ BeforeRestart()
+ }
+ os.Exit(0)
+ }()
+
+ return nil
+}
+
+// CleanupBackup removes what an update left behind. It runs at startup and
+// keeps trying in the background for as long as the process it replaced takes
+// to go away, rather than leaving a second copy of the executable on disk
until
+// the next restart.
+func CleanupBackup() {
+ executable, err := executablePath()
+ if err != nil {
+ return
+ }
+
+ _ = os.RemoveAll(filepath.Join(filepath.Dir(executable), stagingDir))
+
+ backup := executable + backupSuffix
+ if _, err := os.Stat(backup); err != nil {
+ return
+ }
+
+ go func() {
+ for attempt := 0; attempt < cleanupAttempts; attempt++ {
+ if err := os.Remove(backup); err == nil {
+ return
+ }
+ time.Sleep(cleanupInterval)
+ }
+ }()
+}
diff --git a/version/version.go b/version/version.go
new file mode 100644
index 0000000..22dbb7f
--- /dev/null
+++ b/version/version.go
@@ -0,0 +1,145 @@
+// 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 version
+
+import (
+ "fmt"
+ "runtime"
+ "runtime/debug"
+ "sync"
+ "time"
+)
+
+// Filled in by the release build:
+//
+// -ldflags "-X
github.com/apache/casbin-gateway/version.buildVersion=nightly
+// -X github.com/apache/casbin-gateway/version.buildCommit=<sha>
+// -X
github.com/apache/casbin-gateway/version.buildDate=<RFC3339>"
+var (
+ buildVersion string
+ buildCommit string
+ buildDate string
+)
+
+// DevVersion is what an unstamped build calls itself.
+const DevVersion = "dev"
+
+// Build is what this executable knows about itself.
+type Build struct {
+ Version string `json:"version"`
+ Commit string `json:"commit"`
+ ShortCommit string `json:"shortCommit"`
+ BuildTime string `json:"buildTime"`
+ // Modified marks a build made from a checkout with uncommitted changes,
+ // which no commit alone can identify.
+ Modified bool `json:"modified"`
+ Os string `json:"os"`
+ Arch string `json:"arch"`
+ GoVersion string `json:"goVersion"`
+}
+
+var (
+ currentOnce sync.Once
+ current Build
+)
+
+// Current describes this executable. An unstamped build still reports a commit
+// and a date: "go build" records the checkout it came from.
+func Current() Build {
+ currentOnce.Do(func() {
+ current = Build{
+ Version: buildVersion,
+ Commit: buildCommit,
+ BuildTime: buildDate,
+ Os: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ GoVersion: runtime.Version(),
+ }
+
+ if info, ok := debug.ReadBuildInfo(); ok {
+ for _, setting := range info.Settings {
+ switch setting.Key {
+ case "vcs.revision":
+ if current.Commit == "" {
+ current.Commit = setting.Value
+ }
+ case "vcs.time":
+ if current.BuildTime == "" {
+ current.BuildTime =
setting.Value
+ }
+ case "vcs.modified":
+ current.Modified = setting.Value ==
"true"
+ }
+ }
+ }
+
+ if current.Version == "" {
+ current.Version = DevVersion
+ }
+ current.ShortCommit = ShortCommit(current.Commit)
+ })
+
+ return current
+}
+
+// BuildTime is when this executable was built, zero when nothing recorded it.
+func (b Build) Time() time.Time {
+ moment, err := time.Parse(time.RFC3339, b.BuildTime)
+ if err != nil {
+ return time.Time{}
+ }
+
+ return moment
+}
+
+// String is the one-line form printed by "casbin-gateway version".
+func (b Build) String() string {
+ text := b.Version
+ if b.ShortCommit != "" {
+ text += " (" + b.ShortCommit
+ if b.Modified {
+ text += ", modified"
+ }
+ text += ")"
+ }
+ if b.BuildTime != "" {
+ text += " built " + b.BuildTime
+ }
+
+ return fmt.Sprintf("%s %s/%s %s", text, b.Os, b.Arch, b.GoVersion)
+}
+
+func ShortCommit(commit string) string {
+ if len(commit) > 7 {
+ return commit[:7]
+ }
+
+ return commit
+}
+
+// RunCommand answers "casbin-gateway version" and reports whether it did, so
+// the caller knows not to go on and serve. The staged binary of an update is
+// smoke-tested with this before it replaces the running one.
+func RunCommand(args []string) bool {
+ for _, arg := range args[1:] {
+ switch arg {
+ case "version", "--version", "-v":
+ fmt.Println("Casbin Gateway", Current().String())
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 514e489..2694ea9 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -258,6 +258,7 @@ export default function App() {
onToggleSidebar={toggleSidebar}
uri={location.pathname}
account={account}
+ isAdmin={Setting.isAdminUser(account)}
themeAlgorithm={themeAlgorithm}
onThemeChange={changeTheme}
onSignout={signout}
diff --git a/web/src/backend/MiscBackend.ts b/web/src/backend/MiscBackend.ts
index fe1d0cb..c207aa2 100644
--- a/web/src/backend/MiscBackend.ts
+++ b/web/src/backend/MiscBackend.ts
@@ -13,7 +13,14 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {Application, CasdoorProvider, GatewayStatus, MetricPoint} from
"@/types";
+import type {
+ Application,
+ CasdoorProvider,
+ GatewayStatus,
+ MetricPoint,
+ UpdateStatus,
+ VersionInfo,
+} from "@/types";
export function getCasdoorProviders() {
return request<CasdoorProvider[]>("/api/get-casdoor-providers");
@@ -32,6 +39,20 @@ export function getRelayToken() {
return request<{relayToken: string; localOnly:
boolean}>("/api/get-relay-token");
}
+/** Which build this Gateway is, and which one is published. */
+export function getVersion(refresh = false) {
+ return request<VersionInfo>(`/api/get-version${query({refresh: refresh ? 1 :
undefined})}`);
+}
+
+/** Starts the download; the work carries on after this answers. */
+export function updateGateway() {
+ return request<UpdateStatus>("/api/update-gateway", "POST");
+}
+
+export function getUpdateStatus() {
+ return request<UpdateStatus>("/api/get-update-status");
+}
+
export function getMetric(type: string, rangeType: string, count: number,
top?: number) {
return request<MetricPoint[]>(
`/api/get-metrics${query({type: type, rangeType: rangeType, count: count,
top: top})}`,
diff --git a/web/src/components/shared/app-header.tsx
b/web/src/components/shared/app-header.tsx
index 16e6082..8326da6 100644
--- a/web/src/components/shared/app-header.tsx
+++ b/web/src/components/shared/app-header.tsx
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/dropdown-menu";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {BreadcrumbBar} from "@/components/shared/breadcrumb-bar";
+import {VersionPanel} from "@/components/shared/version-panel";
function ThemeToggle({
themeAlgorithm,
@@ -158,6 +159,7 @@ export function AppHeader({
onToggleSidebar,
uri,
account,
+ isAdmin,
themeAlgorithm,
onThemeChange,
onSignout,
@@ -166,6 +168,7 @@ export function AppHeader({
onToggleSidebar: () => void;
uri: string;
account: Account | null | undefined;
+ isAdmin: boolean;
themeAlgorithm: ThemeAlgorithm;
onThemeChange: (next: ThemeAlgorithm) => void;
onSignout: () => void;
@@ -185,6 +188,7 @@ export function AppHeader({
</div>
<div className="flex items-center gap-0.5 pr-1">
+ <VersionPanel isAdmin={isAdmin} signedIn={account !== null && account
!== undefined} />
<ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange}
/>
<LanguageSelect />
<AccountMenu account={account} onSignout={onSignout} />
diff --git a/web/src/components/shared/version-panel.tsx
b/web/src/components/shared/version-panel.tsx
new file mode 100644
index 0000000..75cd4b3
--- /dev/null
+++ b/web/src/components/shared/version-panel.tsx
@@ -0,0 +1,460 @@
+// 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.
+
+import * as React from "react";
+import {ArrowUpCircle, Check, CircleAlert, Copy, ExternalLink, Loader2,
RefreshCw} from "lucide-react";
+import copy from "copy-to-clipboard";
+import i18next from "i18next";
+
+import * as MiscBackend from "@/backend/MiscBackend";
+import type {UpdateStatus, VersionBuild, VersionInfo, VersionRelease} from
"@/types";
+import {Button} from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {Progress} from "@/components/ui/progress";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {formatBytes} from "@/lib/agent-configs";
+
+/**
+ * Where the update has got to as far as this page is concerned. It is not the
+ * backend's stage: from "restarting" onwards the backend is gone, and what is
+ * left to follow is whether the new one has answered yet.
+ */
+type Phase = "idle" | "starting" | "running" | "waiting" | "done";
+
+/** How long the restarted Gateway is given to answer before giving up on it.
*/
+const restartTimeout = 120 * 1000;
+const runningPollInterval = 800;
+const waitingPollInterval = 1500;
+
+function buildLabel(build: VersionBuild) {
+ if (build.shortCommit === "") {
+ return build.version;
+ }
+
+ const suffix = build.modified ? `${build.shortCommit}+` : build.shortCommit;
+ return `${build.version} · ${suffix}`;
+}
+
+function releaseLabel(release: VersionRelease) {
+ return release.shortCommit === "" ? release.tag : `${release.tag} ·
${release.shortCommit}`;
+}
+
+function pad(value: number) {
+ return String(value).padStart(2, "0");
+}
+
+/** "2026-08-23 17:22", in the reader's own time zone. */
+function formatMoment(value: string) {
+ const moment = new Date(value);
+ if (isNaN(moment.getTime())) {
+ return "";
+ }
+
+ const day = formatDay(value);
+ return `${day} ${pad(moment.getHours())}:${pad(moment.getMinutes())}`;
+}
+
+/** The day alone, which is what says at a glance how old a build is. */
+function formatDay(value: string) {
+ const moment = new Date(value);
+ if (isNaN(moment.getTime())) {
+ return "";
+ }
+
+ return `${moment.getFullYear()}-${pad(moment.getMonth() +
1)}-${pad(moment.getDate())}`;
+}
+
+function blockedHint(blocked: string) {
+ switch (blocked) {
+ case "unsupported-platform":
+ return i18next.t("general:No build is published for this platform, so the
update has to be done by hand");
+ case "read-only":
+ return i18next.t("general:This executable sits where it cannot be
replaced, so the update has to be done by hand");
+ default:
+ return i18next.t("general:This Gateway cannot find its own executable, so
the update has to be done by hand");
+ }
+}
+
+function stageLabel(status: UpdateStatus | null, phase: Phase) {
+ if (phase === "waiting") {
+ return i18next.t("general:Restarting into the new version");
+ }
+ if (phase === "done") {
+ return i18next.t("general:Update complete, reloading");
+ }
+ switch (status?.stage) {
+ case "installing":
+ return i18next.t("general:Installing");
+ case "restarting":
+ return i18next.t("general:Restarting into the new version");
+ default:
+ return i18next.t("general:Downloading");
+ }
+}
+
+/** A labelled row of the version table, so the two versions line up. */
+function InfoRow({label, value, note}: {label: string; value: React.ReactNode;
note?: string}) {
+ return (
+ <div className="flex items-baseline justify-between gap-4 py-1.5">
+ <span className="text-muted-foreground shrink-0 text-sm">{label}</span>
+ <span className="min-w-0 text-right">
+ <span className="block truncate font-mono text-sm
font-medium">{value}</span>
+ {note ? <span className="text-muted-foreground block
text-xs">{note}</span> : null}
+ </span>
+ </div>
+ );
+}
+
+function CommandLine({command}: {command: string}) {
+ const [copied, setCopied] = React.useState(false);
+
+ return (
+ <div className="bg-muted/60 flex items-center gap-2 rounded-md border p-2">
+ <code className="scrollbar-thin min-w-0 flex-1 overflow-x-auto text-xs
whitespace-nowrap">{command}</code>
+ <Button
+ variant="ghost"
+ size="icon-sm"
+ aria-label={i18next.t("general:Copy")}
+ onClick={() => {
+ copy(command);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ }}
+ >
+ {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5"
/>}
+ </Button>
+ </div>
+ );
+}
+
+/**
+ * The version of this Gateway, the version it could be, and the one button
that
+ * closes the gap. It sits in the header rather than out of the way, and
carries
+ * the build date: a commit hash alone says nothing about how old a build is.
+ */
+export function VersionPanel({
+ isAdmin,
+ signedIn,
+}: {
+ isAdmin: boolean;
+ /** The version is behind the session, so asking before there is one answers
nothing. */
+ signedIn: boolean;
+}) {
+ const [info, setInfo] = React.useState<VersionInfo | null>(null);
+ const [checking, setChecking] = React.useState(false);
+ const [open, setOpen] = React.useState(false);
+ const [phase, setPhase] = React.useState<Phase>("idle");
+ const [status, setStatus] = React.useState<UpdateStatus | null>(null);
+ const [failure, setFailure] = React.useState("");
+
+ const load = React.useCallback((refresh: boolean) => {
+ setChecking(refresh);
+ return MiscBackend.getVersion(refresh)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setInfo(res.data);
+ }
+ })
+ // A version nobody can read is not worth a toast on every page load.
+ .catch(() => undefined)
+ .finally(() => setChecking(false));
+ }, []);
+
+ React.useEffect(() => {
+ if (signedIn) {
+ load(false);
+ }
+ }, [load, signedIn]);
+
+ // While the update runs, the backend is still there to be asked how far it
+ // has got. It stops answering the moment it restarts, which is not a failure
+ // but the next phase.
+ React.useEffect(() => {
+ if (phase !== "running") {
+ return;
+ }
+
+ let stopped = false;
+ const tick = () => {
+ MiscBackend.getUpdateStatus()
+ .then(res => {
+ if (stopped || res.status !== "ok" || !res.data) {
+ return;
+ }
+ setStatus(res.data);
+ if (res.data.stage === "failed") {
+ setFailure(res.data.error);
+ setPhase("idle");
+ } else if (res.data.stage === "restarting") {
+ setPhase("waiting");
+ }
+ })
+ .catch(() => {
+ if (!stopped) {
+ setPhase("waiting");
+ }
+ });
+ };
+
+ const timer = window.setInterval(tick, runningPollInterval);
+ tick();
+ return () => {
+ stopped = true;
+ window.clearInterval(timer);
+ };
+ }, [phase]);
+
+ // The restarted Gateway is a new process, so its update status is "idle" —
+ // which is exactly what tells the two of them apart across the restart.
+ React.useEffect(() => {
+ if (phase !== "waiting") {
+ return;
+ }
+
+ let stopped = false;
+ const startedAt = Date.now();
+ const timer = window.setInterval(() => {
+ if (Date.now() - startedAt > restartTimeout) {
+ stopped = true;
+ window.clearInterval(timer);
+ setFailure(i18next.t("general:The new version did not start, check the
Gateway log"));
+ setPhase("idle");
+ return;
+ }
+
+ MiscBackend.getUpdateStatus()
+ .then(res => {
+ if (stopped || res.status !== "ok" || !res.data) {
+ return;
+ }
+ if (res.data.stage === "failed") {
+ stopped = true;
+ window.clearInterval(timer);
+ setFailure(res.data.error);
+ setPhase("idle");
+ return;
+ }
+ if (res.data.stage === "idle") {
+ stopped = true;
+ window.clearInterval(timer);
+ setPhase("done");
+ window.setTimeout(() => window.location.reload(), 1200);
+ }
+ })
+ // The gap where nothing answers is the restart itself.
+ .catch(() => undefined);
+ }, waitingPollInterval);
+
+ return () => {
+ stopped = true;
+ window.clearInterval(timer);
+ };
+ }, [phase]);
+
+ const startUpdate = () => {
+ setFailure("");
+ setStatus(null);
+ setPhase("starting");
+ MiscBackend.updateGateway()
+ .then(res => {
+ if (res.status !== "ok") {
+ setFailure(res.msg);
+ setPhase("idle");
+ return;
+ }
+ setStatus(res.data ?? null);
+ setPhase("running");
+ })
+ .catch(error => {
+ setFailure(error?.message ?? String(error));
+ setPhase("idle");
+ });
+ };
+
+ const busy = phase !== "idle";
+ const updateAvailable = info?.updateAvailable === true;
+ const current = info?.current;
+ const day = current ? formatDay(current.buildTime) : "";
+ // "nightly · 7696f22 · 2026-08-23": which build, and how old it is.
+ const label = current ? [buildLabel(current), day].filter(part => part !==
"").join(" · ") : "";
+
+ // An empty chip in the header while the version is on its way, or for a
+ // reader with no session to ask with, is worse than no chip at all.
+ if (info === null) {
+ return null;
+ }
+
+ const trigger = (
+ <SimpleTooltip
+ title={updateAvailable && info.latest ? `${i18next.t("general:Update
available")}: ${releaseLabel(info.latest)}` : label}
+ >
+ <button
+ type="button"
+ onClick={() => setOpen(true)}
+ className={cn(
+ // No transition-colors: switching theme leaves an element that has
one
+ // stuck on the palette it was rendered in until the page reloads.
+ "flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1
font-mono text-xs",
+ updateAvailable
+ ? "border-primary/40 bg-primary/10 text-primary
hover:bg-primary/20"
+ : "text-muted-foreground hover:bg-accent hover:text-foreground
border-transparent",
+ )}
+ >
+ {updateAvailable ? <ArrowUpCircle className="size-3.5 shrink-0" /> :
null}
+ {/* Narrow screens keep the date, which is the half that ages. */}
+ <span className="hidden lg:inline">{label}</span>
+ <span className="lg:hidden">{day === "" ? label : day}</span>
+ {updateAvailable ? (
+ <span className="bg-primary text-primary-foreground shrink-0
rounded-full px-1.5 text-[10px] font-medium">
+ {i18next.t("general:New")}
+ </span>
+ ) : null}
+ </button>
+ </SimpleTooltip>
+ );
+
+ return (
+ <>
+ {trigger}
+
+ <Dialog
+ open={open}
+ onOpenChange={next => {
+ // Closing mid-update would leave nothing watching for the restart,
+ // and the page is about to reload itself anyway.
+ if (!busy) {
+ setOpen(next);
+ }
+ }}
+ >
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>{i18next.t("general:Version")}</DialogTitle>
+ <DialogDescription>
+ {i18next.t("general:Casbin Gateway installs updates from the
nightly build of master")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="divide-y">
+ {current ? (
+ <InfoRow
+ label={i18next.t("general:Current version")}
+ value={buildLabel(current)}
+ note={[
+ current.buildTime ? `${i18next.t("general:Built")}
${formatMoment(current.buildTime)}` : "",
+ `${current.os}/${current.arch}`,
+ ]
+ .filter(part => part !== "")
+ .join(" · ")}
+ />
+ ) : (
+ <InfoRow label={i18next.t("general:Current version")} value="…"
/>
+ )}
+
+ {info?.latest ? (
+ <InfoRow
+ label={i18next.t("general:Latest version")}
+ value={releaseLabel(info.latest)}
+ note={
+ info.latest.publishedAt
+ ? `${i18next.t("general:Published")}
${formatMoment(info.latest.publishedAt)}`
+ : undefined
+ }
+ />
+ ) : null}
+ </div>
+
+ {info?.checkError ? (
+ <p className="text-muted-foreground flex items-start gap-2
text-xs">
+ <CircleAlert className="text-warning mt-0.5 size-3.5 shrink-0" />
+ {i18next.t("general:Could not check for updates")}:
{info.checkError}
+ </p>
+ ) : null}
+
+ {failure ? (
+ <p className="text-destructive flex items-start gap-2 text-xs">
+ <CircleAlert className="mt-0.5 size-3.5 shrink-0" />
+ {failure}
+ </p>
+ ) : null}
+
+ {busy ? (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="flex items-center gap-2">
+ {phase === "done" ? (
+ <Check className="text-success size-4" />
+ ) : (
+ <Loader2 className="size-4 animate-spin" />
+ )}
+ {stageLabel(status, phase)}
+ </span>
+ {status && status.stage === "downloading" && status.total > 0
? (
+ <span className="text-muted-foreground text-xs">
+ {formatBytes(status.downloaded)} /
{formatBytes(status.total)}
+ </span>
+ ) : null}
+ </div>
+ <Progress
+ value={phase === "done" ? 100 : (status?.percent ?? 0)}
+ tone={phase === "done" ? "success" : "default"}
+ className={cn(phase === "waiting" && "animate-pulse")}
+ />
+ <p className="text-muted-foreground text-xs">
+ {i18next.t("general:Leave this page open, it reloads itself
when the new version is up")}
+ </p>
+ </div>
+ ) : updateAvailable && info?.canUpdate && isAdmin ? (
+ <Button onClick={startUpdate} className="w-full">
+ <ArrowUpCircle className="size-4" />
+ {i18next.t("general:Update now")}
+ </Button>
+ ) : updateAvailable && info && !info.canUpdate ? (
+ <div className="space-y-2">
+ <p className="text-muted-foreground
text-xs">{blockedHint(info.blocked)}</p>
+ <CommandLine command={info.installCommand} />
+ </div>
+ ) : info !== null && !updateAvailable && info.checkError === "" ? (
+ <p className="text-muted-foreground flex items-center gap-2
text-sm">
+ <Check className="text-success size-4" />
+ {i18next.t("general:This is the latest version")}
+ </p>
+ ) : null}
+
+ <div className="flex items-center justify-between gap-2 pt-1">
+ <Button variant="ghost" size="sm" disabled={busy || checking}
onClick={() => load(true)}>
+ <RefreshCw className={cn("size-4", checking && "animate-spin")}
/>
+ {i18next.t("general:Check for updates")}
+ </Button>
+ {info?.releaseUrl ? (
+ <Button variant="ghost" size="sm" asChild>
+ <a href={info.releaseUrl} target="_blank" rel="noreferrer">
+ {i18next.t("general:Release notes")}
+ <ExternalLink className="size-3.5" />
+ </a>
+ </Button>
+ ) : null}
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d009812..4e523df 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -307,25 +307,31 @@
"Advanced": "Advanced",
"At Any Time": "At Any Time",
"Back Home": "Back Home",
+ "Built": "Built",
"Cancel": "Cancel",
"Cannot connect to the server, please check that the backend is running":
"Cannot connect to the server, please check that the backend is running",
+ "Casbin Gateway installs updates from the nightly build of master":
"Casbin Gateway installs updates from the nightly build of master",
"Certs": "Certs",
+ "Check for updates": "Check for updates",
"Client IP": "Client IP",
"Client ip": "Client ip",
"Close": "Close",
"Copied": "Copied",
"Copied to clipboard": "Copied to clipboard",
"Copy": "Copy",
+ "Could not check for updates": "Could not check for updates",
"Count": "Count",
"Create time": "Create time",
"Created time": "Created time",
"CreatedTime": "CreatedTime",
+ "Current version": "Current version",
"Dark": "Dark",
"Dashboard": "Dashboard",
"Delete": "Delete",
"Deleted successfully": "Deleted successfully",
"Display name": "Display name",
"Down": "Down",
+ "Downloading": "Downloading",
"Edit": "Edit",
"Edit Record": "Edit Record",
"Failed to add": "Failed to add",
@@ -341,15 +347,20 @@
"ID": "ID",
"IP Address": "IP Address",
"Input IP Addresses": "Input IP Addresses",
+ "Installing": "Installing",
+ "Latest version": "Latest version",
+ "Leave this page open, it reloads itself when the new version is up":
"Leave this page open, it reloads itself when the new version is up",
"Light": "Light",
"Logged in successfully": "Logged in successfully",
"Login Error": "Login Error",
"Method": "Method",
"Name": "Name",
"Name cannot be empty": "Name cannot be empty",
+ "New": "New",
"New Record": "New Record",
"Next": "Next",
"No Upgrade": "No Upgrade",
+ "No build is published for this platform, so the update has to be done by
hand": "No build is published for this platform, so the update has to be done
by hand",
"No data": "No data",
"Nodes": "Nodes",
"OK": "OK",
@@ -361,9 +372,12 @@
"Previous": "Previous",
"Proxy Analytics": "Proxy Analytics",
"Proxy Log": "Proxy Log",
+ "Published": "Published",
"Records": "Records",
"Refresh": "Refresh",
+ "Release notes": "Release notes",
"Request Count Over Time": "Request Count Over Time",
+ "Restarting into the new version": "Restarting into the new version",
"Restore": "Restore",
"Rules": "Rules",
"Save": "Save",
@@ -377,7 +391,11 @@
"Successfully saved": "Successfully saved",
"Sure to delete {name} ?": "Sure to delete {name} ?",
"Tag": "Tag",
+ "The new version did not start, check the Gateway log": "The new version
did not start, check the Gateway log",
+ "This Gateway cannot find its own executable, so the update has to be done
by hand": "This Gateway cannot find its own executable, so the update has to be
done by hand",
+ "This executable sits where it cannot be replaced, so the update has to be
done by hand": "This executable sits where it cannot be replaced, so the update
has to be done by hand",
"This is a read-only demo site!": "This is a read-only demo site!",
+ "This is the latest version": "This is the latest version",
"Toggle navigation": "Toggle navigation",
"Toggle sidebar": "Toggle sidebar",
"Top 10 IP Addresses": "Top 10 IP Addresses",
@@ -386,9 +404,13 @@
"Unauthorized": "Unauthorized",
"Unique IP Count": "Unique IP Count",
"Up": "Up",
+ "Update available": "Update available",
+ "Update complete, reloading": "Update complete, reloading",
+ "Update now": "Update now",
"Update time": "Update time",
"Upgrade mode": "Upgrade mode",
"User-Agent": "User-Agent",
+ "Version": "Version",
"{total} in total": "{total} in total"
},
"llm": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 1723f87..2626b5e 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -307,25 +307,31 @@
"Advanced": "高级",
"At Any Time": "任意时间",
"Back Home": "返回首页",
+ "Built": "构建于",
"Cancel": "取消",
"Cannot connect to the server, please check that the backend is running":
"无法连接到服务器,请检查后端是否已启动",
+ "Casbin Gateway installs updates from the nightly build of master":
"Casbin Gateway 从 master 的 nightly 构建升级",
"Certs": "证书",
+ "Check for updates": "检查更新",
"Client IP": "客户端 IP",
"Client ip": "客户端IP",
"Close": "关闭",
"Copied": "已复制",
"Copied to clipboard": "已复制到剪贴板",
"Copy": "复制",
+ "Could not check for updates": "检查更新失败",
"Count": "计数",
"Create time": "创建时间",
"Created time": "创建时间",
"CreatedTime": "创建时间",
+ "Current version": "当前版本",
"Dark": "深色",
"Dashboard": "数据看板",
"Delete": "删除",
"Deleted successfully": "删除成功",
"Display name": "显示名称",
"Down": "下移",
+ "Downloading": "正在下载",
"Edit": "编辑",
"Edit Record": "编辑日志",
"Failed to add": "添加失败",
@@ -341,15 +347,20 @@
"ID": "ID",
"IP Address": "IP地址",
"Input IP Addresses": "请输入 IP 地址",
+ "Installing": "正在安装",
+ "Latest version": "最新版本",
+ "Leave this page open, it reloads itself when the new version is up":
"请保持本页面打开,新版本启动后会自动刷新",
"Light": "浅色",
"Logged in successfully": "登录成功",
"Login Error": "登录失败",
"Method": "方法",
"Name": "名称",
"Name cannot be empty": "名称不能为空",
+ "New": "新",
"New Record": "新建记录",
"Next": "下一页",
"No Upgrade": "不升级",
+ "No build is published for this platform, so the update has to be done by
hand": "该平台没有发布构建产物,需要手动升级",
"No data": "暂无数据",
"Nodes": "节点",
"OK": "确定",
@@ -361,9 +372,12 @@
"Previous": "上一页",
"Proxy Analytics": "代理统计",
"Proxy Log": "代理日志",
+ "Published": "发布于",
"Records": "日志",
"Refresh": "刷新",
+ "Release notes": "发布说明",
"Request Count Over Time": "请求数趋势",
+ "Restarting into the new version": "正在重启到新版本",
"Restore": "复原",
"Rules": "规则",
"Save": "保存",
@@ -377,7 +391,11 @@
"Successfully saved": "保存成功",
"Sure to delete {name} ?": "确定删除 {name} ?",
"Tag": "标签",
+ "The new version did not start, check the Gateway log": "新版本没有启动,请查看
Gateway 日志",
+ "This Gateway cannot find its own executable, so the update has to be done
by hand": "Gateway 找不到自己的可执行文件,无法替换,需要手动升级",
+ "This executable sits where it cannot be replaced, so the update has to be
done by hand": "当前可执行文件所在目录不可写,无法被替换,需要手动升级",
"This is a read-only demo site!": "这是一个只读演示站点!",
+ "This is the latest version": "已是最新版本",
"Toggle navigation": "收起/展开导航",
"Toggle sidebar": "折叠/展开侧边栏",
"Top 10 IP Addresses": "前10名IP地址",
@@ -386,9 +404,13 @@
"Unauthorized": "无权限",
"Unique IP Count": "独立IP统计",
"Up": "上移",
+ "Update available": "有新版本可用",
+ "Update complete, reloading": "升级完成,正在重新加载",
+ "Update now": "立即升级",
"Update time": "更新时间",
"Upgrade mode": "升级模式",
"User-Agent": "User-Agent",
+ "Version": "版本",
"{total} in total": "共 {total} 条"
},
"llm": {
diff --git a/web/src/types.ts b/web/src/types.ts
index a42fee5..5f4965b 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -597,3 +597,54 @@ export interface AgentConfigPlanItem {
reason?: string;
path?: string;
}
+
+/** What this Gateway executable knows about itself. */
+export interface VersionBuild {
+ version: string;
+ commit: string;
+ shortCommit: string;
+ buildTime: string;
+ /** Built from a checkout with uncommitted changes, which no commit
identifies. */
+ modified: boolean;
+ os: string;
+ arch: string;
+ goVersion: string;
+}
+
+/** The published build this Gateway can move to. */
+export interface VersionRelease {
+ tag: string;
+ name: string;
+ commit: string;
+ shortCommit: string;
+ publishedAt: string;
+ pageUrl: string;
+ assetName: string;
+ assetUrl: string;
+ assetSize: number;
+}
+
+export type UpdateStage = "idle" | "downloading" | "installing" | "restarting"
| "failed";
+
+export interface UpdateStatus {
+ stage: UpdateStage;
+ percent: number;
+ downloaded: number;
+ total: number;
+ target: string;
+ error: string;
+}
+
+export interface VersionInfo {
+ current: VersionBuild;
+ latest: VersionRelease | null;
+ updateAvailable: boolean;
+ canUpdate: boolean;
+ /** "unsupported-platform", "read-only" or "no-executable" when it cannot
update itself. */
+ blocked: string;
+ /** A failed lookup, which leaves the current version worth showing anyway.
*/
+ checkError: string;
+ update: UpdateStatus;
+ releaseUrl: string;
+ installCommand: string;
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]