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 f5406e2 feat(desktop): create a desktop shortcut on the first start
f5406e2 is described below
commit f5406e224cce4a0ed989d86d6ed610441e295cbf
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 15:17:15 2026 +0800
feat(desktop): create a desktop shortcut on the first start
---
.github/workflows/nightly.yml | 7 +-
README.md | 2 +-
README_zh.md | 2 +-
desktop/main.go | 15 ++--
desktop/shortcut.go | 62 +++++++++++++
desktop/shortcut_darwin.go | 134 ++++++++++++++++++++++++++++
desktop/shortcut_linux.go | 132 +++++++++++++++++++++++++++
desktop/shortcut_unix.go | 33 +++++++
desktop/shortcut_windows.go | 201 ++++++++++++++++++++++++++++++++++++++++++
desktop/tray.go | 4 +
scripts/install.ps1 | 40 +++------
scripts/install.sh | 104 ++++------------------
12 files changed, 613 insertions(+), 123 deletions(-)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 650e41c..4cba66d 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -227,8 +227,11 @@ jobs:
`./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.
+ native window, and stays in the tray after the window is closed. Its
first start
+ puts a **Casbin Gateway** entry on the desktop, in the Start menu or
in
+ `~/Applications`, pointing back at the directory the archive was
unpacked into.
+ Running the server on its own with `casbin-gateway start` still
works and needs
+ nothing else.
| Platform | Archive |
| --- | --- |
diff --git a/README.md b/README.md
index b88e87a..c6dad28 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/insta
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.
-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.
+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. An archive unpacked by hand gets the same entry the first time the
launcher runs.
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.
diff --git a/README_zh.md b/README_zh.md
index ea2412d..81575ea 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -67,7 +67,7 @@ irm
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/insta
两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入
PATH,启动它,并设置成开机(登录)自启。安装用的那个终端窗口立刻就还给你。
-Gateway 会在自己的窗口里打开 ——
不用登录:它只服务本机,本机访问会直接以管理员身份进入。关掉窗口不会退出,它缩到托盘图标后台继续跑,重新打开窗口和真正退出都在托盘菜单里。桌面和开始菜单里会有一个
**Casbin Gateway** 快捷方式;macOS 上在 `~/Applications`,Linux 上在应用菜单里。
+Gateway 会在自己的窗口里打开 ——
不用登录:它只服务本机,本机访问会直接以管理员身份进入。关掉窗口不会退出,它缩到托盘图标后台继续跑,重新打开窗口和真正退出都在托盘菜单里。桌面和开始菜单里会有一个
**Casbin Gateway** 快捷方式;macOS 上在 `~/Applications`,Linux
上在应用菜单里。手动解压的压缩包也一样,启动器第一次运行时会自己建好。
想用浏览器,或者机器上根本没有桌面环境,**http://localhost:17000** 一样能访问;`casbin-gateway start`
则只跑服务端,没有窗口也没有托盘。
diff --git a/desktop/main.go b/desktop/main.go
index 38c02fa..0695abf 100644
--- a/desktop/main.go
+++ b/desktop/main.go
@@ -29,16 +29,19 @@ import (
)
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()
+ // "shortcut on|off" is what the installers call, so that the entries
the
+ // first start would have created are the ones they create — or, with
off,
+ // the ones they leave the machine without. It prints what to open the
+ // Gateway from.
+ if len(os.Args) > 2 && os.Args[1] == "shortcut" {
+ path, err := setShortcuts(os.Args[2] == "on")
if err != nil {
fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
os.Exit(1)
}
- fmt.Println(path)
+ if path != "" {
+ fmt.Println(path)
+ }
return
}
diff --git a/desktop/shortcut.go b/desktop/shortcut.go
new file mode 100644
index 0000000..7416d3f
--- /dev/null
+++ b/desktop/shortcut.go
@@ -0,0 +1,62 @@
+// 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"
+)
+
+// shortcutName is what the entry is called wherever the desktop shows a name.
+const shortcutName = "Casbin Gateway"
+
+// shortcutMarker records that the entries were created once. Without it every
+// start would put back a shortcut the user deleted on purpose.
+func shortcutMarker() string {
+ return filepath.Join(gatewayHome(), ".shortcuts")
+}
+
+// setShortcuts installs the desktop entries, or removes them, and records
+// either way so that the next start leaves the choice alone. It returns the
+// entry worth naming in the installer's output.
+func setShortcuts(enabled bool) (string, error) {
+ path := ""
+ if enabled {
+ installed, err := installShortcuts()
+ if err != nil {
+ return "", err
+ }
+ path = installed
+ } else if err := removeShortcuts(); err != nil {
+ return "", err
+ }
+
+ _ = os.WriteFile(shortcutMarker(), []byte(path), 0o644)
+ return path, nil
+}
+
+// ensureShortcuts gives an archive that was unpacked by hand the same entries
+// the installers create, on its first run. It is best-effort: a Gateway with
no
+// shortcut still runs, which is what the user double-clicked for.
+func ensureShortcuts() {
+ if _, err := os.Stat(shortcutMarker()); err == nil {
+ return
+ }
+
+ if _, err := setShortcuts(true); err != nil {
+ fmt.Fprintln(os.Stderr, "casbin-gateway-desktop: could not
create the shortcut:", err)
+ }
+}
diff --git a/desktop/shortcut_darwin.go b/desktop/shortcut_darwin.go
new file mode 100644
index 0000000..b3bf663
--- /dev/null
+++ b/desktop/shortcut_darwin.go
@@ -0,0 +1,134 @@
+// 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"
+ "strings"
+ "time"
+)
+
+// macOS has no desktop shortcuts to speak of; the application bundle in
+// ~/Applications is what Launchpad, Spotlight and the Dock look for.
+func installShortcuts() (string, error) {
+ bundle, err := bundlePath()
+ if err != nil {
+ return "", err
+ }
+ // A start from inside the bundle has nothing left to install, and would
+ // otherwise delete the launcher it is running.
+ if inBundle(bundle) {
+ return bundle, nil
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ icon, err := writeAppIcon()
+ if err != nil {
+ return "", err
+ }
+
+ if err := os.RemoveAll(bundle); err != nil {
+ return "", err
+ }
+ macOsDir := filepath.Join(bundle, "Contents", "MacOS")
+ resources := filepath.Join(bundle, "Contents", "Resources")
+ for _, dir := range []string{macOsDir, resources} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ }
+
+ // 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. Which is also why the data directory is recorded next to it —
the
+ // copy cannot find it by looking beside itself.
+ if err := copyFile(executable, filepath.Join(macOsDir,
serverName+"-desktop"), 0o755); err != nil {
+ return "", err
+ }
+ if err := copyFile(icon, filepath.Join(resources, "appicon.icns"),
0o644); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(filepath.Join(resources, "home"),
[]byte(gatewayHome()+"\n"), 0o644); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(filepath.Join(bundle, "Contents", "Info.plist"),
[]byte(infoPlist()), 0o644); err != nil {
+ return "", err
+ }
+
+ // The build is not signed or notarized, and an ad-hoc signature is the
most
+ // this can do without a developer certificate. Finder caches the
bundle by
+ // path, so a reinstall over an old one has to say the directory
changed or
+ // it keeps showing the old icon. All three are best-effort.
+ _ = exec.Command("xattr", "-dr", "com.apple.quarantine", bundle).Run()
+ _ = exec.Command("codesign", "--force", "--deep", "--sign", "-",
bundle).Run()
+ now := time.Now()
+ _ = os.Chtimes(bundle, now, now)
+
+ return bundle, nil
+}
+
+func bundlePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, "Applications", shortcutName+".app"), nil
+}
+
+func inBundle(bundle string) bool {
+ executable, err := os.Executable()
+ if err != nil {
+ return false
+ }
+ return strings.HasPrefix(executable, bundle+string(filepath.Separator))
+}
+
+func removeShortcuts() error {
+ bundle, err := bundlePath()
+ if err != nil {
+ return err
+ }
+
+ // Removing the bundle a start came out of would delete the running
+ // launcher, so that one is left where it is.
+ if inBundle(bundle) {
+ return nil
+ }
+ return os.RemoveAll(bundle)
+}
+
+func infoPlist() string {
+ return 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>CFBundleName</key><string>%s</string>
+ <key>CFBundleDisplayName</key><string>%s</string>
+ <key>CFBundleIdentifier</key><string>%s</string>
+ <key>CFBundleExecutable</key><string>%s-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>
+`, shortcutName, shortcutName, appId, serverName)
+}
diff --git a/desktop/shortcut_linux.go b/desktop/shortcut_linux.go
new file mode 100644
index 0000000..93d3490
--- /dev/null
+++ b/desktop/shortcut_linux.go
@@ -0,0 +1,132 @@
+// 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"
+)
+
+func installShortcuts() (string, error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ icon, err := writeAppIcon()
+ if err != nil {
+ return "", err
+ }
+
+ dataDir := xdgDataHome()
+ if dataDir == "" {
+ return "", fmt.Errorf("cannot find the home directory")
+ }
+
+ // The entry points at the icon by name, which only resolves once the
file
+ // is in the icon theme.
+ iconDir := filepath.Join(dataDir, "icons", "hicolor", "512x512", "apps")
+ if err := os.MkdirAll(iconDir, 0o755); err != nil {
+ return "", err
+ }
+ if err := copyFile(icon, filepath.Join(iconDir, appId+".png"), 0o644);
err != nil {
+ return "", err
+ }
+
+ entryDir := filepath.Join(dataDir, "applications")
+ if err := os.MkdirAll(entryDir, 0o755); err != nil {
+ return "", err
+ }
+ entry := filepath.Join(entryDir, appId+".desktop")
+ if err := os.WriteFile(entry, []byte(desktopEntry(executable)), 0o644);
err != nil {
+ return "", err
+ }
+ _ = exec.Command("update-desktop-database", entryDir).Run()
+
+ installDesktopIcon(desktopEntry(executable))
+ return entry, nil
+}
+
+func removeShortcuts() error {
+ paths := []string{}
+ if dataDir := xdgDataHome(); dataDir != "" {
+ paths = append(paths,
+ filepath.Join(dataDir, "applications",
appId+".desktop"),
+ filepath.Join(dataDir, "icons", "hicolor", "512x512",
"apps", appId+".png"))
+ }
+ if home, err := os.UserHomeDir(); err == nil {
+ paths = append(paths, filepath.Join(home, "Desktop",
appId+".desktop"))
+ }
+
+ for _, path := range paths {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ return nil
+}
+
+func desktopEntry(executable string) string {
+ // Path is where the entry starts it: the Gateway keeps its database,
logs
+ // and temporary files in the working directory, so an entry that
started it
+ // elsewhere would start a second, empty installation.
+ return fmt.Sprintf(`[Desktop Entry]
+Type=Application
+Name=%s
+Comment=Local gateway for AI agents
+Exec="%s"
+Path=%s
+Icon=%s
+Terminal=false
+Categories=Development;Network;
+StartupWMClass=%s-desktop
+`, shortcutName, executable, gatewayHome(), appId, serverName)
+}
+
+// installDesktopIcon puts the same entry on the desktop itself. Most desktops
+// only show one there if the file is executable and marked trusted; the chmod
+// is the half that is portable.
+func installDesktopIcon(entry string) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return
+ }
+
+ dir := filepath.Join(home, "Desktop")
+ if _, err := os.Stat(dir); err != nil {
+ return
+ }
+ path := filepath.Join(dir, appId+".desktop")
+ if err := os.WriteFile(path, []byte(entry), 0o755); err != nil {
+ return
+ }
+ // WriteFile only sets the mode on a file it creates, and this one may
be
+ // left over from an earlier install.
+ _ = os.Chmod(path, 0o755)
+ _ = exec.Command("gio", "set", path, "metadata::trusted", "true").Run()
+}
+
+func xdgDataHome() string {
+ if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
+ return dir
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(home, ".local", "share")
+}
diff --git a/desktop/shortcut_unix.go b/desktop/shortcut_unix.go
new file mode 100644
index 0000000..601ec9f
--- /dev/null
+++ b/desktop/shortcut_unix.go
@@ -0,0 +1,33 @@
+// 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 "os"
+
+// copyFile replaces the destination rather than writing over it: the file may
+// be a launcher that is running, and overwriting one in place fails.
+func copyFile(source string, destination string, mode os.FileMode) error {
+ data, err := os.ReadFile(source)
+ if err != nil {
+ return err
+ }
+
+ if err := os.Remove(destination); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return os.WriteFile(destination, data, mode)
+}
diff --git a/desktop/shortcut_windows.go b/desktop/shortcut_windows.go
new file mode 100644
index 0000000..06dfcc8
--- /dev/null
+++ b/desktop/shortcut_windows.go
@@ -0,0 +1,201 @@
+// 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"
+ "runtime"
+ "syscall"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+// A .lnk file has no documented format, so the shell writes it:
CoCreateInstance
+// makes a shell link, IShellLinkW fills it in, and IPersistFile saves it.
+var (
+ ole32 = syscall.NewLazyDLL("ole32.dll")
+
+ procCoCreateInstance = ole32.NewProc("CoCreateInstance")
+
+ clsidShellLink = windows.GUID{Data1: 0x00021401, Data4: [8]byte{0xc0,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}}
+ iidShellLinkW = windows.GUID{Data1: 0x000214f9, Data4: [8]byte{0xc0,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}}
+ iidPersistFile = windows.GUID{Data1: 0x0000010b, Data4: [8]byte{0xc0,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}}
+)
+
+// The vtable slots of the methods used below, counted from QueryInterface.
+const (
+ comQueryInterface = 0
+ comRelease = 2
+
+ shellLinkSetDescription = 7
+ shellLinkSetWorkingDirectory = 9
+ shellLinkSetIconLocation = 17
+ shellLinkSetPath = 20
+
+ persistFileSave = 6
+)
+
+func installShortcuts() (string, error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ icon, err := writeAppIcon()
+ if err != nil {
+ return "", err
+ }
+
+ // COM is per-thread, so it is set up on a thread of this call's own
rather
+ // than on one the tray or the window goes on to run its own loop on.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ if err := coInitialize(); err != nil {
+ return "", err
+ }
+ defer windows.CoUninitialize()
+
+ desktop, err := windows.KnownFolderPath(windows.FOLDERID_Desktop, 0)
+ if err != nil {
+ return "", err
+ }
+ shortcut := filepath.Join(desktop, shortcutName+".lnk")
+ if err := writeShellLink(shortcut, executable, icon); err != nil {
+ return "", err
+ }
+
+ // The Start menu entry as well, because that is where search finds an
+ // application and what "Pin to Start" pins. Its own failure is not
worth
+ // losing the desktop shortcut over.
+ if programs, err := windows.KnownFolderPath(windows.FOLDERID_Programs,
0); err == nil {
+ _ = writeShellLink(filepath.Join(programs,
shortcutName+".lnk"), executable, icon)
+ }
+ return shortcut, nil
+}
+
+func removeShortcuts() error {
+ for _, folder := range
[]*windows.KNOWNFOLDERID{windows.FOLDERID_Desktop, windows.FOLDERID_Programs} {
+ dir, err := windows.KnownFolderPath(folder, 0)
+ if err != nil {
+ continue
+ }
+ if err := os.Remove(filepath.Join(dir, shortcutName+".lnk"));
err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ return nil
+}
+
+// coInitialize treats S_FALSE, which says the thread was already initialized,
+// as the success it is.
+func coInitialize() error {
+ err := windows.CoInitializeEx(0,
windows.COINIT_APARTMENTTHREADED|windows.COINIT_DISABLE_OLE1DDE)
+ if errno, ok := err.(syscall.Errno); ok && errno == 1 {
+ return nil
+ }
+ return err
+}
+
+func writeShellLink(path string, target string, icon string) error {
+ var link unsafe.Pointer
+ hr, _, _ := procCoCreateInstance.Call(
+ uintptr(unsafe.Pointer(&clsidShellLink)),
+ 0,
+ windows.CLSCTX_INPROC_SERVER,
+ uintptr(unsafe.Pointer(&iidShellLinkW)),
+ uintptr(unsafe.Pointer(&link)),
+ )
+ if int32(hr) < 0 {
+ return fmt.Errorf("cannot create a shell link: %s", hresult(hr))
+ }
+ defer comCall(link, comRelease)
+
+ for _, field := range []struct {
+ slot uintptr
+ value string
+ }{
+ {shellLinkSetPath, target},
+ // The Gateway keeps its database, logs and temporary files in
the
+ // working directory, so a shortcut that starts it elsewhere
would start
+ // a second, empty installation.
+ {shellLinkSetWorkingDirectory, gatewayHome()},
+ {shellLinkSetDescription, shortcutName},
+ } {
+ if err := setLinkString(link, field.slot, field.value); err !=
nil {
+ return err
+ }
+ }
+ if err := setLinkIcon(link, icon); err != nil {
+ return err
+ }
+
+ var persist unsafe.Pointer
+ if err := comCall(link, comQueryInterface,
uintptr(unsafe.Pointer(&iidPersistFile)), uintptr(unsafe.Pointer(&persist)));
err != nil {
+ return err
+ }
+ defer comCall(persist, comRelease)
+
+ return saveLink(persist, path)
+}
+
+func setLinkString(link unsafe.Pointer, slot uintptr, value string) error {
+ text, err := syscall.UTF16PtrFromString(value)
+ if err != nil {
+ return err
+ }
+
+ err = comCall(link, slot, uintptr(unsafe.Pointer(text)))
+ runtime.KeepAlive(text)
+ return err
+}
+
+func setLinkIcon(link unsafe.Pointer, icon string) error {
+ path, err := syscall.UTF16PtrFromString(icon)
+ if err != nil {
+ return err
+ }
+ err = comCall(link, shellLinkSetIconLocation,
uintptr(unsafe.Pointer(path)), 0)
+ runtime.KeepAlive(path)
+ return err
+}
+
+func saveLink(persist unsafe.Pointer, path string) error {
+ name, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return err
+ }
+ err = comCall(persist, persistFileSave, uintptr(unsafe.Pointer(name)),
1)
+ runtime.KeepAlive(name)
+ return err
+}
+
+// comCall invokes the method in the object's vtable at slot, passing the
object
+// itself as the "this" every COM method takes first.
+func comCall(object unsafe.Pointer, slot uintptr, args ...uintptr) error {
+ vtable := *(**[32]uintptr)(object)
+ call := append([]uintptr{uintptr(object)}, args...)
+
+ hr, _, _ := syscall.SyscallN(vtable[slot], call...)
+ if int32(hr) < 0 {
+ return fmt.Errorf("the shell refused the shortcut: %s",
hresult(hr))
+ }
+ return nil
+}
+
+func hresult(hr uintptr) string {
+ return fmt.Sprintf("0x%08x", uint32(hr))
+}
diff --git a/desktop/tray.go b/desktop/tray.go
index ddb63fd..1133cf8 100644
--- a/desktop/tray.go
+++ b/desktop/tray.go
@@ -36,6 +36,10 @@ var ownsServer struct {
}
func runTray() {
+ // An archive that was unpacked by hand has no way in but the executable
+ // itself, so the first start is what gives it one.
+ go ensureShortcuts()
+
// The tray menu is drawn by this process too, so it blurs on a scaled
// display for the same reason the window did.
enableDpiAwareness()
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 904558d..c062032 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -61,7 +61,6 @@ try {
Write-Info "Installing to $InstallDir"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
- $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 {
@@ -106,36 +105,23 @@ if ($UserPath -notlike "*$BinDir*") {
}
# ── 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.
-# The launcher is a GUI binary, whose output PowerShell cannot capture, so the
-# icon path is spelled out rather than read back.
-Start-Process -FilePath $DesktopExePath -ArgumentList 'icon' -WorkingDirectory
$InstallDir -Wait
-$IconPath = Join-Path $InstallDir 'casbin-gateway.ico'
+# The launcher creates them, so that an install and an archive unpacked by hand
+# end up with the same pair. They point at the launcher rather than at the
+# server: it is what shows the window, and it starts the server itself if
+# nothing is serving yet. Telling it "off" is what keeps a reinstall with
+# NO_SHORTCUT from getting them back on the next start.
$ShortcutName = 'Casbin Gateway.lnk'
+$ShortcutState = if ($env:NO_SHORTCUT) { 'off' } else { 'on' }
-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}: $_"
- }
+try {
+ $shortcut = Start-Process -FilePath $DesktopExePath -ArgumentList
'shortcut', $ShortcutState -WorkingDirectory $InstallDir -Wait -PassThru
+ if ($shortcut.ExitCode -ne 0) {
+ throw "the launcher exited with $($shortcut.ExitCode)"
}
}
+catch {
+ Write-Info "Could not create the shortcuts: $_"
+}
# ── start with Windows
────────────────────────────────────────────────────────
# The launcher owns this entry so that the tray's "Start at Login" checkbox and
diff --git a/scripts/install.sh b/scripts/install.sh
index 34eebd7..9436e49 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -92,7 +92,6 @@ 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
@@ -132,94 +131,27 @@ EOF
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.
+# A macOS bundle or a Linux desktop entry, created by the launcher so that an
+# install and an archive unpacked by hand end up with the same one. Either
+# points at the launcher rather than at the server: it is what shows the
window,
+# and it starts the server itself. Telling it "off" is what keeps a reinstall
+# with NO_SHORTCUT from getting one back on the next start.
appNote=""
+shortcutState="on"
+if [[ -n "${NO_SHORTCUT}" ]]; then
+ shortcutState="off"
+fi
-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>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
-
- # 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
- 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}"
-}
+if ! appNote="$("${desktopExe}" shortcut "${shortcutState}")"; then
+ appNote=""
+ info "Could not create the desktop application"
+fi
-if [[ -z "${NO_SHORTCUT}" ]]; then
- if [[ "${osName}" == "darwin" ]]; then
- install_macos_app
- else
- install_linux_app
- fi
+# The macOS bundle holds its own copy of the launcher, and that copy is the one
+# the login item and the first start should use: a launcher running from
outside
+# the bundle gets neither the Dock icon nor the name.
+if [[ "${appNote}" == *.app ]]; then
+ desktopExe="${appNote}/Contents/MacOS/casbin-gateway-desktop"
fi
# ── start at login
────────────────────────────────────────────────────────────
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]