diff --git a/.github/workflows/release-tray.yml b/.github/workflows/release-tray.yml
index 4318df2..67440e7 100644
--- a/.github/workflows/release-tray.yml
+++ b/.github/workflows/release-tray.yml
@@ -59,6 +59,17 @@ jobs:
- name: Prepare tray build assets
run: cp internal/geo/cities.json cmd/sap-devs-tray/data/cities.json
+ - name: Copy platform icon
+ shell: bash
+ run: |
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ cp cmd/sap-devs-tray/assets/icon.ico cmd/sap-devs-tray/sap-devs-tray.ico
+ elif [ "${{ matrix.goos }}" = "darwin" ]; then
+ cp cmd/sap-devs-tray/assets/icon.icns cmd/sap-devs-tray/icon.icns
+ else
+ cp cmd/sap-devs-tray/assets/icon.png cmd/sap-devs-tray/sap-devs-tray.png
+ fi
+
- name: Build
working-directory: cmd/sap-devs-tray
shell: bash
@@ -69,17 +80,23 @@ jobs:
CC: ${{ matrix.cc }}
run: |
EXT=""
- if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
- go build -ldflags "-X main.version=${VERSION}" -o "sap-devs-tray${EXT}" .
+ EXTRA_LDFLAGS=""
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ EXT=".exe"
+ EXTRA_LDFLAGS="-H windowsgui"
+ fi
+ go build -ldflags "-X main.version=${VERSION} ${EXTRA_LDFLAGS}" -o "sap-devs-tray${EXT}" .
- name: Package
shell: bash
run: |
ASSET="sap-devs-tray_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
- 7z a "${ASSET}.zip" ./cmd/sap-devs-tray/sap-devs-tray.exe
+ 7z a "${ASSET}.zip" ./cmd/sap-devs-tray/sap-devs-tray.exe ./cmd/sap-devs-tray/sap-devs-tray.ico
+ elif [ "${{ matrix.goos }}" = "darwin" ]; then
+ tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray icon.icns
else
- tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray
+ tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray sap-devs-tray.png
fi
- name: Generate checksum
diff --git a/CLAUDE.md b/CLAUDE.md
index e5d3b0e..4e3015d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -143,7 +143,7 @@ On every command invocation (except `update` and dev builds), a background gorou
### Tray Companion (Experimental)
-`internal/trayctl/` manages an optional GUI tray binary (`sap-devs-tray`) downloaded from GitHub Releases. `Manager` handles download, SHA256 checksum verification (via `tray-checksums.txt`), extraction (tar.gz/zip), start/stop (process management), and version-matched updates during `sap-devs update`. `autostart.go` provides cross-platform login startup registration: Windows registry (`HKCU\...\Run`), macOS LaunchAgent plist, Linux XDG `.desktop` file. The tray binary is stored at `~/.cache/sap-devs/bin/sap-devs-tray`. Config key: `config.Tray.Autostart`.
+`internal/trayctl/` manages an optional GUI tray binary (`sap-devs-tray`) downloaded from GitHub Releases. `Manager` handles download, SHA256 checksum verification (via `tray-checksums.txt`), extraction (tar.gz/zip), start/stop (process management), and version-matched updates during `sap-devs update`. `autostart.go` provides cross-platform login startup registration: Windows registry (`HKCU\...\Run`), macOS LaunchAgent plist, Linux XDG `.desktop` file. The tray binary is stored at `~/.cache/sap-devs/bin/sap-devs-tray`. Config key: `config.Tray.Autostart`. `shortcut_windows.go` / `shortcut_darwin.go` / `shortcut_linux.go` handle native app shortcuts (Windows `.lnk`, macOS `.app` bundle, Linux `.desktop` files) — created during install, removed during uninstall. The release pipeline ships platform-specific icons alongside the binary and sets the Windows PE subsystem to GUI (`-H windowsgui`) to prevent terminal window allocation.
### Tray Binary (Optional)
diff --git a/cmd/sap-devs-tray/assets/README.md b/cmd/sap-devs-tray/assets/README.md
new file mode 100644
index 0000000..92991d5
--- /dev/null
+++ b/cmd/sap-devs-tray/assets/README.md
@@ -0,0 +1,7 @@
+# Tray Icon Assets
+
+- `icon.png` — 1024x1024 master PNG (source of truth)
+- `icon.ico` — Windows ICO (multi-resolution: 16/32/48/256)
+- `icon.icns` — macOS ICNS
+
+Current files are placeholders. Replace with real assets before release.
diff --git a/cmd/sap-devs-tray/assets/icon.icns b/cmd/sap-devs-tray/assets/icon.icns
new file mode 100644
index 0000000..e69de29
diff --git a/cmd/sap-devs-tray/assets/icon.ico b/cmd/sap-devs-tray/assets/icon.ico
new file mode 100644
index 0000000..e69de29
diff --git a/cmd/sap-devs-tray/assets/icon.png b/cmd/sap-devs-tray/assets/icon.png
new file mode 100644
index 0000000..a22e39f
Binary files /dev/null and b/cmd/sap-devs-tray/assets/icon.png differ
diff --git a/docs/superpowers/plans/2026-05-07-tray-headless-launch.md b/docs/superpowers/plans/2026-05-07-tray-headless-launch.md
new file mode 100644
index 0000000..e217cc9
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-07-tray-headless-launch.md
@@ -0,0 +1,792 @@
+# Tray Headless Launch & App Shortcuts Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eliminate the terminal window when the tray binary launches on Windows, and provide native app shortcuts for easy manual launch on all platforms.
+
+**Architecture:** Build-tagged platform files split process launching (`startProcess`) and shortcut management (`CreateShortcuts`/`RemoveShortcuts`) into per-OS implementations. The release pipeline packages icons alongside the binary and sets the Windows PE subsystem to GUI. The existing `Install()`/`Uninstall()` methods in `manager.go` gain shortcut lifecycle calls.
+
+**Tech Stack:** Go build tags, `syscall.SysProcAttr` (Windows), PowerShell COM (`.lnk`), macOS `.app` bundle, freedesktop `.desktop` files, GitHub Actions workflow YAML.
+
+**Spec:** `docs/superpowers/specs/2026-05-07-tray-headless-launch-design.md`
+
+---
+
+### Task 1: Platform-specific `startProcess` — Windows
+
+**Files:**
+- Create: `internal/trayctl/start_windows.go`
+
+- [ ] **Step 1: Create the build-tagged Windows start implementation**
+
+```go
+//go:build windows
+
+package trayctl
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func startProcess(binaryPath string) error {
+ cmd := exec.Command(binaryPath)
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ CreationFlags: 0x00000008 | 0x00000010, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
+ }
+ return cmd.Start()
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build (no output)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/trayctl/start_windows.go
+git commit -m "feat(tray): add Windows startProcess with DETACHED_PROCESS flags"
+```
+
+---
+
+### Task 2: Platform-specific `startProcess` — Unix default
+
+**Files:**
+- Create: `internal/trayctl/start_other.go`
+
+- [ ] **Step 1: Create the build-tagged non-Windows implementation**
+
+```go
+//go:build !windows
+
+package trayctl
+
+import "os/exec"
+
+func startProcess(binaryPath string) error {
+ cmd := exec.Command(binaryPath)
+ cmd.Stdout = nil
+ cmd.Stderr = nil
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ return cmd.Process.Release()
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/trayctl/start_other.go
+git commit -m "feat(tray): add Unix startProcess with process release"
+```
+
+---
+
+### Task 3: Refactor `Start()` to delegate to `startProcess`
+
+**Files:**
+- Modify: `internal/trayctl/manager.go:132-143`
+
+- [ ] **Step 1: Replace the `Start()` method body**
+
+Change the current `Start()` method (lines 132-143) to:
+
+```go
+func (m *Manager) Start() error {
+ if !m.IsInstalled() {
+ return fmt.Errorf("tray is not installed — run `sap-devs tray install` first")
+ }
+ return startProcess(m.BinaryPath())
+}
+```
+
+- [ ] **Step 2: Remove unused import `"os/exec"` if it's now only used elsewhere**
+
+Check if `os/exec` is still needed by other methods in `manager.go` (`Stop()`, `Verify()`, `IsRunning()` — yes, they use it). No change needed.
+
+- [ ] **Step 3: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 4: Run vet**
+
+Run: `go vet ./internal/trayctl/`
+Expected: no issues
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/trayctl/manager.go
+git commit -m "refactor(tray): delegate Start() to platform-specific startProcess"
+```
+
+---
+
+### Task 4: Extract assets — replace `extractBinary` with `extractAssets`
+
+**Files:**
+- Modify: `internal/trayctl/extract.go`
+- Modify: `internal/trayctl/manager.go:100-112`
+
+- [ ] **Step 1: Add `extractAllFromTarGz` and `extractAllFromZip` functions to `extract.go`**
+
+Add below the existing functions in `extract.go`:
+
+```go
+func extractAllFromTarGz(data []byte) (map[string][]byte, error) {
+ gz, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ defer gz.Close()
+ files := make(map[string][]byte)
+ tr := tar.NewReader(gz)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ if hdr.Typeflag != tar.TypeReg {
+ continue
+ }
+ content, err := io.ReadAll(io.LimitReader(tr, maxDownloadBytes))
+ if err != nil {
+ return nil, err
+ }
+ files[filepath.Base(hdr.Name)] = content
+ }
+ return files, nil
+}
+
+func extractAllFromZip(data []byte) (map[string][]byte, error) {
+ r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return nil, err
+ }
+ files := make(map[string][]byte)
+ for _, f := range r.File {
+ if f.FileInfo().IsDir() {
+ continue
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return nil, err
+ }
+ content, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes))
+ rc.Close()
+ if err != nil {
+ return nil, err
+ }
+ files[filepath.Base(f.Name)] = content
+ }
+ return files, nil
+}
+
+func extractAssets(data []byte, assetFileName string) (map[string][]byte, error) {
+ if strings.HasSuffix(assetFileName, ".zip") {
+ return extractAllFromZip(data)
+ }
+ return extractAllFromTarGz(data)
+}
+```
+
+- [ ] **Step 2: Update imports in `extract.go` to add `"strings"`**
+
+Replace the import block in `extract.go` with:
+
+```go
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+)
+```
+
+- [ ] **Step 3: Update `Install()` in `manager.go` to use `extractAssets`**
+
+Replace lines 100-112 of `manager.go` with:
+
+```go
+ assets, err := extractAssets(archive, asset)
+ if err != nil {
+ return fmt.Errorf("could not extract assets: %w", err)
+ }
+
+ if err := os.MkdirAll(m.binDir(), 0755); err != nil {
+ return err
+ }
+ for name, content := range assets {
+ perm := os.FileMode(0644)
+ if name == binaryName() {
+ perm = 0755
+ }
+ if err := os.WriteFile(filepath.Join(m.binDir(), name), content, perm); err != nil {
+ return err
+ }
+ }
+```
+
+- [ ] **Step 4: Delete the now-dead `extractBinary` function from `manager.go`**
+
+Remove lines 206-212 of `manager.go` (the old single-file extractor is replaced by `extractAssets`):
+
+```go
+// DELETE this entire function:
+func extractBinary(data []byte, assetFileName string) ([]byte, error) {
+ name := binaryName()
+ if strings.HasSuffix(assetFileName, ".zip") {
+ return extractFromZip(data, name)
+ }
+ return extractFromTarGz(data, name)
+}
+```
+
+- [ ] **Step 5: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/trayctl/extract.go internal/trayctl/manager.go
+git commit -m "feat(tray): extract all archive assets (binary + icon) on install"
+```
+
+---
+
+### Task 5: Windows shortcuts — `CreateShortcuts` / `RemoveShortcuts`
+
+**Files:**
+- Create: `internal/trayctl/shortcut_windows.go`
+
+- [ ] **Step 1: Create the Windows shortcut implementation**
+
+```go
+//go:build windows
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ target := m.BinaryPath()
+ workDir := m.binDir()
+ iconPath := filepath.Join(m.binDir(), "sap-devs-tray.ico")
+
+ startMenuDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs")
+ if err := createLnk(filepath.Join(startMenuDir, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
+ return fmt.Errorf("start menu shortcut: %w", err)
+ }
+
+ desktopPath, err := resolveDesktopPath()
+ if err != nil {
+ return fmt.Errorf("resolve desktop path: %w", err)
+ }
+ if err := createLnk(filepath.Join(desktopPath, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
+ return fmt.Errorf("desktop shortcut: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ startMenuLink := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "SAP Devs Tray.lnk")
+ _ = os.Remove(startMenuLink)
+
+ desktopPath, _ := resolveDesktopPath()
+ if desktopPath != "" {
+ _ = os.Remove(filepath.Join(desktopPath, "SAP Devs Tray.lnk"))
+ }
+
+ _ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.ico"))
+ return nil
+}
+
+func resolveDesktopPath() (string, error) {
+ cmd := exec.Command("powershell", "-NoProfile", "-Command",
+ "[Environment]::GetFolderPath('Desktop')")
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ path := strings.TrimSpace(string(out))
+ if path == "" {
+ return "", fmt.Errorf("could not resolve Desktop folder")
+ }
+ return path, nil
+}
+
+func createLnk(lnkPath, target, workDir, iconPath string) error {
+ script := fmt.Sprintf(`
+$ws = New-Object -ComObject WScript.Shell
+$s = $ws.CreateShortcut('%s')
+$s.TargetPath = '%s'
+$s.WorkingDirectory = '%s'
+$s.IconLocation = '%s,0'
+$s.Save()
+`, lnkPath, target, workDir, iconPath)
+
+ cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 2: Add `"strings"` to imports**
+
+Already present in the code above — verify the import block includes `"strings"`.
+
+- [ ] **Step 3: Verify it compiles**
+
+Run: `GOOS=windows go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/trayctl/shortcut_windows.go
+git commit -m "feat(tray): Windows .lnk shortcut creation via PowerShell COM"
+```
+
+---
+
+### Task 6: macOS shortcuts — `.app` bundle
+
+**Files:**
+- Create: `internal/trayctl/shortcut_darwin.go`
+
+- [ ] **Step 1: Create the macOS shortcut implementation**
+
+```go
+//go:build darwin
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ appDir := filepath.Join(home, "Applications", "SAP Devs Tray.app", "Contents")
+ macosDir := filepath.Join(appDir, "MacOS")
+ resDir := filepath.Join(appDir, "Resources")
+
+ for _, d := range []string{macosDir, resDir} {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return err
+ }
+ }
+
+ symlinkPath := filepath.Join(macosDir, "sap-devs-tray")
+ _ = os.Remove(symlinkPath)
+ if err := os.Symlink(m.BinaryPath(), symlinkPath); err != nil {
+ return fmt.Errorf("symlink: %w", err)
+ }
+
+ icnsSource := filepath.Join(m.binDir(), "icon.icns")
+ icnsDest := filepath.Join(resDir, "AppIcon.icns")
+ if data, err := os.ReadFile(icnsSource); err == nil {
+ _ = os.WriteFile(icnsDest, data, 0644)
+ }
+
+ plist := `
+
+
+
+ CFBundleName
+ SAP Devs Tray
+ CFBundleIdentifier
+ com.sap-devs.tray
+ CFBundleExecutable
+ sap-devs-tray
+ CFBundleIconFile
+ AppIcon
+ CFBundlePackageType
+ APPL
+ LSUIElement
+
+ LSBackgroundOnly
+
+
+`
+
+ return os.WriteFile(filepath.Join(appDir, "Info.plist"), []byte(plist), 0644)
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ home, _ := os.UserHomeDir()
+ if home == "" {
+ return nil
+ }
+ _ = os.RemoveAll(filepath.Join(home, "Applications", "SAP Devs Tray.app"))
+ _ = os.Remove(filepath.Join(m.binDir(), "icon.icns"))
+ return nil
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `GOOS=darwin go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/trayctl/shortcut_darwin.go
+git commit -m "feat(tray): macOS .app bundle creation in ~/Applications/"
+```
+
+---
+
+### Task 7: Linux shortcuts — `.desktop` files
+
+**Files:**
+- Create: `internal/trayctl/shortcut_linux.go`
+
+- [ ] **Step 1: Create the Linux shortcut implementation**
+
+```go
+//go:build linux
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+
+ iconPath := filepath.Join(m.binDir(), "sap-devs-tray.png")
+ entry := fmt.Sprintf(`[Desktop Entry]
+Type=Application
+Name=SAP Devs Tray
+Comment=SAP developer tools system tray companion
+Exec=%s
+Icon=%s
+Terminal=false
+Categories=Development;
+StartupNotify=false
+`, m.BinaryPath(), iconPath)
+
+ appsDir := filepath.Join(home, ".local", "share", "applications")
+ if err := os.MkdirAll(appsDir, 0755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(appsDir, "sap-devs-tray.desktop"), []byte(entry), 0644); err != nil {
+ return err
+ }
+
+ desktopDir := filepath.Join(home, "Desktop")
+ if info, err := os.Stat(desktopDir); err == nil && info.IsDir() {
+ desktopFile := filepath.Join(desktopDir, "sap-devs-tray.desktop")
+ if err := os.WriteFile(desktopFile, []byte(entry), 0755); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ home, _ := os.UserHomeDir()
+ if home == "" {
+ return nil
+ }
+ _ = os.Remove(filepath.Join(home, ".local", "share", "applications", "sap-devs-tray.desktop"))
+ _ = os.Remove(filepath.Join(home, "Desktop", "sap-devs-tray.desktop"))
+ _ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.png"))
+ return nil
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `GOOS=linux go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/trayctl/shortcut_linux.go
+git commit -m "feat(tray): Linux .desktop file creation for app launchers"
+```
+
+---
+
+### Task 8: Wire shortcuts into `Install()`
+
+**Files:**
+- Modify: `internal/trayctl/manager.go` (Install method)
+
+- [ ] **Step 1: Add `CreateShortcuts()` call at the end of `Install()` in `manager.go`**
+
+After the asset extraction loop (the new code from Task 4), add before the closing `return nil`:
+
+```go
+ if err := m.CreateShortcuts(); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: could not create shortcuts: %v\n", err)
+ }
+ return nil
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 3: Verify the full CLI builds**
+
+Run: `go build ./...`
+Expected: clean build
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/trayctl/manager.go
+git commit -m "feat(tray): wire CreateShortcuts into Install"
+```
+
+---
+
+### Task 9: Icon assets
+
+**Files:**
+- Create: `cmd/sap-devs-tray/assets/icon.png` (placeholder — real asset needs design)
+- Create: `cmd/sap-devs-tray/assets/icon.ico` (placeholder)
+- Create: `cmd/sap-devs-tray/assets/icon.icns` (placeholder)
+
+- [ ] **Step 1: Create the assets directory**
+
+```bash
+mkdir -p cmd/sap-devs-tray/assets
+```
+
+- [ ] **Step 2: Create placeholder icon files**
+
+For now, create minimal valid placeholder files. Real icons will be provided by design. A 1x1 PNG is sufficient to validate the pipeline:
+
+```bash
+# Create a minimal 1x1 transparent PNG (67 bytes)
+printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82' > cmd/sap-devs-tray/assets/icon.png
+```
+
+For `.ico` and `.icns`, create empty files as placeholders (the pipeline will work, shortcuts will just show default icons):
+
+```bash
+touch cmd/sap-devs-tray/assets/icon.ico
+touch cmd/sap-devs-tray/assets/icon.icns
+```
+
+- [ ] **Step 3: Add a README in the assets folder**
+
+Create `cmd/sap-devs-tray/assets/README.md`:
+
+```markdown
+# Tray Icon Assets
+
+- `icon.png` — 1024x1024 master PNG (source of truth)
+- `icon.ico` — Windows ICO (multi-resolution: 16/32/48/256)
+- `icon.icns` — macOS ICNS
+
+Current files are placeholders. Replace with real assets before release.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cmd/sap-devs-tray/assets/
+git commit -m "chore(tray): add placeholder icon assets for release pipeline"
+```
+
+---
+
+### Task 10: Release pipeline — Windows GUI subsystem + icon packaging
+
+**Files:**
+- Modify: `.github/workflows/release-tray.yml`
+
+- [ ] **Step 1: Add `-H windowsgui` to the Windows build step**
+
+In the Build step (line 73), change the `go build` command to conditionally include the ldflag:
+
+Replace the entire Build step `run:` block with:
+
+```bash
+EXT=""
+EXTRA_LDFLAGS=""
+if [ "${{ matrix.goos }}" = "windows" ]; then
+ EXT=".exe"
+ EXTRA_LDFLAGS="-H windowsgui"
+fi
+go build -ldflags "-X main.version=${VERSION} ${EXTRA_LDFLAGS}" -o "sap-devs-tray${EXT}" .
+```
+
+- [ ] **Step 2: Add a step to copy platform-specific icon into build output**
+
+Add after the "Prepare tray build assets" step and before "Build":
+
+```yaml
+ - name: Copy platform icon
+ shell: bash
+ run: |
+ if [ "${{ matrix.goos }}" = "windows" ]; then
+ cp cmd/sap-devs-tray/assets/icon.ico cmd/sap-devs-tray/sap-devs-tray.ico
+ elif [ "${{ matrix.goos }}" = "darwin" ]; then
+ cp cmd/sap-devs-tray/assets/icon.icns cmd/sap-devs-tray/icon.icns
+ else
+ cp cmd/sap-devs-tray/assets/icon.png cmd/sap-devs-tray/sap-devs-tray.png
+ fi
+```
+
+- [ ] **Step 3: Update the Package step to include icon files**
+
+Replace the Package step `run:` block:
+
+```bash
+ASSET="sap-devs-tray_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}"
+if [ "${{ matrix.goos }}" = "windows" ]; then
+ 7z a "${ASSET}.zip" ./cmd/sap-devs-tray/sap-devs-tray.exe ./cmd/sap-devs-tray/sap-devs-tray.ico
+elif [ "${{ matrix.goos }}" = "darwin" ]; then
+ tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray icon.icns
+else
+ tar czf "${ASSET}.tar.gz" -C cmd/sap-devs-tray sap-devs-tray sap-devs-tray.png
+fi
+```
+
+- [ ] **Step 4: Verify YAML is valid**
+
+Run: `yq '.' .github/workflows/release-tray.yml > /dev/null`
+Expected: no error
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .github/workflows/release-tray.yml
+git commit -m "ci(tray): add -H windowsgui for Windows, package icons in release archives"
+```
+
+---
+
+### Task 11: Update `Uninstall()` to remove all bin dir assets
+
+**Files:**
+- Modify: `internal/trayctl/manager.go` (Uninstall method)
+
+- [ ] **Step 1: Change Uninstall to remove the entire binDir instead of just the binary**
+
+The current `Uninstall()` does `os.Remove(m.BinaryPath())`. Since the bin dir now contains binary + icon, and the tray owns that directory, remove the whole directory:
+
+```go
+func (m *Manager) Uninstall() error {
+ _ = m.RemoveShortcuts()
+ _ = m.Stop()
+ return os.RemoveAll(m.binDir())
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/trayctl/manager.go
+git commit -m "fix(tray): Uninstall removes entire bin dir (binary + icons)"
+```
+
+---
+
+### Task 12: Final integration verification
+
+- [ ] **Step 1: Full build check**
+
+Run: `go build ./...`
+Expected: clean build of all packages
+
+- [ ] **Step 2: Vet check**
+
+Run: `go vet ./...`
+Expected: no issues
+
+- [ ] **Step 3: Cross-compilation check (Windows from any platform)**
+
+Run: `GOOS=windows GOARCH=amd64 go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 4: Cross-compilation check (Darwin)**
+
+Run: `GOOS=darwin GOARCH=arm64 go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 5: Cross-compilation check (Linux)**
+
+Run: `GOOS=linux GOARCH=amd64 go build ./internal/trayctl/`
+Expected: clean build
+
+- [ ] **Step 6: Commit any remaining fixes**
+
+If any compilation issues were found and fixed, commit them.
+
+---
+
+### Task 13: Documentation updates
+
+**Files:**
+- Modify: `CLAUDE.md` (Tray Companion section)
+
+- [ ] **Step 1: Update the Tray Companion section in CLAUDE.md**
+
+In the `### Tray Companion (Experimental)` section, update to mention shortcut management:
+
+Add after the sentence ending with `Config key: config.Tray.Autostart.`:
+
+```
+`shortcut_windows.go` / `shortcut_darwin.go` / `shortcut_linux.go` handle native app shortcuts (Windows `.lnk`, macOS `.app` bundle, Linux `.desktop` files) — created during install, removed during uninstall. The release pipeline ships platform-specific icons alongside the binary and sets the Windows PE subsystem to GUI (`-H windowsgui`) to prevent terminal window allocation.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add CLAUDE.md
+git commit -m "docs: update CLAUDE.md with tray shortcut and headless launch details"
+```
diff --git a/internal/trayctl/extract.go b/internal/trayctl/extract.go
index 8bdd869..894eaed 100644
--- a/internal/trayctl/extract.go
+++ b/internal/trayctl/extract.go
@@ -5,17 +5,18 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
- "fmt"
"io"
"path/filepath"
+ "strings"
)
-func extractFromTarGz(data []byte, name string) ([]byte, error) {
+func extractAllFromTarGz(data []byte) (map[string][]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer gz.Close()
+ files := make(map[string][]byte)
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
@@ -25,28 +26,45 @@ func extractFromTarGz(data []byte, name string) ([]byte, error) {
if err != nil {
return nil, err
}
- if filepath.Base(hdr.Name) == name {
- return io.ReadAll(io.LimitReader(tr, maxDownloadBytes))
+ if hdr.Typeflag != tar.TypeReg {
+ continue
}
+ content, err := io.ReadAll(io.LimitReader(tr, maxDownloadBytes))
+ if err != nil {
+ return nil, err
+ }
+ files[filepath.Base(hdr.Name)] = content
}
- return nil, fmt.Errorf("binary %q not found in archive", name)
+ return files, nil
}
-func extractFromZip(data []byte, name string) ([]byte, error) {
+func extractAllFromZip(data []byte) (map[string][]byte, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
+ files := make(map[string][]byte)
for _, f := range r.File {
- if filepath.Base(f.Name) == name {
- rc, err := f.Open()
- if err != nil {
- return nil, err
- }
- result, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes))
- rc.Close()
- return result, err
+ if f.FileInfo().IsDir() {
+ continue
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return nil, err
}
+ content, err := io.ReadAll(io.LimitReader(rc, maxDownloadBytes))
+ rc.Close()
+ if err != nil {
+ return nil, err
+ }
+ files[filepath.Base(f.Name)] = content
+ }
+ return files, nil
+}
+
+func extractAssets(data []byte, assetFileName string) (map[string][]byte, error) {
+ if strings.HasSuffix(assetFileName, ".zip") {
+ return extractAllFromZip(data)
}
- return nil, fmt.Errorf("binary %q not found in zip archive", name)
+ return extractAllFromTarGz(data)
}
diff --git a/internal/trayctl/manager.go b/internal/trayctl/manager.go
index 7f8b7a8..0b12c3d 100644
--- a/internal/trayctl/manager.go
+++ b/internal/trayctl/manager.go
@@ -97,17 +97,26 @@ func (m *Manager) Install() error {
return fmt.Errorf("checksum mismatch — download may be corrupt")
}
- binBytes, err := extractBinary(archive, asset)
+ assets, err := extractAssets(archive, asset)
if err != nil {
- return fmt.Errorf("could not extract binary: %w", err)
+ return fmt.Errorf("could not extract assets: %w", err)
}
if err := os.MkdirAll(m.binDir(), 0755); err != nil {
return err
}
- path := m.BinaryPath()
- if err := os.WriteFile(path, binBytes, 0755); err != nil {
- return err
+ for name, content := range assets {
+ perm := os.FileMode(0644)
+ if name == binaryName() {
+ perm = 0755
+ }
+ if err := os.WriteFile(filepath.Join(m.binDir(), name), content, perm); err != nil {
+ return err
+ }
+ }
+
+ if err := m.CreateShortcuts(); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: could not create shortcuts: %v\n", err)
}
return nil
}
@@ -122,10 +131,11 @@ func (m *Manager) Verify() error {
return nil
}
-// Uninstall stops the tray and removes the binary.
+// Uninstall stops the tray, removes shortcuts, and deletes the bin directory.
func (m *Manager) Uninstall() error {
+ _ = m.RemoveShortcuts()
_ = m.Stop()
- return os.Remove(m.BinaryPath())
+ return os.RemoveAll(m.binDir())
}
// Start launches the tray process in the background.
@@ -133,13 +143,7 @@ func (m *Manager) Start() error {
if !m.IsInstalled() {
return fmt.Errorf("tray is not installed — run `sap-devs tray install` first")
}
- cmd := exec.Command(m.BinaryPath())
- cmd.Stdout = nil
- cmd.Stderr = nil
- if err := cmd.Start(); err != nil {
- return err
- }
- return cmd.Process.Release()
+ return startProcess(m.BinaryPath())
}
// Stop terminates the running tray process.
@@ -203,10 +207,3 @@ func findChecksum(data []byte, assetName string) (string, error) {
return "", fmt.Errorf("asset %s not found in checksums", assetName)
}
-func extractBinary(data []byte, assetFileName string) ([]byte, error) {
- name := binaryName()
- if strings.HasSuffix(assetFileName, ".zip") {
- return extractFromZip(data, name)
- }
- return extractFromTarGz(data, name)
-}
diff --git a/internal/trayctl/shortcut_darwin.go b/internal/trayctl/shortcut_darwin.go
new file mode 100644
index 0000000..8f623f3
--- /dev/null
+++ b/internal/trayctl/shortcut_darwin.go
@@ -0,0 +1,70 @@
+//go:build darwin
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ appDir := filepath.Join(home, "Applications", "SAP Devs Tray.app", "Contents")
+ macosDir := filepath.Join(appDir, "MacOS")
+ resDir := filepath.Join(appDir, "Resources")
+
+ for _, d := range []string{macosDir, resDir} {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return err
+ }
+ }
+
+ symlinkPath := filepath.Join(macosDir, "sap-devs-tray")
+ _ = os.Remove(symlinkPath)
+ if err := os.Symlink(m.BinaryPath(), symlinkPath); err != nil {
+ return fmt.Errorf("symlink: %w", err)
+ }
+
+ icnsSource := filepath.Join(m.binDir(), "icon.icns")
+ icnsDest := filepath.Join(resDir, "AppIcon.icns")
+ if data, err := os.ReadFile(icnsSource); err == nil {
+ _ = os.WriteFile(icnsDest, data, 0644)
+ }
+
+ plist := `
+
+
+
+ CFBundleName
+ SAP Devs Tray
+ CFBundleIdentifier
+ com.sap-devs.tray
+ CFBundleExecutable
+ sap-devs-tray
+ CFBundleIconFile
+ AppIcon
+ CFBundlePackageType
+ APPL
+ LSUIElement
+
+ LSBackgroundOnly
+
+
+`
+
+ return os.WriteFile(filepath.Join(appDir, "Info.plist"), []byte(plist), 0644)
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ home, _ := os.UserHomeDir()
+ if home == "" {
+ return nil
+ }
+ _ = os.RemoveAll(filepath.Join(home, "Applications", "SAP Devs Tray.app"))
+ _ = os.Remove(filepath.Join(m.binDir(), "icon.icns"))
+ return nil
+}
diff --git a/internal/trayctl/shortcut_linux.go b/internal/trayctl/shortcut_linux.go
new file mode 100644
index 0000000..f7c51c5
--- /dev/null
+++ b/internal/trayctl/shortcut_linux.go
@@ -0,0 +1,57 @@
+//go:build linux
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+
+ iconPath := filepath.Join(m.binDir(), "sap-devs-tray.png")
+ entry := fmt.Sprintf(`[Desktop Entry]
+Type=Application
+Name=SAP Devs Tray
+Comment=SAP developer tools system tray companion
+Exec=%s
+Icon=%s
+Terminal=false
+Categories=Development;
+StartupNotify=false
+`, m.BinaryPath(), iconPath)
+
+ appsDir := filepath.Join(home, ".local", "share", "applications")
+ if err := os.MkdirAll(appsDir, 0755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(appsDir, "sap-devs-tray.desktop"), []byte(entry), 0644); err != nil {
+ return err
+ }
+
+ desktopDir := filepath.Join(home, "Desktop")
+ if info, err := os.Stat(desktopDir); err == nil && info.IsDir() {
+ desktopFile := filepath.Join(desktopDir, "sap-devs-tray.desktop")
+ if err := os.WriteFile(desktopFile, []byte(entry), 0755); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ home, _ := os.UserHomeDir()
+ if home == "" {
+ return nil
+ }
+ _ = os.Remove(filepath.Join(home, ".local", "share", "applications", "sap-devs-tray.desktop"))
+ _ = os.Remove(filepath.Join(home, "Desktop", "sap-devs-tray.desktop"))
+ _ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.png"))
+ return nil
+}
diff --git a/internal/trayctl/shortcut_windows.go b/internal/trayctl/shortcut_windows.go
new file mode 100644
index 0000000..13c5b4b
--- /dev/null
+++ b/internal/trayctl/shortcut_windows.go
@@ -0,0 +1,75 @@
+//go:build windows
+
+package trayctl
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func (m *Manager) CreateShortcuts() error {
+ target := m.BinaryPath()
+ workDir := m.binDir()
+ iconPath := filepath.Join(m.binDir(), "sap-devs-tray.ico")
+
+ startMenuDir := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs")
+ if err := createLnk(filepath.Join(startMenuDir, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
+ return fmt.Errorf("start menu shortcut: %w", err)
+ }
+
+ desktopPath, err := resolveDesktopPath()
+ if err != nil {
+ return fmt.Errorf("resolve desktop path: %w", err)
+ }
+ if err := createLnk(filepath.Join(desktopPath, "SAP Devs Tray.lnk"), target, workDir, iconPath); err != nil {
+ return fmt.Errorf("desktop shortcut: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) RemoveShortcuts() error {
+ startMenuLink := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "SAP Devs Tray.lnk")
+ _ = os.Remove(startMenuLink)
+
+ desktopPath, _ := resolveDesktopPath()
+ if desktopPath != "" {
+ _ = os.Remove(filepath.Join(desktopPath, "SAP Devs Tray.lnk"))
+ }
+
+ _ = os.Remove(filepath.Join(m.binDir(), "sap-devs-tray.ico"))
+ return nil
+}
+
+func resolveDesktopPath() (string, error) {
+ cmd := exec.Command("powershell", "-NoProfile", "-Command",
+ "[Environment]::GetFolderPath('Desktop')")
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ path := strings.TrimSpace(string(out))
+ if path == "" {
+ return "", fmt.Errorf("could not resolve Desktop folder")
+ }
+ return path, nil
+}
+
+func createLnk(lnkPath, target, workDir, iconPath string) error {
+ script := fmt.Sprintf(`
+$ws = New-Object -ComObject WScript.Shell
+$s = $ws.CreateShortcut('%s')
+$s.TargetPath = '%s'
+$s.WorkingDirectory = '%s'
+$s.IconLocation = '%s,0'
+$s.Save()
+`, lnkPath, target, workDir, iconPath)
+
+ cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
diff --git a/internal/trayctl/start_other.go b/internal/trayctl/start_other.go
new file mode 100644
index 0000000..7f3eca6
--- /dev/null
+++ b/internal/trayctl/start_other.go
@@ -0,0 +1,15 @@
+//go:build !windows
+
+package trayctl
+
+import "os/exec"
+
+func startProcess(binaryPath string) error {
+ cmd := exec.Command(binaryPath)
+ cmd.Stdout = nil
+ cmd.Stderr = nil
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ return cmd.Process.Release()
+}
diff --git a/internal/trayctl/start_windows.go b/internal/trayctl/start_windows.go
new file mode 100644
index 0000000..798e653
--- /dev/null
+++ b/internal/trayctl/start_windows.go
@@ -0,0 +1,16 @@
+//go:build windows
+
+package trayctl
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func startProcess(binaryPath string) error {
+ cmd := exec.Command(binaryPath)
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ CreationFlags: 0x00000008 | 0x00000010, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
+ }
+ return cmd.Start()
+}