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 300acf4 feat: add desktop app
300acf4 is described below
commit 300acf4c920746f206c011789ac0e2529c15c6e5
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 24 22:20:07 2026 +0800
feat: add desktop app
---
.github/workflows/build.yml | 44 +++++++++
.github/workflows/nightly.yml | 95 ++++++++++++++++--
.gitignore | 6 ++
README.md | 6 +-
README_zh.md | 6 +-
desktop/autostart_darwin.go | 83 ++++++++++++++++
desktop/autostart_linux.go | 77 +++++++++++++++
desktop/autostart_windows.go | 61 ++++++++++++
desktop/dpi_other.go | 21 ++++
desktop/dpi_windows.go | 61 ++++++++++++
desktop/focus_other.go | 22 +++++
desktop/focus_windows.go | 70 +++++++++++++
desktop/gateway.go | 186 +++++++++++++++++++++++++++++++++++
desktop/go.mod | 15 +++
desktop/go.sum | 14 +++
desktop/hide_windows.go | 94 ++++++++++++++++++
desktop/icon.go | 46 +++++++++
desktop/internal/assets/appicon.icns | Bin 0 -> 336974 bytes
desktop/internal/assets/appicon.ico | Bin 0 -> 69337 bytes
desktop/internal/assets/appicon.png | Bin 0 -> 115911 bytes
desktop/internal/assets/assets.go | 40 ++++++++
desktop/internal/assets/trayicon.png | Bin 0 -> 3421 bytes
desktop/main.go | 72 ++++++++++++++
desktop/process_other.go | 21 ++++
desktop/process_windows.go | 26 +++++
desktop/tray.go | 172 ++++++++++++++++++++++++++++++++
desktop/tray_linux.go | 26 +++++
desktop/tray_other.go | 21 ++++
desktop/window.go | 26 +++++
desktop/window_unix.go | 40 ++++++++
desktop/window_windows.go | 111 +++++++++++++++++++++
desktop/windowproc.go | 116 ++++++++++++++++++++++
scripts/install.ps1 | 81 ++++++++++-----
scripts/install.sh | 165 ++++++++++++++++++++++---------
version/desktop.go | 118 ++++++++++++++++++++++
version/update.go | 13 ++-
36 files changed, 1868 insertions(+), 87 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8518b46..9517fae 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -53,3 +53,47 @@ jobs:
run: |
go build -race -ldflags "-extldflags '-static'"
working-directory: ./
+
+ # The desktop launcher is a separate module with platform-specific files that
+ # nothing else compiles, so each of the three is built here.
+ desktop:
+ name: Desktop launcher (${{ matrix.os }})
+ needs: [ go-tests ]
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ goos: linux
+ - os: macos-latest
+ goos: darwin
+ - os: windows-latest
+ goos: windows
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.21'
+ cache-dependency-path: ./desktop/go.mod
+
+ # webview_go asks pkg-config for webkit2gtk-4.0, which this Ubuntu no
+ # longer ships; the shim points that name at the 4.1 it does.
+ - name: Install the Linux dependencies
+ if: ${{ matrix.goos == 'linux' }}
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
+ mkdir -p "${RUNNER_TEMP}/pkgconfig"
+ cat > "${RUNNER_TEMP}/pkgconfig/webkit2gtk-4.0.pc" <<'PC'
+ Name: webkit2gtk-4.0
+ Description: Shim mapping webkit2gtk-4.0 onto the installed 4.1
+ Version: 4.1
+ Requires: webkit2gtk-4.1
+ PC
+ echo "PKG_CONFIG_PATH=${RUNNER_TEMP}/pkgconfig" >> "${GITHUB_ENV}"
+
+ - name: Build
+ run: go build ./...
+ working-directory: ./desktop
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index dae40bf..1f85703 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -53,10 +53,46 @@ jobs:
path: ./web/build
retention-days: 1
+ # The desktop launcher is the one artifact that cannot be cross-compiled: its
+ # tray and its webview are Cocoa, so the macOS build needs a macOS runner.
The
+ # Linux and Windows launchers are built in the job below.
+ desktop-macos:
+ name: Build the macOS desktop launcher
+ runs-on: macos-latest
+ if: ${{ github.repository == 'apache/casbin-gateway' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache-dependency-path: ./desktop/go.mod
+
+ # The runner is arm64 and the published archive is x86_64, which macOS
+ # runs under Rosetta 2, so the launcher is built for the same
architecture
+ # as the server it sits next to.
+ - name: Build
+ working-directory: ./desktop
+ env:
+ CGO_ENABLED: "1"
+ GOARCH: amd64
+ CGO_CFLAGS: -arch x86_64
+ CGO_CXXFLAGS: -arch x86_64
+ CGO_LDFLAGS: -arch x86_64
+ run: go build -trimpath -ldflags "-s -w" -o casbin-gateway-desktop .
+
+ - name: Upload the launcher
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-darwin-${{ github.run_id }}
+ path: ./desktop/casbin-gateway-desktop
+ retention-days: 1
+
nightly:
name: Publish nightly
runs-on: ubuntu-latest
- needs: [ frontend ]
+ needs: [ frontend, desktop-macos ]
if: ${{ github.repository == 'apache/casbin-gateway' }}
steps:
- name: Checkout
@@ -67,6 +103,29 @@ jobs:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ./go.mod
+ # The Linux launcher links against GTK and WebKitGTK. webview_go still
+ # asks pkg-config for webkit2gtk-4.0, which this Ubuntu no longer ships,
+ # so a shim points that name at the 4.1 it does ship — the library itself
+ # is opened by name at runtime and already prefers 4.1.
+ - name: Install the Linux desktop build dependencies
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
+ mkdir -p "${RUNNER_TEMP}/pkgconfig"
+ cat > "${RUNNER_TEMP}/pkgconfig/webkit2gtk-4.0.pc" <<'PC'
+ Name: webkit2gtk-4.0
+ Description: Shim mapping webkit2gtk-4.0 onto the installed 4.1
+ Version: 4.1
+ Requires: webkit2gtk-4.1
+ PC
+
+ - name: Download the macOS desktop launcher
+ uses: actions/download-artifact@v4
+ with:
+ name: desktop-darwin-${{ github.run_id }}
+ path: ./dist/desktop-darwin
+
# The binaries are built with -tags embed, which bakes conf/app.conf, the
# web UI and the IP database in, so web/build has to be in place first.
- name: Download web build
@@ -111,6 +170,25 @@ jobs:
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 \
go build -tags embed -trimpath -ldflags "${ldflags}" -o
"${binary}" .
+ # The desktop launcher: its own module, and the only place CGO is
+ # needed. Windows does without it, Linux is built natively here,
and
+ # macOS comes from the job that ran on a macOS runner.
+ desktopBinary="${stage}/casbin-gateway-desktop"
+ case "${goos}" in
+ windows)
+ (cd desktop && GOOS=windows GOARCH="${goarch}" CGO_ENABLED=0 \
+ go build -trimpath -ldflags "-s -w" -o
"../${desktopBinary}.exe" .)
+ ;;
+ linux)
+ (cd desktop && CGO_ENABLED=1
PKG_CONFIG_PATH="${RUNNER_TEMP}/pkgconfig" \
+ go build -trimpath -ldflags "-s -w" -o "../${desktopBinary}"
.)
+ ;;
+ darwin)
+ cp dist/desktop-darwin/casbin-gateway-desktop
"${desktopBinary}"
+ chmod 755 "${desktopBinary}"
+ ;;
+ esac
+
cp LICENSE NOTICE DISCLAIMER README.md "${stage}/"
if [[ "${goos}" == "windows" ]]; then
@@ -124,6 +202,7 @@ jobs:
rm -rf "${stage}"
done
+ rm -rf dist/desktop-darwin
ls -l dist
- name: Write release notes
@@ -140,11 +219,15 @@ jobs:
## Contents
- Each archive holds one self-contained `casbin-gateway` executable,
together with
- `LICENSE`, `NOTICE` and `DISCLAIMER`. The executable is built with
`-tags embed`,
- so it carries its own settings, web UI and IP database and starts
with no files
- next to it. Data is written to `./data`, `./logs` and `./tmp` in the
working
- directory.
+ Each archive holds the self-contained `casbin-gateway` server, the
+ `casbin-gateway-desktop` launcher, and `LICENSE`, `NOTICE` and
`DISCLAIMER`. The
+ server is built with `-tags embed`, so it carries its own settings,
web UI and IP
+ database and starts with no files next to it. Data is written to
`./data`,
+ `./logs` and `./tmp` in the working directory.
+
+ The launcher is the desktop application: it starts the server, shows
the UI in a
+ native window, and stays in the tray after the window is closed.
Running the
+ server on its own with `casbin-gateway start` still works and needs
nothing else.
| Platform | Archive |
| --- | --- |
diff --git a/.gitignore b/.gitignore
index 64fb89c..80cb178 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,12 @@
# Built binaries
casbin-gateway
+casbin-gateway-desktop
+
+# Written next to the binaries at run time, for the window and the shortcuts
+casbin-gateway.ico
+casbin-gateway.icns
+casbin-gateway.png
# Release and nightly archives, staged here by the workflows
dist/
diff --git a/README.md b/README.md
index 4041ed6..56953e5 100644
--- a/README.md
+++ b/README.md
@@ -53,9 +53,11 @@ On Windows, in PowerShell:
irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
| iex
```
-Either one downloads the build for this machine, unpacks it into
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows),
puts a `casbin-gateway` command on your PATH, starts it in the background, and
arranges for it to start again when you log in. The terminal you installed from
is yours again straight away. Then open:
+Either one downloads the build for this machine, unpacks it into
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows),
puts a `casbin-gateway` command on your PATH, starts it, and arranges for it to
start again when you log in. The terminal you installed from is yours again
straight away.
-**http://localhost:17000** — no sign-in: Gateway serves this machine only and
signs the local admin in on sight.
+Gateway then opens in its own window — no sign-in: it serves this machine only
and signs the local admin in on sight. Closing that window leaves Gateway
running behind its tray icon, which is also where you reopen the window and
quit for real. There is a **Casbin Gateway** entry on your desktop and in the
Start menu, in `~/Applications`, or in the application menu, depending on the
platform.
+
+If you would rather use a browser, or you are on a machine with no desktop at
all, everything is still at **http://localhost:17000**, and `casbin-gateway
start` runs the server on its own with no window and no tray.
That is the whole installation. Gateway keeps its data in a SQLite file inside
its own directory.
diff --git a/README_zh.md b/README_zh.md
index b857a01..ee74284 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -53,9 +53,11 @@ Windows,在 PowerShell 中:
irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
| iex
```
-两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入
PATH,在后台启动它,并设置成开机(登录)自启。安装用的那个终端窗口立刻就还给你。接着打开:
+两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入
PATH,启动它,并设置成开机(登录)自启。安装用的那个终端窗口立刻就还给你。
-**http://localhost:17000** —— 不用登录:Gateway 只服务本机,本机访问会直接以管理员身份进入。
+Gateway 会在自己的窗口里打开 ——
不用登录:它只服务本机,本机访问会直接以管理员身份进入。关掉窗口不会退出,它缩到托盘图标后台继续跑,重新打开窗口和真正退出都在托盘菜单里。桌面和开始菜单里会有一个
**Casbin Gateway** 快捷方式;macOS 上在 `~/Applications`,Linux 上在应用菜单里。
+
+想用浏览器,或者机器上根本没有桌面环境,**http://localhost:17000** 一样能访问;`casbin-gateway start`
则只跑服务端,没有窗口也没有托盘。
安装到此为止。Gateway 把数据存在自己目录下的一个 SQLite 文件里,登录也走它自己的用户表。
diff --git a/desktop/autostart_darwin.go b/desktop/autostart_darwin.go
new file mode 100644
index 0000000..5ecebd8
--- /dev/null
+++ b/desktop/autostart_darwin.go
@@ -0,0 +1,83 @@
+// 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"
+ "os/exec"
+ "path/filepath"
+)
+
+const launchAgentLabel = appId + ".desktop"
+
+func launchAgentPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(home, "Library", "LaunchAgents",
launchAgentLabel+".plist")
+}
+
+func autostartEnabled() bool {
+ path := launchAgentPath()
+ if path == "" {
+ return false
+ }
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func setAutostart(enabled bool) error {
+ path := launchAgentPath()
+ if path == "" {
+ return fmt.Errorf("cannot find the home directory")
+ }
+
+ if !enabled {
+ _ = exec.Command("launchctl", "unload", path).Run()
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key><string>%s</string>
+ <key>ProgramArguments</key>
+ <array><string>%s</string></array>
+ <key>WorkingDirectory</key><string>%s</string>
+ <key>RunAtLoad</key><true/>
+</dict>
+</plist>
+`, launchAgentLabel, executable, gatewayHome())
+
+ if err := os.WriteFile(path, []byte(plist), 0o644); err != nil {
+ return err
+ }
+ _ = exec.Command("launchctl", "load", path).Run()
+ return nil
+}
diff --git a/desktop/autostart_linux.go b/desktop/autostart_linux.go
new file mode 100644
index 0000000..87e446a
--- /dev/null
+++ b/desktop/autostart_linux.go
@@ -0,0 +1,77 @@
+// 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"
+ "path/filepath"
+)
+
+// XDG autostart rather than a systemd user unit: this one starts a tray icon,
+// which needs the desktop session that a unit does not wait for.
+func autostartPath() string {
+ dir := os.Getenv("XDG_CONFIG_HOME")
+ if dir == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ dir = filepath.Join(home, ".config")
+ }
+ return filepath.Join(dir, "autostart", appId+".desktop")
+}
+
+func autostartEnabled() bool {
+ path := autostartPath()
+ if path == "" {
+ return false
+ }
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func setAutostart(enabled bool) error {
+ path := autostartPath()
+ if path == "" {
+ return fmt.Errorf("cannot find the config directory")
+ }
+
+ if !enabled {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ entry := fmt.Sprintf(`[Desktop Entry]
+Type=Application
+Name=Casbin Gateway
+Exec=%s
+Icon=%s
+Terminal=false
+X-GNOME-Autostart-enabled=true
+`, executable, appId)
+
+ return os.WriteFile(path, []byte(entry), 0o644)
+}
diff --git a/desktop/autostart_windows.go b/desktop/autostart_windows.go
new file mode 100644
index 0000000..a9e44a9
--- /dev/null
+++ b/desktop/autostart_windows.go
@@ -0,0 +1,61 @@
+// 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 (
+ "os"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+// The per-user Run key rather than a service or a Startup shortcut: it needs
no
+// elevation, Task Manager's Startup tab lists it, and removing it is one
value.
+const (
+ runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
+ runKeyName = "CasbinGateway"
+)
+
+func autostartEnabled() bool {
+ key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath,
registry.QUERY_VALUE)
+ if err != nil {
+ return false
+ }
+ defer key.Close()
+
+ value, _, err := key.GetStringValue(runKeyName)
+ return err == nil && value != ""
+}
+
+func setAutostart(enabled bool) error {
+ key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath,
registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer key.Close()
+
+ if !enabled {
+ err := key.DeleteValue(runKeyName)
+ if err == registry.ErrNotExist {
+ return nil
+ }
+ return err
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ return key.SetStringValue(runKeyName, `"`+executable+`"`)
+}
diff --git a/desktop/dpi_other.go b/desktop/dpi_other.go
new file mode 100644
index 0000000..c72fd78
--- /dev/null
+++ b/desktop/dpi_other.go
@@ -0,0 +1,21 @@
+// 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.
+
+//go:build !windows
+
+package main
+
+// enableDpiAwareness has nothing to do outside Windows: macOS and GTK hand the
+// application a scaled drawing context without being asked.
+func enableDpiAwareness() {}
diff --git a/desktop/dpi_windows.go b/desktop/dpi_windows.go
new file mode 100644
index 0000000..c9f5973
--- /dev/null
+++ b/desktop/dpi_windows.go
@@ -0,0 +1,61 @@
+// 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
+
+const (
+ // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, which is (HANDLE)-4.
+ dpiPerMonitorAwareV2 = ^uintptr(3)
+ defaultDpi = 96
+)
+
+var (
+ procSetDpiAwarenessContext =
user32.NewProc("SetProcessDpiAwarenessContext")
+ procSetDpiAware = user32.NewProc("SetProcessDPIAware")
+ procGetDpiForSystem = user32.NewProc("GetDpiForSystem")
+)
+
+// enableDpiAwareness stops Windows from rendering at 96 DPI and stretching the
+// result, which is what a process that never says otherwise gets — and what
+// made the UI blurry on a scaled display. Nothing declares it for us, since a
+// Go binary carries no application manifest, so it is claimed here instead. It
+// has to happen before the process owns any window, and it applies to the tray
+// menu as much as to the webview.
+func enableDpiAwareness() {
+ if procSetDpiAwarenessContext.Find() == nil {
+ if ok, _, _ :=
procSetDpiAwarenessContext.Call(dpiPerMonitorAwareV2); ok != 0 {
+ return
+ }
+ }
+
+ // Windows before 1703 knows only the one system-wide setting.
+ if procSetDpiAware.Find() == nil {
+ _, _, _ = procSetDpiAware.Call()
+ }
+}
+
+// systemDpi is the DPI of the primary monitor, which is the one the window is
+// centred on. It answers 96 to a process that is not DPI aware, so it is only
+// meaningful after enableDpiAwareness.
+func systemDpi() uint {
+ if procGetDpiForSystem.Find() != nil {
+ return defaultDpi
+ }
+
+ dpi, _, _ := procGetDpiForSystem.Call()
+ if dpi == 0 {
+ return defaultDpi
+ }
+ return uint(dpi)
+}
diff --git a/desktop/focus_other.go b/desktop/focus_other.go
new file mode 100644
index 0000000..f684e17
--- /dev/null
+++ b/desktop/focus_other.go
@@ -0,0 +1,22 @@
+// 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.
+
+//go:build !windows
+
+package main
+
+// focusWindow has no portable equivalent outside Windows: macOS raises the
+// window when its Dock icon is clicked, and on Linux it is up to the window
+// manager. An open window that stays where it is beats one this cannot reach.
+func focusWindow(pid int) {}
diff --git a/desktop/focus_windows.go b/desktop/focus_windows.go
new file mode 100644
index 0000000..1cf4aaf
--- /dev/null
+++ b/desktop/focus_windows.go
@@ -0,0 +1,70 @@
+// 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 (
+ "syscall"
+ "unsafe"
+)
+
+var (
+ shell32 = syscall.NewLazyDLL("shell32.dll")
+ user32 = syscall.NewLazyDLL("user32.dll")
+
+ procSetAppId =
shell32.NewProc("SetCurrentProcessExplicitAppUserModelID")
+ procEnumWindows = user32.NewProc("EnumWindows")
+ procGetWindowThreadPid = user32.NewProc("GetWindowThreadProcessId")
+ procIsWindowVisible = user32.NewProc("IsWindowVisible")
+ procShowWindow = user32.NewProc("ShowWindow")
+ procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
+)
+
+const swRestore = 9
+
+func setAppUserModelId() {
+ id, err := syscall.UTF16PtrFromString(appId)
+ if err != nil {
+ return
+ }
+ _, _, _ = procSetAppId.Call(uintptr(unsafe.Pointer(id)))
+}
+
+// focusWindow raises the window process's own window, so that clicking the
tray
+// while it is already open brings it to the front instead of doing nothing.
+func focusWindow(pid int) {
+ var found uintptr
+
+ callback := syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr {
+ visible, _, _ := procIsWindowVisible.Call(hwnd)
+ if visible == 0 {
+ return 1
+ }
+ var owner uint32
+ _, _, _ = procGetWindowThreadPid.Call(hwnd,
uintptr(unsafe.Pointer(&owner)))
+ if owner != uint32(pid) {
+ return 1
+ }
+ found = hwnd
+ return 0
+ })
+
+ _, _, _ = procEnumWindows.Call(callback, 0)
+ if found == 0 {
+ return
+ }
+
+ _, _, _ = procShowWindow.Call(found, swRestore)
+ _, _, _ = procSetForegroundWindow.Call(found)
+}
diff --git a/desktop/gateway.go b/desktop/gateway.go
new file mode 100644
index 0000000..2876790
--- /dev/null
+++ b/desktop/gateway.go
@@ -0,0 +1,186 @@
+// 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 (
+ "bufio"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// defaultHttpPort mirrors conf.GetHttpPort(): the launcher reads the same
+// app.conf, and falls back to the same number when there is none to read.
+const defaultHttpPort = 17000
+
+const (
+ homeEnvKey = "CASBIN_GATEWAY_HOME"
+ urlEnvKey = "CASBIN_GATEWAY_URL"
+ debugEnvKey = "CASBIN_GATEWAY_DEBUG"
+ serverName = "casbin-gateway"
+ startupWait = 40 * time.Second
+ startupPoll = 200 * time.Millisecond
+ probeInterval = 3 * time.Second
+)
+
+// gatewayHome is the directory holding the server executable and its data.
+// Gateway keeps its database, logs and tmp files in its working directory, so
+// everything the launcher starts has to start there.
+func gatewayHome() string {
+ if dir := os.Getenv(homeEnvKey); dir != "" {
+ return dir
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ wd, _ := os.Getwd()
+ return wd
+ }
+ if resolved, err := filepath.EvalSymlinks(executable); err == nil {
+ executable = resolved
+ }
+ dir := filepath.Dir(executable)
+
+ // Inside a macOS .app the executable sits in Contents/MacOS, which is
not
+ // where the data lives. The installer records the real directory next
to it
+ // rather than wrapping the binary in a script, because a wrapper that
execs
+ // outside the bundle loses the Dock icon and the app name with it.
+ if recorded := readBundleHome(dir); recorded != "" {
+ return recorded
+ }
+ return dir
+}
+
+func readBundleHome(macOSDir string) string {
+ if runtime.GOOS != "darwin" || filepath.Base(macOSDir) != "MacOS" {
+ return ""
+ }
+
+ data, err := os.ReadFile(filepath.Join(filepath.Dir(macOSDir),
"Resources", "home"))
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+func serverPath() string {
+ name := serverName
+ if runtime.GOOS == "windows" {
+ name += ".exe"
+ }
+ return filepath.Join(gatewayHome(), name)
+}
+
+// httpPort is the port the server will bind. Only the keys read before the
+// database opens come from app.conf, and "httpport" is one of them — but an
+// environment variable of the same name wins over the file there, so it has to
+// win here too, or the launcher waits on a port nothing is serving.
+func httpPort() int {
+ if port, err := strconv.Atoi(strings.Trim(os.Getenv("httpport"), `"'
`)); err == nil && port > 0 {
+ return port
+ }
+
+ file, err := os.Open(filepath.Join(gatewayHome(), "conf", "app.conf"))
+ if err != nil {
+ return defaultHttpPort
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#")
{
+ continue
+ }
+ key, value, found := strings.Cut(line, "=")
+ if !found || strings.TrimSpace(key) != "httpport" {
+ continue
+ }
+ if port, err :=
strconv.Atoi(strings.Trim(strings.TrimSpace(value), `"' `)); err == nil && port
> 0 {
+ return port
+ }
+ }
+ return defaultHttpPort
+}
+
+// gatewayUrl is what the window shows. It is the server's own address, unless
+// something points the window elsewhere — during frontend work that is the
Vite
+// dev server, which serves the UI from source and proxies the API back to the
+// server this launcher started.
+func gatewayUrl() string {
+ if url := strings.TrimSpace(os.Getenv(urlEnvKey)); url != "" {
+ return url
+ }
+ return fmt.Sprintf("http://127.0.0.1:%d", httpPort())
+}
+
+// debugEnabled turns on the webview's own context menu and developer tools,
+// which are off by default because a desktop window has no address bar to
+// recover from whatever they lead to.
+func debugEnabled() bool {
+ return os.Getenv(debugEnvKey) != ""
+}
+
+// isServing reports whether something answers on the port, which is all a
+// process that did not start the server can tell.
+func isServing(port int) bool {
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port),
time.Second)
+ if err != nil {
+ return false
+ }
+ conn.Close()
+ return true
+}
+
+// startServer runs "casbin-gateway start", which detaches a copy of the server
+// and returns once it answers. It reports whether this call is what started
it,
+// so that quitting only stops a server the launcher owns.
+func startServer(port int) (started bool, err error) {
+ if isServing(port) {
+ return false, nil
+ }
+
+ cmd := exec.Command(serverPath(), "start")
+ cmd.Dir = gatewayHome()
+ hideConsole(cmd)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return false, fmt.Errorf("could not start the Gateway server:
%v: %s", err, strings.TrimSpace(string(output)))
+ }
+
+ deadline := time.Now().Add(startupWait)
+ for time.Now().Before(deadline) {
+ if isServing(port) {
+ return true, nil
+ }
+ time.Sleep(startupPoll)
+ }
+ return false, fmt.Errorf("the Gateway server did not answer on port
%d", port)
+}
+
+func stopServer() error {
+ cmd := exec.Command(serverPath(), "stop")
+ cmd.Dir = gatewayHome()
+ hideConsole(cmd)
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("%v: %s", err,
strings.TrimSpace(string(output)))
+ }
+ return nil
+}
diff --git a/desktop/go.mod b/desktop/go.mod
new file mode 100644
index 0000000..d76c519
--- /dev/null
+++ b/desktop/go.mod
@@ -0,0 +1,15 @@
+module github.com/apache/casbin-gateway/desktop
+
+go 1.21
+
+require (
+ fyne.io/systray v1.12.2
+ github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
+ github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
+ golang.org/x/sys v0.15.0
+)
+
+require (
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 //
indirect
+)
diff --git a/desktop/go.sum b/desktop/go.sum
new file mode 100644
index 0000000..b2cde19
--- /dev/null
+++ b/desktop/go.sum
@@ -0,0 +1,14 @@
+fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
+fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/godbus/dbus/v5 v5.1.0
h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod
h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
+github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod
h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1
h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod
h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
+github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod
h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod
h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod
h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/desktop/hide_windows.go b/desktop/hide_windows.go
new file mode 100644
index 0000000..b42d365
--- /dev/null
+++ b/desktop/hide_windows.go
@@ -0,0 +1,94 @@
+// 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 "syscall"
+
+const (
+ wmClose = 0x0010
+ swHide = 0
+ swShow = 5
+ swRestore = 9
+ gwlpWndProc = ^uintptr(3) // GWLP_WNDPROC, which is -4
+
+ // DWMWA_TRANSITIONS_FORCEDISABLED, which turns off the animation the
window
+ // manager plays when a window is shown, hidden or restored.
+ dwmTransitionsForceDisabled = 3
+)
+
+var (
+ dwmapi = syscall.NewLazyDLL("dwmapi.dll")
+
+ procSetWindowLongPtrW = user32.NewProc("SetWindowLongPtrW")
+ procSetWindowLongW = user32.NewProc("SetWindowLongW")
+ procCallWindowProcW = user32.NewProc("CallWindowProcW")
+ procDwmSetWindowAttrib = dwmapi.NewProc("DwmSetWindowAttribute")
+ procIsIconic = user32.NewProc("IsIconic")
+ procBringWindowToTop = user32.NewProc("BringWindowToTop")
+ procAttachThreadInput = user32.NewProc("AttachThreadInput")
+ procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
+ procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId")
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
+ previousWindowProc uintptr
+ hideOnCloseProcCallback = syscall.NewCallback(hideOnClose)
+)
+
+// keepWindowAlive makes closing the window hide it instead of destroying it.
+// The webview's own window procedure answers WM_CLOSE with DestroyWindow,
which
+// ends the run loop and the process with it — which is why reopening from the
+// tray used to reload everything from scratch. Hiding keeps the page loaded,
so
+// the window comes back instantly.
+func keepWindowAlive(hwnd uintptr) {
+ if hwnd == 0 {
+ return
+ }
+
+ disableWindowAnimations(hwnd)
+
+ setLong := procSetWindowLongPtrW
+ if setLong.Find() != nil {
+ // 32-bit Windows has no pointer-sized variant.
+ setLong = procSetWindowLongW
+ }
+ if setLong.Find() != nil {
+ return
+ }
+
+ previousWindowProc, _, _ = setLong.Call(hwnd, gwlpWndProc,
hideOnCloseProcCallback)
+}
+
+func hideOnClose(hwnd, msg, wparam, lparam uintptr) uintptr {
+ if msg == wmClose {
+ _, _, _ = procShowWindow.Call(hwnd, swHide)
+ return 0
+ }
+
+ result, _, _ := procCallWindowProcW.Call(previousWindowProc, hwnd, msg,
wparam, lparam)
+ return result
+}
+
+func disableWindowAnimations(hwnd uintptr) {
+ if procDwmSetWindowAttrib.Find() != nil {
+ return
+ }
+
+ disabled := int32(1)
+ _, _, _ = procDwmSetWindowAttrib.Call(
+ hwnd,
+ dwmTransitionsForceDisabled,
+ uintptr(unsafePointerTo(&disabled)),
+ 4,
+ )
+}
diff --git a/desktop/icon.go b/desktop/icon.go
new file mode 100644
index 0000000..8edcd86
--- /dev/null
+++ b/desktop/icon.go
@@ -0,0 +1,46 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/apache/casbin-gateway/desktop/internal/assets"
+)
+
+// writeAppIcon puts the icon on disk next to the Gateway, where the window can
+// load it and a shortcut can point at it. The alternative is a linker
resource,
+// which would mean a mingw toolchain in the build for one image.
+func writeAppIcon() (string, error) {
+ name, data := "casbin-gateway.png", assets.AppIconPng
+ switch runtime.GOOS {
+ case "windows":
+ name, data = "casbin-gateway.ico", assets.AppIcon
+ case "darwin":
+ // The application bundle wants an icon container, not a PNG.
+ name, data = "casbin-gateway.icns", assets.AppIconIcns
+ }
+
+ path := filepath.Join(gatewayHome(), name)
+ if existing, err := os.ReadFile(path); err == nil && len(existing) ==
len(data) {
+ return path, nil
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return "", err
+ }
+ return path, nil
+}
diff --git a/desktop/internal/assets/appicon.icns
b/desktop/internal/assets/appicon.icns
new file mode 100644
index 0000000..be185da
Binary files /dev/null and b/desktop/internal/assets/appicon.icns differ
diff --git a/desktop/internal/assets/appicon.ico
b/desktop/internal/assets/appicon.ico
new file mode 100644
index 0000000..2b73fa6
Binary files /dev/null and b/desktop/internal/assets/appicon.ico differ
diff --git a/desktop/internal/assets/appicon.png
b/desktop/internal/assets/appicon.png
new file mode 100644
index 0000000..50b6178
Binary files /dev/null and b/desktop/internal/assets/appicon.png differ
diff --git a/desktop/internal/assets/assets.go
b/desktop/internal/assets/assets.go
new file mode 100644
index 0000000..1d1c468
--- /dev/null
+++ b/desktop/internal/assets/assets.go
@@ -0,0 +1,40 @@
+// 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 assets holds the desktop icons, all rendered from
web/public/logo512.png.
+package assets
+
+import _ "embed"
+
+// AppIcon is the Windows icon, 16 to 256 pixels, used for the window, the
+// shortcuts and the tray.
+//
+//go:embed appicon.ico
+var AppIcon []byte
+
+// AppIconPng is the 512-pixel icon a Linux desktop entry points at.
+//
+//go:embed appicon.png
+var AppIconPng []byte
+
+// AppIconIcns is the icon of the macOS application bundle.
+//
+//go:embed appicon.icns
+var AppIconIcns []byte
+
+// TrayIcon is what the macOS menu bar and the Linux tray show; both want a
+// small PNG rather than an icon container.
+//
+//go:embed trayicon.png
+var TrayIcon []byte
diff --git a/desktop/internal/assets/trayicon.png
b/desktop/internal/assets/trayicon.png
new file mode 100644
index 0000000..0b46463
Binary files /dev/null and b/desktop/internal/assets/trayicon.png differ
diff --git a/desktop/main.go b/desktop/main.go
new file mode 100644
index 0000000..7d5557b
--- /dev/null
+++ b/desktop/main.go
@@ -0,0 +1,72 @@
+// 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.
+
+// Command casbin-gateway-desktop is the desktop shell around the Gateway
+// server: a tray icon that keeps the server running and a native window that
+// shows its web UI. It is a separate module and a separate executable so that
+// the server binary stays pure Go and cross-compiles as it does today.
+//
+// The window runs as a child process rather than in this one. Both the tray
and
+// the webview want to own the main thread and its event loop, and splitting
them
+// is what makes "close the window, stay in the tray" a process exit instead
of a
+// fight between two run loops.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ // "icon" writes the application icon next to the Gateway and exits. The
+ // installers call it, because a shortcut needs the icon file to exist
+ // before it can point at one.
+ if len(os.Args) > 1 && os.Args[1] == "icon" {
+ path, err := writeAppIcon()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ os.Exit(1)
+ }
+ fmt.Println(path)
+ return
+ }
+
+ // "autostart on|off" is what the installers call, so that the login
entry
+ // they create is the same one the tray checkbox turns off.
+ if len(os.Args) > 2 && os.Args[1] == "autostart" {
+ if err := setAutostart(os.Args[2] == "on"); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ if len(os.Args) > 1 && os.Args[1] == "window" {
+ url := ""
+ if len(os.Args) > 2 {
+ url = os.Args[2]
+ }
+ if url == "" {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop: window
needs a URL")
+ os.Exit(2)
+ }
+ if err := runWindow(url); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ runTray()
+}
diff --git a/desktop/process_other.go b/desktop/process_other.go
new file mode 100644
index 0000000..ecd74b2
--- /dev/null
+++ b/desktop/process_other.go
@@ -0,0 +1,21 @@
+// 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.
+
+//go:build !windows
+
+package main
+
+import "os/exec"
+
+func hideConsole(cmd *exec.Cmd) {}
diff --git a/desktop/process_windows.go b/desktop/process_windows.go
new file mode 100644
index 0000000..1a2beaf
--- /dev/null
+++ b/desktop/process_windows.go
@@ -0,0 +1,26 @@
+// 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 (
+ "os/exec"
+ "syscall"
+)
+
+// hideConsole keeps a child from flashing a console window on a desktop that
+// was never started from a terminal.
+func hideConsole(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} //
CREATE_NO_WINDOW
+}
diff --git a/desktop/tray.go b/desktop/tray.go
new file mode 100644
index 0000000..ddb63fd
--- /dev/null
+++ b/desktop/tray.go
@@ -0,0 +1,172 @@
+// 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"
+ "os/exec"
+ "runtime"
+ "sync"
+ "time"
+
+ "fyne.io/systray"
+
+ "github.com/apache/casbin-gateway/desktop/internal/assets"
+)
+
+// ownsServer records that the launcher is what started the server, which is
the
+// only case where quitting is allowed to stop it. A server that was already
+// running — as a login item, a service, or from a terminal — outlives the
tray.
+var ownsServer struct {
+ sync.Mutex
+ value bool
+}
+
+func runTray() {
+ // The tray menu is drawn by this process too, so it blurs on a scaled
+ // display for the same reason the window did.
+ enableDpiAwareness()
+
+ // Left click opens the window; right click keeps the menu, which is
what
+ // leaving the secondary handler unset means on Windows and macOS. This
has
+ // to be set before Run: Linux decides from it, once, whether the icon
is a
+ // menu button or a clickable item.
+ systray.SetOnTapped(showWindow)
+ setSecondaryTapped()
+
+ systray.Run(onTrayReady, func() {})
+}
+
+func onTrayReady() {
+ setTrayIcon()
+ systray.SetTitle("")
+ systray.SetTooltip("Casbin Gateway")
+
+ 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()
+ mStatus := systray.AddMenuItem("Starting...", "")
+ mStatus.Disable()
+ systray.AddSeparator()
+ mAutostart := systray.AddMenuItemCheckbox("Start at Login", "Run Casbin
Gateway when you log in", autostartEnabled())
+ systray.AddSeparator()
+ mQuit := systray.AddMenuItem("Quit", "Close the window and leave the
tray")
+
+ port := httpPort()
+
+ // The window is opened from a goroutine so that the tray answers clicks
+ // while the server is still starting up.
+ go func() {
+ started, err := startServer(port)
+ if err != nil {
+ // The status item keeps the reason: a launcher started
from a
+ // shortcut has nowhere else to report it.
+ mStatus.SetTitle(fmt.Sprintf("Not running: %v", err))
+ return
+ }
+ ownsServer.Lock()
+ ownsServer.value = started
+ ownsServer.Unlock()
+
+ go watchStatus(mStatus, port)
+ showWindow()
+ }()
+
+ for {
+ select {
+ case <-mOpen.ClickedCh:
+ showWindow()
+ case <-mBrowser.ClickedCh:
+ if err := openBrowser(gatewayUrl()); err != nil {
+ fmt.Fprintln(os.Stderr,
"casbin-gateway-desktop:", err)
+ }
+ case <-mAutostart.ClickedCh:
+ toggleAutostart(mAutostart)
+ case <-mQuit.ClickedCh:
+ quit()
+ return
+ }
+ }
+}
+
+func setTrayIcon() {
+ // Windows wants an icon container; the macOS menu bar and the Linux
tray
+ // want a plain PNG.
+ if runtime.GOOS == "windows" {
+ systray.SetIcon(assets.AppIcon)
+ return
+ }
+ systray.SetIcon(assets.TrayIcon)
+}
+
+func watchStatus(item *systray.MenuItem, port int) {
+ for {
+ if isServing(port) {
+ item.SetTitle(fmt.Sprintf("Running on port %d", port))
+ } else {
+ item.SetTitle("Not running")
+ }
+ time.Sleep(probeInterval)
+ }
+}
+
+func toggleAutostart(item *systray.MenuItem) {
+ if item.Checked() {
+ if err := setAutostart(false); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ return
+ }
+ item.Uncheck()
+ return
+ }
+
+ if err := setAutostart(true); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ return
+ }
+ item.Check()
+}
+
+func quit() {
+ closeWindow()
+
+ ownsServer.Lock()
+ owned := ownsServer.value
+ ownsServer.Unlock()
+
+ if owned {
+ if err := stopServer(); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop: could
not stop the Gateway server:", err)
+ }
+ }
+ systray.Quit()
+}
+
+// openBrowser is the fallback for hosts where no webview could be created, and
+// the "Open in Browser" item for everyone else.
+func openBrowser(url string) error {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler",
url)
+ case "darwin":
+ cmd = exec.Command("open", url)
+ default:
+ cmd = exec.Command("xdg-open", url)
+ }
+ hideConsole(cmd)
+ return cmd.Start()
+}
diff --git a/desktop/tray_linux.go b/desktop/tray_linux.go
new file mode 100644
index 0000000..d4840c1
--- /dev/null
+++ b/desktop/tray_linux.go
@@ -0,0 +1,26 @@
+// 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 "fyne.io/systray"
+
+// setSecondaryTapped answers the right click rather than leaving it to fail.
+// The menu here is drawn by the desktop from the exported dbusmenu, not by
this
+// process, so there is nothing for the handler itself to do — but without one
+// the tray reports the method as unknown, and a host that asks before falling
+// back to the menu it already has would show nothing at all.
+func setSecondaryTapped() {
+ systray.SetOnSecondaryTapped(func() {})
+}
diff --git a/desktop/tray_other.go b/desktop/tray_other.go
new file mode 100644
index 0000000..c2c7877
--- /dev/null
+++ b/desktop/tray_other.go
@@ -0,0 +1,21 @@
+// 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.
+
+//go:build !linux
+
+package main
+
+// setSecondaryTapped leaves the right click alone: unset is what makes Windows
+// and macOS pop the menu themselves, and any handler would replace that.
+func setSecondaryTapped() {}
diff --git a/desktop/window.go b/desktop/window.go
new file mode 100644
index 0000000..4cfe42e
--- /dev/null
+++ b/desktop/window.go
@@ -0,0 +1,26 @@
+// 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
+
+const (
+ windowTitle = "Casbin Gateway"
+ windowWidth = 1280
+ windowHeight = 860
+)
+
+// appId is the application identity the desktop uses everywhere it needs one:
+// the Windows AppUserModelID that decides taskbar grouping, the macOS bundle
+// identifier, and the name of the Linux desktop entry.
+const appId = "org.apache.casbin-gateway"
diff --git a/desktop/window_unix.go b/desktop/window_unix.go
new file mode 100644
index 0000000..cad8326
--- /dev/null
+++ b/desktop/window_unix.go
@@ -0,0 +1,40 @@
+// 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.
+
+//go:build darwin || linux
+
+package main
+
+import (
+ "errors"
+
+ webview "github.com/webview/webview_go"
+)
+
+// runWindow shows the UI in the platform webview: WebKit on macOS, WebKitGTK
on
+// Linux. Failing here is not fatal for the desktop as a whole — the tray
notices
+// this process die and falls back to the browser.
+func runWindow(url string) error {
+ view := webview.New(debugEnabled())
+ if view == nil {
+ return errors.New("no system webview available")
+ }
+ defer view.Destroy()
+
+ view.SetTitle(windowTitle)
+ view.SetSize(windowWidth, windowHeight, webview.HintNone)
+ view.Navigate(url)
+ view.Run()
+ return nil
+}
diff --git a/desktop/window_windows.go b/desktop/window_windows.go
new file mode 100644
index 0000000..fe45551
--- /dev/null
+++ b/desktop/window_windows.go
@@ -0,0 +1,111 @@
+// 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 (
+ "errors"
+ "path/filepath"
+ "syscall"
+ "unsafe"
+
+ webview2 "github.com/jchv/go-webview2"
+)
+
+const (
+ wmSetIcon = 0x0080
+ iconSmall = 0
+ iconBig = 1
+ imageIcon = 1
+ lrLoadFromFile = 0x0010
+ smCxIcon = 11
+ smCyIcon = 12
+ smCxSmIcon = 49
+ smCySmIcon = 50
+)
+
+var (
+ procLoadImageW = user32.NewProc("LoadImageW")
+ procSendMessageW = user32.NewProc("SendMessageW")
+ procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
+)
+
+func runWindow(url string) error {
+ // Without an explicit AppUserModelID the window is grouped under
whatever
+ // launched it, so this is what makes Casbin Gateway its own taskbar
button
+ // and lets a pinned shortcut point at it.
+ setAppUserModelId()
+
+ // Both of these have to happen before the window exists: DPI awareness
is
+ // fixed at the first window, and the size below is in physical pixels.
+ enableDpiAwareness()
+ scale := systemDpi()
+
+ view := webview2.NewWithOptions(webview2.WebViewOptions{
+ // Keeping the profile in the Gateway directory means
uninstalling is
+ // still "delete the directory".
+ DataPath: filepath.Join(gatewayHome(), "tmp", "webview"),
+ AutoFocus: true,
+ Debug: debugEnabled(),
+ WindowOptions: webview2.WindowOptions{
+ Title: windowTitle,
+ Width: windowWidth * scale / defaultDpi,
+ Height: windowHeight * scale / defaultDpi,
+ Center: true,
+ },
+ })
+ if view == nil {
+ return errors.New("the Microsoft Edge WebView2 runtime is
missing, install it from
https://developer.microsoft.com/microsoft-edge/webview2/")
+ }
+ defer view.Destroy()
+
+ setWindowIcon(uintptr(view.Window()))
+ view.Navigate(url)
+ view.Run()
+ return nil
+}
+
+// setWindowIcon gives the title bar and the taskbar button the Casbin icon. A
+// window whose class was registered without one keeps the generic icon
+// otherwise, and that is the icon the user sees in the taskbar.
+func setWindowIcon(hwnd uintptr) {
+ if hwnd == 0 {
+ return
+ }
+
+ path, err := writeAppIcon()
+ if err != nil {
+ return
+ }
+ pathPtr, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return
+ }
+
+ for _, size := range []struct {
+ which uintptr
+ cx, cy uintptr
+ }{
+ {iconSmall, smCxSmIcon, smCySmIcon},
+ {iconBig, smCxIcon, smCyIcon},
+ } {
+ cx, _, _ := procGetSystemMetrics.Call(size.cx)
+ cy, _, _ := procGetSystemMetrics.Call(size.cy)
+ icon, _, _ := procLoadImageW.Call(0,
uintptr(unsafe.Pointer(pathPtr)), imageIcon, cx, cy, lrLoadFromFile)
+ if icon == 0 {
+ continue
+ }
+ _, _, _ = procSendMessageW.Call(hwnd, wmSetIcon, size.which,
icon)
+ }
+}
diff --git a/desktop/windowproc.go b/desktop/windowproc.go
new file mode 100644
index 0000000..8ae0e96
--- /dev/null
+++ b/desktop/windowproc.go
@@ -0,0 +1,116 @@
+// 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"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+// windowFailWindow is how long a window child has to stay up before its exit
+// counts as the user closing it rather than as a webview that could not start.
+const windowFailWindow = 3 * time.Second
+
+var window struct {
+ sync.Mutex
+ process *os.Process
+ closing bool
+}
+
+// showWindow puts the management UI in front of the user. The window is a
child
+// process, so an open one is raised rather than replaced, and one that never
+// came up — a Linux host without WebKitGTK, a Windows one without the WebView2
+// runtime — falls back to the default browser.
+func showWindow() {
+ window.Lock()
+ if window.process != nil {
+ pid := window.process.Pid
+ window.Unlock()
+ focusWindow(pid)
+ return
+ }
+ window.Unlock()
+
+ // Clicking the tray while the server is still coming up, or after it
failed
+ // to, would open a window on a port nothing answers. The startup path
opens
+ // the window itself once the server is serving, so there is nothing to
lose
+ // by doing nothing here.
+ if !isServing(httpPort()) {
+ return
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ return
+ }
+
+ cmd := exec.Command(executable, "window", gatewayUrl())
+ cmd.Dir = gatewayHome()
+ stderr := &strings.Builder{}
+ cmd.Stderr = stderr
+ hideConsole(cmd)
+ if err := cmd.Start(); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ fallbackToBrowser()
+ return
+ }
+
+ window.Lock()
+ window.process = cmd.Process
+ window.closing = false
+ window.Unlock()
+
+ go func() {
+ startedAt := time.Now()
+ waitErr := cmd.Wait()
+
+ window.Lock()
+ killed := window.closing
+ window.process = nil
+ window.closing = false
+ window.Unlock()
+
+ if killed || waitErr == nil || time.Since(startedAt) >=
windowFailWindow {
+ return
+ }
+ fmt.Fprintf(os.Stderr, "casbin-gateway-desktop: no window on
this host (%v) %s\n",
+ waitErr, strings.TrimSpace(stderr.String()))
+ fallbackToBrowser()
+ }()
+}
+
+func closeWindow() {
+ window.Lock()
+ process := window.process
+ if process != nil {
+ window.closing = true
+ }
+ window.Unlock()
+
+ if process != nil {
+ _ = process.Kill()
+ }
+}
+
+func fallbackToBrowser() {
+ if err := openBrowser(gatewayUrl()); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+ }
+}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 9d251b4..5050112 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -10,6 +10,8 @@
# INSTALL_DIR where the executable and its data live
# (default: $env:LOCALAPPDATA\casbin-gateway)
# NO_START set to any value to install without starting Gateway
+# NO_AUTOSTART set to any value to skip the login-time startup entry
+# NO_SHORTCUT set to any value to skip the desktop and Start menu shortcuts
$ErrorActionPreference = 'Stop'
@@ -59,14 +61,17 @@ try {
Write-Info "Installing to $InstallDir"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
- $ExePath = Join-Path $InstallDir 'casbin-gateway.exe'
- try {
- Copy-Item -Path (Join-Path $Unpacked 'casbin-gateway.exe')
-Destination $ExePath -Force
- }
- catch {
- # Windows locks a running executable, so this is what an upgrade over a
- # started Gateway looks like.
- throw "cannot replace $ExePath, stop Casbin Gateway if it is running
and try again ($_)"
+ $ExePath = Join-Path $InstallDir 'casbin-gateway.exe'
+ $DesktopExePath = Join-Path $InstallDir 'casbin-gateway-desktop.exe'
+ foreach ($executable in @('casbin-gateway.exe',
'casbin-gateway-desktop.exe')) {
+ try {
+ Copy-Item -Path (Join-Path $Unpacked $executable) -Destination
(Join-Path $InstallDir $executable) -Force
+ }
+ catch {
+ # Windows locks a running executable, so this is what an upgrade
over
+ # a started Gateway looks like.
+ throw "cannot replace $executable in $InstallDir, quit Casbin
Gateway from its tray icon and try again ($_)"
+ }
}
foreach ($legalFile in @('LICENSE', 'NOTICE', 'DISCLAIMER')) {
@@ -100,24 +105,47 @@ if ($UserPath -notlike "*$BinDir*") {
Write-Info "Added $BinDir to your PATH, which takes effect in your next
terminal"
}
-# -- start with Windows -------------------------------------------------------
-# A shortcut in the Startup folder rather than a service: it needs no
elevation,
-# shows up in Task Manager's Startup tab, and is undone by deleting the file.
-$StartupDir = [System.Environment]::GetFolderPath('Startup')
-$StartupLink = Join-Path $StartupDir 'Casbin Gateway.lnk'
+# ── desktop and Start menu shortcuts
──────────────────────────────────────────
+# Both point at the desktop launcher, not at the server: it is what shows the
+# window, and it starts the server itself if nothing is serving yet.
+$IconPath = & $DesktopExePath icon
+$ShortcutName = 'Casbin Gateway.lnk'
+
+function New-GatewayShortcut {
+ param([string]$Path)
+
+ $shell = New-Object -ComObject WScript.Shell
+ $shortcut = $shell.CreateShortcut($Path)
+ $shortcut.TargetPath = $DesktopExePath
+ $shortcut.WorkingDirectory = $InstallDir
+ $shortcut.IconLocation = $IconPath
+ $shortcut.Description = 'Casbin Gateway'
+ $shortcut.Save()
+}
+
+if (-not $env:NO_SHORTCUT) {
+ foreach ($dir in @([System.Environment]::GetFolderPath('Desktop'),
[System.Environment]::GetFolderPath('Programs'))) {
+ try {
+ New-GatewayShortcut -Path (Join-Path $dir $ShortcutName)
+ }
+ catch {
+ Write-Info "Could not create the shortcut in ${dir}: $_"
+ }
+ }
+}
+
+# ── start with Windows
────────────────────────────────────────────────────────
+# The launcher owns this entry so that the tray's "Start at Login" checkbox and
+# the installer are never out of step. An older install put a shortcut in the
+# Startup folder instead, which would now start a second copy.
+Remove-Item -Path (Join-Path ([System.Environment]::GetFolderPath('Startup'))
$ShortcutName) -Force -ErrorAction SilentlyContinue
if (-not $env:NO_AUTOSTART) {
try {
- $shell = New-Object -ComObject WScript.Shell
- $shortcut = $shell.CreateShortcut($StartupLink)
- $shortcut.TargetPath = Join-Path $InstallDir 'casbin-gateway.exe'
- $shortcut.Arguments = 'start'
- $shortcut.WorkingDirectory = $InstallDir
- $shortcut.Description = 'Casbin Gateway'
- $shortcut.Save()
+ & $DesktopExePath autostart on
Write-Info 'Casbin Gateway will start with Windows.'
}
catch {
- Write-Info "Could not add the startup shortcut: $_"
+ Write-Info "Could not add the startup entry: $_"
}
}
@@ -125,15 +153,14 @@ Write-Info ''
Write-Info "Casbin Gateway is installed in $InstallDir"
Write-Info 'Its database, logs and temporary files stay in that directory.'
Write-Info 'It serves this machine only, and signs you in there as admin
without a password.'
-Write-Info 'Stop it with "casbin-gateway stop", check on it with
"casbin-gateway status".'
-Write-Info "Remove the startup entry by deleting $StartupLink"
+Write-Info 'Closing its window leaves it running in the tray; quit it from
there.'
+Write-Info 'Without the window: "casbin-gateway start", "casbin-gateway stop",
"casbin-gateway status".'
Write-Info ''
if ($env:NO_START) {
- Write-Info 'Start it with: casbin-gateway start'
+ Write-Info 'Start it from the "Casbin Gateway" shortcut, or with:
casbin-gateway start'
return
}
-# "start" detaches and returns, so installing does not occupy this terminal.
-Set-Location $InstallDir
-& (Join-Path $InstallDir 'casbin-gateway.exe') start
+# The launcher detaches on its own, so installing does not occupy this
terminal.
+Start-Process -FilePath $DesktopExePath -WorkingDirectory $InstallDir
diff --git a/scripts/install.sh b/scripts/install.sh
index 1c0eaca..34eebd7 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -14,6 +14,7 @@
# (default: $HOME/.local/bin)
# NO_START set to any value to install without starting Gateway
# NO_AUTOSTART set to any value to skip the login-time startup entry
+# NO_SHORTCUT set to any value to skip the macOS bundle or the desktop
entry
set -euo pipefail
@@ -25,6 +26,7 @@
INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/share/casbin-gateway}"
BIN_DIR="${BIN_DIR:-${HOME}/.local/bin}"
NO_START="${NO_START:-}"
NO_AUTOSTART="${NO_AUTOSTART:-}"
+NO_SHORTCUT="${NO_SHORTCUT:-}"
info() { printf '%s\n' "$*"; }
die() { printf 'casbin-gateway: %s\n' "$*" >&2; exit 1; }
@@ -80,13 +82,18 @@ rm -rf "${stage}"
mkdir -p "${stage}"
tar -xzf "${tmpDir}/${archive}" -C "${stage}" --strip-components=1
-mv -f "${stage}/casbin-gateway" "${INSTALL_DIR}/casbin-gateway"
-chmod 755 "${INSTALL_DIR}/casbin-gateway"
+for executable in casbin-gateway casbin-gateway-desktop; do
+ mv -f "${stage}/${executable}" "${INSTALL_DIR}/${executable}"
+ chmod 755 "${INSTALL_DIR}/${executable}"
+done
for legalFile in LICENSE NOTICE DISCLAIMER; do
mv -f "${stage}/${legalFile}" "${INSTALL_DIR}/${legalFile}"
done
rm -rf "${stage}"
+desktopExe="${INSTALL_DIR}/casbin-gateway-desktop"
+iconPath="$("${desktopExe}" icon)"
+
# ── put a "casbin-gateway" command on PATH
────────────────────────────────────
# Gateway keeps its database, logs and temporary files in the working
# directory, so the command is a wrapper that always starts it in INSTALL_DIR.
@@ -124,76 +131,136 @@ EOF
esac
fi
-# ── start at login
────────────────────────────────────────────────────────────
-# A user-level unit on Linux and a LaunchAgent on macOS: neither needs root,
and
-# both are undone by deleting one file.
-autostartNote=""
-install_autostart() {
- if [[ -n "${NO_AUTOSTART}" ]]; then
- return
- fi
+# ── the desktop application
───────────────────────────────────────────────────
+# A macOS bundle and a Linux desktop entry, both pointing at the launcher
rather
+# than the server: it is what shows the window, and it starts the server
itself.
+appNote=""
- if [[ "${osName}" == "darwin" ]]; then
-
plist="${HOME}/Library/LaunchAgents/org.apache.casbin-gateway.plist"
- mkdir -p "$(dirname "${plist}")" || return
- cat > "${plist}" <<EOF
+install_macos_app() {
+ appDir="${HOME}/Applications/Casbin Gateway.app"
+ rm -rf "${appDir}"
+ mkdir -p "${appDir}/Contents/MacOS" "${appDir}/Contents/Resources" ||
return
+
+ # The launcher is copied in rather than symlinked or wrapped: a bundle
whose
+ # executable runs from outside it gets neither the Dock icon nor the
name.
+ cp "${desktopExe}" "${appDir}/Contents/MacOS/casbin-gateway-desktop"
+ chmod 755 "${appDir}/Contents/MacOS/casbin-gateway-desktop"
+ cp "${iconPath}" "${appDir}/Contents/Resources/appicon.icns"
+
+ # Which is also why the data directory is recorded here: the copy
inside the
+ # bundle cannot find it by looking next to itself.
+ printf '%s\n' "${INSTALL_DIR}" > "${appDir}/Contents/Resources/home"
+
+ cat > "${appDir}/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>Label</key><string>org.apache.casbin-gateway</string>
- <key>ProgramArguments</key>
- <array><string>${INSTALL_DIR}/casbin-gateway</string></array>
- <key>WorkingDirectory</key><string>${INSTALL_DIR}</string>
- <key>RunAtLoad</key><true/>
- <key>KeepAlive</key><true/>
+ <key>CFBundleName</key><string>Casbin Gateway</string>
+ <key>CFBundleDisplayName</key><string>Casbin Gateway</string>
+ <key>CFBundleIdentifier</key><string>org.apache.casbin-gateway</string>
+ <key>CFBundleExecutable</key><string>casbin-gateway-desktop</string>
+ <key>CFBundleIconFile</key><string>appicon</string>
+ <key>CFBundlePackageType</key><string>APPL</string>
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
+ <key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
EOF
- launchctl unload "${plist}" >/dev/null 2>&1 || true
- autostartNote="${plist}"
- return
- fi
- command -v systemctl >/dev/null 2>&1 || return
- unit="${HOME}/.config/systemd/user/casbin-gateway.service"
- mkdir -p "$(dirname "${unit}")" || return
- cat > "${unit}" <<EOF
-[Unit]
-Description=Casbin Gateway
-
-[Service]
-Type=simple
-WorkingDirectory=${INSTALL_DIR}
-ExecStart=${INSTALL_DIR}/casbin-gateway
-Restart=on-failure
-RestartPreventExitStatus=78
-
-[Install]
-WantedBy=default.target
+ # The download is not signed or notarized. Nothing curl writes is
+ # quarantined, so this only matters for an archive that arrived some
other
+ # way, and an ad-hoc signature is the most this can do without a
developer
+ # certificate. Both are best-effort.
+ xattr -dr com.apple.quarantine "${appDir}" >/dev/null 2>&1 || true
+ command -v codesign >/dev/null 2>&1 && codesign --force --deep --sign -
"${appDir}" >/dev/null 2>&1
+
+ # Finder caches the bundle by path, so a reinstall over the old one
keeps
+ # showing the old icon until it is told the directory changed.
+ touch "${appDir}"
+ desktopExe="${appDir}/Contents/MacOS/casbin-gateway-desktop"
+ appNote="${appDir}"
+}
+
+install_linux_app() {
+ entryDir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
+
iconDir="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor/512x512/apps"
+ mkdir -p "${entryDir}" "${iconDir}" || return
+ cp "${iconPath}" "${iconDir}/org.apache.casbin-gateway.png"
+
+ entry="${entryDir}/org.apache.casbin-gateway.desktop"
+ cat > "${entry}" <<EOF
+[Desktop Entry]
+Type=Application
+Name=Casbin Gateway
+Comment=Local gateway for AI agents
+Exec=${desktopExe}
+Path=${INSTALL_DIR}
+Icon=org.apache.casbin-gateway
+Terminal=false
+Categories=Development;Network;
+StartupWMClass=casbin-gateway-desktop
EOF
- systemctl --user daemon-reload >/dev/null 2>&1 || true
- systemctl --user enable casbin-gateway.service >/dev/null 2>&1 || true
- autostartNote="${unit}"
+ chmod 644 "${entry}"
+ command -v update-desktop-database >/dev/null 2>&1 &&
update-desktop-database "${entryDir}" >/dev/null 2>&1
+
+ # Most desktops only show an icon on ~/Desktop if the file is
executable and
+ # marked trusted; the chmod is the half that is portable.
+ if [[ -d "${HOME}/Desktop" ]]; then
+ cp "${entry}"
"${HOME}/Desktop/org.apache.casbin-gateway.desktop"
+ chmod 755 "${HOME}/Desktop/org.apache.casbin-gateway.desktop"
+ command -v gio >/dev/null 2>&1 && gio set
"${HOME}/Desktop/org.apache.casbin-gateway.desktop" metadata::trusted true
>/dev/null 2>&1
+ fi
+ appNote="${entry}"
}
-install_autostart
+if [[ -z "${NO_SHORTCUT}" ]]; then
+ if [[ "${osName}" == "darwin" ]]; then
+ install_macos_app
+ else
+ install_linux_app
+ fi
+fi
+
+# ── start at login
────────────────────────────────────────────────────────────
+# The launcher owns this entry so that the tray's "Start at Login" checkbox and
+# the installer are never out of step. Older installs started the server on its
+# own; those entries would now bring up a second copy.
+rm -f "${HOME}/Library/LaunchAgents/org.apache.casbin-gateway.plist"
+if command -v systemctl >/dev/null 2>&1 && [[ -f
"${HOME}/.config/systemd/user/casbin-gateway.service" ]]; then
+ systemctl --user disable --now casbin-gateway.service >/dev/null 2>&1
|| true
+ rm -f "${HOME}/.config/systemd/user/casbin-gateway.service"
+ systemctl --user daemon-reload >/dev/null 2>&1 || true
+fi
+
+autostartNote=""
+if [[ -z "${NO_AUTOSTART}" ]]; then
+ if "${desktopExe}" autostart on; then
+ autostartNote="yes"
+ fi
+fi
info ""
info "Casbin Gateway is installed in ${INSTALL_DIR}"
info "Its database, logs and temporary files stay in that directory."
info "It serves this machine only, and signs you in there as admin without a
password."
-info "Stop it with \"casbin-gateway stop\", check on it with \"casbin-gateway
status\"."
+info "Closing its window leaves it running in the tray; quit it from there."
+info "Without the window: \"casbin-gateway start\", \"casbin-gateway stop\",
\"casbin-gateway status\"."
+if [[ -n "${appNote}" ]]; then
+ info "The application is ${appNote}"
+fi
if [[ -n "${autostartNote}" ]]; then
- info "It will start when you log in; remove ${autostartNote} to undo
that."
+ info "It will start when you log in; turn that off from the tray menu."
fi
info ""
if [[ -n "${NO_START}" ]]; then
- info "Start it with: casbin-gateway start"
+ info "Start it from the Casbin Gateway application, or with:
casbin-gateway start"
exit 0
fi
-# "start" detaches and returns, so installing does not occupy this terminal.
+# The launcher forks the server and the window, so installing does not occupy
+# this terminal beyond the startup it waits for.
cd "${INSTALL_DIR}"
-exec "${INSTALL_DIR}/casbin-gateway" start
+"${desktopExe}" >/dev/null 2>&1 &
+disown 2>/dev/null || true
diff --git a/version/desktop.go b/version/desktop.go
new file mode 100644
index 0000000..53b2423
--- /dev/null
+++ b/version/desktop.go
@@ -0,0 +1,118 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/beego/beego"
+)
+
+// macOsBundleLauncher is the copy the installer puts inside the application
+// bundle, which is a copy because a bundle whose executable lives outside it
+// gets neither the Dock icon nor the name.
+const macOsBundleLauncher = "Applications/Casbin
Gateway.app/Contents/MacOS/casbin-gateway-desktop"
+
+func desktopLauncherName() string {
+ if runtime.GOOS == "windows" {
+ return "casbin-gateway-desktop.exe"
+ }
+ return "casbin-gateway-desktop"
+}
+
+// updateDesktopLauncher replaces the launcher next to the Gateway with the one
+// from the same archive. It is best-effort and never fails the update: an
+// installation without the desktop has nothing to replace, and one whose
+// launcher is running out of a directory this cannot write to still ends up
+// with an updated server.
+func updateDesktopLauncher(archive string, staging string, installDir string) {
+ launcher := filepath.Join(installDir, desktopLauncherName())
+ if _, err := os.Stat(launcher); err != nil {
+ return
+ }
+
+ staged := filepath.Join(staging, desktopLauncherName())
+ if err := extractBinary(archive, staged); err != nil {
+ beego.Error("the update holds no desktop launcher:", err)
+ return
+ }
+ if err := os.Chmod(staged, 0o755); err != nil {
+ beego.Error("could not make the new desktop launcher
executable:", err)
+ return
+ }
+
+ if err := swapLauncher(launcher, staged); err != nil {
+ beego.Error("the desktop launcher was left at the previous
version:", err)
+ return
+ }
+
+ refreshMacOsBundle(launcher)
+}
+
+// swapLauncher moves the new launcher into place. The old one is renamed
rather
+// than overwritten because it is very likely running — the tray icon is what
+// started this Gateway — and a running executable can be renamed on every
+// platform but replaced in place on none.
+func swapLauncher(launcher string, staged string) error {
+ backup := launcher + backupSuffix
+ _ = os.Remove(backup)
+
+ if err := os.Rename(launcher, backup); err != nil {
+ return err
+ }
+ if err := os.Rename(staged, launcher); err != nil {
+ _ = os.Rename(backup, launcher)
+ return err
+ }
+
+ removeWhenUnlocked(backup)
+ return nil
+}
+
+// refreshMacOsBundle keeps the copy inside the application bundle in step with
+// the one that was just replaced. Without it the Dock icon would go on
starting
+// the previous launcher.
+func refreshMacOsBundle(launcher string) {
+ if runtime.GOOS != "darwin" {
+ return
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return
+ }
+ bundled := filepath.Join(home, macOsBundleLauncher)
+ if _, err := os.Stat(bundled); err != nil {
+ return
+ }
+
+ // Copied through a temporary name in the same directory so that the
rename
+ // onto the running executable is atomic.
+ content, err := os.ReadFile(launcher)
+ if err != nil {
+ return
+ }
+ staged := bundled + ".new"
+ if err := os.WriteFile(staged, content, 0o755); err != nil {
+ beego.Error("could not stage the launcher inside the
application bundle:", err)
+ return
+ }
+ if err := swapLauncher(bundled, staged); err != nil {
+ _ = os.Remove(staged)
+ beego.Error("the application bundle was left at the previous
version:", err)
+ }
+}
diff --git a/version/update.go b/version/update.go
index 9cc85fa..3cc3b0b 100644
--- a/version/update.go
+++ b/version/update.go
@@ -268,6 +268,11 @@ func runUpdate(release *Release) error {
return err
}
+ // The desktop launcher ships in the same archive. It is replaced after
the
+ // server, and separately, so that a failure there leaves an updated
Gateway
+ // rather than an aborted update.
+ updateDesktopLauncher(archive, staging, filepath.Dir(executable))
+
statusLock.Lock()
status.Stage = StageRestarting
statusLock.Unlock()
@@ -361,7 +366,13 @@ func CleanupBackup() {
_ = os.RemoveAll(filepath.Join(filepath.Dir(executable), stagingDir))
- backup := executable + backupSuffix
+ removeWhenUnlocked(executable + backupSuffix)
+ removeWhenUnlocked(filepath.Join(filepath.Dir(executable),
desktopLauncherName()+backupSuffix))
+}
+
+// removeWhenUnlocked deletes a replaced executable once nothing runs from it,
+// which on Windows is only true after the process using it has exited.
+func removeWhenUnlocked(backup string) {
if _, err := os.Stat(backup); err != nil {
return
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]