From ae4691da184005762d1f0a498078e162763029b9 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 19:44:27 -0400 Subject: [PATCH 01/13] docs: add tray headless launch & app shortcuts implementation plan 13-task plan covering Windows GUI subsystem fix, platform-specific process detach, archive extraction refactor, and native app shortcuts (Windows .lnk, macOS .app bundle, Linux .desktop files). --- .../plans/2026-05-07-tray-headless-launch.md | 792 ++++++++++++++++++ 1 file changed, 792 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-tray-headless-launch.md 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" +``` From 2cffb18fc0941197cfcb70d78f76c16ba3f0c859 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 19:49:09 -0400 Subject: [PATCH 02/13] feat(tray): add Windows startProcess with DETACHED_PROCESS flags --- internal/trayctl/start_windows.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 internal/trayctl/start_windows.go 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() +} From 997b68789f5bbd023be17c0328747128f2324b32 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 19:52:13 -0400 Subject: [PATCH 03/13] feat(tray): add Unix startProcess with process detach Implements the non-Windows startProcess() that spawns the tray binary as a fully detached background process using cmd.Process.Release(). --- internal/trayctl/start_other.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 internal/trayctl/start_other.go 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() +} From 8473e2b00282eb1b0e41189ef355a0edbfa916ed Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 19:59:42 -0400 Subject: [PATCH 04/13] refactor(tray): delegate Start() to platform-specific startProcess --- internal/trayctl/manager.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/trayctl/manager.go b/internal/trayctl/manager.go index 7f8b7a8..e75263a 100644 --- a/internal/trayctl/manager.go +++ b/internal/trayctl/manager.go @@ -133,13 +133,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. From 3ecd8bb17df4f0d8fd8dc91d776894492550c812 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:01:35 -0400 Subject: [PATCH 05/13] feat(tray): extract all archive assets (binary + icon) on install --- internal/trayctl/extract.go | 48 +++++++++++++++++++++++++------------ internal/trayctl/manager.go | 22 ++++++++--------- 2 files changed, 43 insertions(+), 27 deletions(-) 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 e75263a..0b3838c 100644 --- a/internal/trayctl/manager.go +++ b/internal/trayctl/manager.go @@ -97,17 +97,22 @@ 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 + } } return nil } @@ -197,10 +202,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) -} From 328225bdc3423fb98df2818d4aa9143ddaf40b57 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:03:18 -0400 Subject: [PATCH 06/13] feat(tray): Windows .lnk shortcut creation via PowerShell COM --- internal/trayctl/shortcut_windows.go | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 internal/trayctl/shortcut_windows.go 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 +} From e93164681ff04758a21cda64e0de440b5526adc9 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:03:22 -0400 Subject: [PATCH 07/13] feat(tray): macOS .app bundle creation in ~/Applications/ --- internal/trayctl/shortcut_darwin.go | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 internal/trayctl/shortcut_darwin.go 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 +} From 879364209f4ff0584fb4fb2aaa331fc8dfc0091d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:03:26 -0400 Subject: [PATCH 08/13] feat(tray): Linux .desktop file creation for app launchers --- internal/trayctl/shortcut_linux.go | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 internal/trayctl/shortcut_linux.go 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 +} From 21a926eae21b98a08bec407919e33a35c7305777 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:04:07 -0400 Subject: [PATCH 09/13] feat(tray): wire CreateShortcuts into Install --- internal/trayctl/manager.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/trayctl/manager.go b/internal/trayctl/manager.go index 0b3838c..ed4f572 100644 --- a/internal/trayctl/manager.go +++ b/internal/trayctl/manager.go @@ -114,6 +114,10 @@ func (m *Manager) Install() error { return err } } + + if err := m.CreateShortcuts(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create shortcuts: %v\n", err) + } return nil } From 27ab6917a31939c1d76ce7425b7d30fd59a4046a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:04:46 -0400 Subject: [PATCH 10/13] chore(tray): add placeholder icon assets for release pipeline --- cmd/sap-devs-tray/assets/README.md | 7 +++++++ cmd/sap-devs-tray/assets/icon.icns | 0 cmd/sap-devs-tray/assets/icon.ico | 0 cmd/sap-devs-tray/assets/icon.png | Bin 0 -> 66 bytes 4 files changed, 7 insertions(+) create mode 100644 cmd/sap-devs-tray/assets/README.md create mode 100644 cmd/sap-devs-tray/assets/icon.icns create mode 100644 cmd/sap-devs-tray/assets/icon.ico create mode 100644 cmd/sap-devs-tray/assets/icon.png 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 0000000000000000000000000000000000000000..a22e39fca00920f88b629167a83b8f29f800e058 GIT binary patch literal 66 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blE>9Q7kcv6UAQ@H$MqaKhKtTpi LS3j3^P6 Date: Thu, 7 May 2026 20:05:41 -0400 Subject: [PATCH 11/13] ci(tray): add -H windowsgui for Windows, package icons in release archives --- .github/workflows/release-tray.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) 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 From 53980d647a9958bdb8bba8dd318b8f234757f77e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:06:16 -0400 Subject: [PATCH 12/13] fix(tray): Uninstall removes entire bin dir (binary + icons) and shortcuts --- internal/trayctl/manager.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/trayctl/manager.go b/internal/trayctl/manager.go index ed4f572..0b12c3d 100644 --- a/internal/trayctl/manager.go +++ b/internal/trayctl/manager.go @@ -131,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. From 426b3beac2e538b9ada8dba4ebe3f06deca8d94a Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 7 May 2026 20:07:46 -0400 Subject: [PATCH 13/13] docs: update CLAUDE.md with tray shortcut and headless launch details --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)