Skip to content

Commit 99b4604

Browse files
authored
Merge pull request #4 from SAP-samples/feature/tray-lifecycle
feat(tray): add tray lifecycle management and CLI commands
2 parents ad2fe9b + 993e9a9 commit 99b4604

10 files changed

Lines changed: 661 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ On every command invocation (except `update` and dev builds), a background gorou
122122

123123
`internal/service/` provides a `Scheduler` interface with platform implementations behind build tags: `scheduler_windows.go` (Task Scheduler via `schtasks`), `scheduler_darwin.go` (launchd plist), `scheduler_linux.go` (systemd user timer). Each shells out to OS tools — no CGO, no new dependencies. `service.New(cacheDir)` returns the platform-appropriate implementation. Scheduler logs to `~/.cache/sap-devs/daemon.log`. Interval is configured via `config.Service.Interval` (default 6h).
124124

125+
### Tray Companion (Experimental)
126+
127+
`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`.
128+
125129
### CLI Commands
126130

127131
| Command | Purpose |
@@ -150,6 +154,7 @@ On every command invocation (except `update` and dev builds), a background gorou
150154
| `update` | Self-update the binary |
151155
| `init` | First-time setup wizard |
152156
| `service install/uninstall/status` | Manage OS-native background scheduler (systemd/launchd/Task Scheduler) |
157+
| `tray install/uninstall/start/stop/status` | Download and manage optional GUI tray companion (Wails v3, experimental) |
153158

154159
### Project Detection & Health Check
155160

cmd/tray.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/SAP-samples/sap-devs-cli/internal/config"
10+
"github.com/SAP-samples/sap-devs-cli/internal/credentials"
11+
"github.com/SAP-samples/sap-devs-cli/internal/trayctl"
12+
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var trayCmd = &cobra.Command{
17+
Use: "tray",
18+
Short: "Manage the optional GUI tray companion",
19+
Long: `Manage the sap-devs system tray companion — an optional graphical dashboard
20+
that shows sync status, active profile, and injected tools at a glance.
21+
22+
Note: The tray companion uses Wails v3 (currently in alpha).
23+
This is an optional enhancement — all CLI features work without it.`,
24+
}
25+
26+
var trayInstallCmd = &cobra.Command{
27+
Use: "install",
28+
Short: "Download and install the tray companion binary",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
paths, err := xdg.New()
31+
if err != nil {
32+
return err
33+
}
34+
35+
mgr := &trayctl.Manager{
36+
CacheDir: paths.CacheDir,
37+
Token: credentials.Resolve(paths.ConfigDir),
38+
Version: Version,
39+
RepoURL: repoURL,
40+
}
41+
42+
out := cmd.OutOrStdout()
43+
fmt.Fprintln(out, "Downloading sap-devs-tray...")
44+
if err := mgr.Install(); err != nil {
45+
return err
46+
}
47+
48+
fmt.Fprintln(out, "Verifying...")
49+
if err := mgr.Verify(); err != nil {
50+
return fmt.Errorf("verification failed: %w", err)
51+
}
52+
53+
fmt.Fprintln(out, "Tray companion installed successfully.")
54+
fmt.Fprintln(out)
55+
fmt.Fprintln(out, "Note: The sap-devs tray companion uses Wails v3 (currently in alpha).")
56+
fmt.Fprintln(out, "This is an optional enhancement — all CLI features work without it.")
57+
fmt.Fprintln(out, "If you encounter issues, run `sap-devs tray uninstall` to remove it.")
58+
fmt.Fprintln(out)
59+
60+
fmt.Fprint(out, "Start tray automatically on login? [Y/n] ")
61+
reader := bufio.NewReader(os.Stdin)
62+
answer, _ := reader.ReadString('\n')
63+
answer = strings.TrimSpace(strings.ToLower(answer))
64+
if answer == "" || answer == "y" || answer == "yes" {
65+
if err := mgr.RegisterAutostart(); err != nil {
66+
fmt.Fprintf(out, "Warning: could not register autostart: %v\n", err)
67+
} else {
68+
fmt.Fprintln(out, "Autostart registered.")
69+
cfg, _ := config.Load(paths.ConfigDir)
70+
cfg.Tray.Autostart = true
71+
_ = cfg.Save(paths.ConfigDir)
72+
}
73+
}
74+
75+
return nil
76+
},
77+
}
78+
79+
var trayUninstallCmd = &cobra.Command{
80+
Use: "uninstall",
81+
Short: "Remove the tray companion",
82+
RunE: func(cmd *cobra.Command, args []string) error {
83+
paths, err := xdg.New()
84+
if err != nil {
85+
return err
86+
}
87+
mgr := &trayctl.Manager{CacheDir: paths.CacheDir, Version: Version, RepoURL: repoURL}
88+
89+
if err := mgr.UnregisterAutostart(); err != nil {
90+
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove autostart entry: %v\n", err)
91+
}
92+
if err := mgr.Uninstall(); err != nil {
93+
return err
94+
}
95+
96+
cfg, _ := config.Load(paths.ConfigDir)
97+
cfg.Tray.Autostart = false
98+
_ = cfg.Save(paths.ConfigDir)
99+
100+
fmt.Fprintln(cmd.OutOrStdout(), "Tray companion uninstalled.")
101+
return nil
102+
},
103+
}
104+
105+
var trayStartCmd = &cobra.Command{
106+
Use: "start",
107+
Short: "Launch the tray companion",
108+
RunE: func(cmd *cobra.Command, args []string) error {
109+
paths, err := xdg.New()
110+
if err != nil {
111+
return err
112+
}
113+
mgr := &trayctl.Manager{CacheDir: paths.CacheDir}
114+
if err := mgr.Start(); err != nil {
115+
return err
116+
}
117+
fmt.Fprintln(cmd.OutOrStdout(), "Tray companion started.")
118+
return nil
119+
},
120+
}
121+
122+
var trayStopCmd = &cobra.Command{
123+
Use: "stop",
124+
Short: "Stop the running tray companion",
125+
RunE: func(cmd *cobra.Command, args []string) error {
126+
paths, err := xdg.New()
127+
if err != nil {
128+
return err
129+
}
130+
mgr := &trayctl.Manager{CacheDir: paths.CacheDir}
131+
if err := mgr.Stop(); err != nil {
132+
return err
133+
}
134+
fmt.Fprintln(cmd.OutOrStdout(), "Tray companion stopped.")
135+
return nil
136+
},
137+
}
138+
139+
var trayStatusCmd = &cobra.Command{
140+
Use: "status",
141+
Short: "Show tray companion status",
142+
RunE: func(cmd *cobra.Command, args []string) error {
143+
paths, err := xdg.New()
144+
if err != nil {
145+
return err
146+
}
147+
mgr := &trayctl.Manager{CacheDir: paths.CacheDir, Version: Version}
148+
out := cmd.OutOrStdout()
149+
150+
if !mgr.IsInstalled() {
151+
fmt.Fprintln(out, "Tray: not installed")
152+
fmt.Fprintln(out, "Run `sap-devs tray install` to download the tray companion.")
153+
return nil
154+
}
155+
156+
running := "stopped"
157+
if mgr.IsRunning() {
158+
running = "running"
159+
}
160+
161+
cfg, _ := config.Load(paths.ConfigDir)
162+
autostart := "disabled"
163+
if cfg.Tray.Autostart {
164+
autostart = "enabled"
165+
}
166+
167+
fmt.Fprintf(out, "Tray: installed (%s)\n", running)
168+
fmt.Fprintf(out, "Autostart: %s\n", autostart)
169+
fmt.Fprintf(out, "Binary: %s\n", mgr.BinaryPath())
170+
return nil
171+
},
172+
}
173+
174+
func init() {
175+
trayCmd.AddCommand(trayInstallCmd)
176+
trayCmd.AddCommand(trayUninstallCmd)
177+
trayCmd.AddCommand(trayStartCmd)
178+
trayCmd.AddCommand(trayStopCmd)
179+
trayCmd.AddCommand(trayStatusCmd)
180+
rootCmd.AddCommand(trayCmd)
181+
}

cmd/update.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/spf13/cobra"
88
"github.com/SAP-samples/sap-devs-cli/internal/credentials"
99
"github.com/SAP-samples/sap-devs-cli/internal/i18n"
10+
"github.com/SAP-samples/sap-devs-cli/internal/trayctl"
1011
"github.com/SAP-samples/sap-devs-cli/internal/update"
1112
"github.com/SAP-samples/sap-devs-cli/internal/xdg"
1213
)
@@ -49,6 +50,17 @@ var updateCmd = &cobra.Command{
4950
}
5051

5152
fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(i18n.ActiveLang, "update.done", map[string]any{"TagName": rel.TagName}))
53+
54+
mgr := &trayctl.Manager{CacheDir: paths.CacheDir, Version: rel.Version, Token: token, RepoURL: repoURL}
55+
if mgr.IsInstalled() {
56+
fmt.Fprintln(cmd.OutOrStdout(), "Updating tray companion...")
57+
if err := mgr.Install(); err != nil {
58+
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: tray update failed: %v\n", err)
59+
} else {
60+
fmt.Fprintln(cmd.OutOrStdout(), "Tray companion updated.")
61+
}
62+
}
63+
5264
return nil
5365
},
5466
}

internal/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Config struct {
1919
Tutorial TutorialConfig `yaml:"tutorial,omitempty"`
2020
ExperienceLevel string `yaml:"experience_level,omitempty"`
2121
Service ServiceConfig `yaml:"service,omitempty"`
22+
Tray TrayConfig `yaml:"tray,omitempty"`
2223
}
2324

2425
// SyncConfig controls per-category TTLs for background content refresh.
@@ -60,6 +61,11 @@ type ServiceConfig struct {
6061
Interval time.Duration `yaml:"interval"`
6162
}
6263

64+
// TrayConfig controls the optional GUI tray companion.
65+
type TrayConfig struct {
66+
Autostart bool `yaml:"autostart,omitempty"`
67+
}
68+
6369
func (e EventsConfig) EffectiveLocalRadius() int {
6470
if e.LocalRadius > 0 {
6571
return e.LocalRadius

internal/config/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,21 @@ func TestServiceConfig_RoundTrip(t *testing.T) {
159159
require.NoError(t, err)
160160
assert.Equal(t, 12*time.Hour, loaded.Service.Interval)
161161
}
162+
163+
func TestTrayConfig_Defaults(t *testing.T) {
164+
dir := t.TempDir()
165+
cfg, err := config.Load(dir)
166+
require.NoError(t, err)
167+
assert.False(t, cfg.Tray.Autostart)
168+
}
169+
170+
func TestTrayConfig_RoundTrip(t *testing.T) {
171+
dir := t.TempDir()
172+
cfg := config.Default()
173+
cfg.Tray.Autostart = true
174+
require.NoError(t, cfg.Save(dir))
175+
176+
loaded, err := config.Load(dir)
177+
require.NoError(t, err)
178+
assert.True(t, loaded.Tray.Autostart)
179+
}

internal/trayctl/autostart.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package trayctl
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
)
10+
11+
func autostartEntryName() string {
12+
switch runtime.GOOS {
13+
case "windows":
14+
return "sap-devs-tray"
15+
case "darwin":
16+
return "com.sap-devs.tray"
17+
default:
18+
return "sap-devs-tray.desktop"
19+
}
20+
}
21+
22+
func (m *Manager) RegisterAutostart() error {
23+
binaryPath := m.BinaryPath()
24+
switch runtime.GOOS {
25+
case "windows":
26+
return registerWindowsAutostart(binaryPath)
27+
case "darwin":
28+
return registerDarwinAutostart(binaryPath)
29+
case "linux":
30+
return registerLinuxAutostart(binaryPath)
31+
default:
32+
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
33+
}
34+
}
35+
36+
func (m *Manager) UnregisterAutostart() error {
37+
switch runtime.GOOS {
38+
case "windows":
39+
return unregisterWindowsAutostart()
40+
case "darwin":
41+
return unregisterDarwinAutostart()
42+
case "linux":
43+
return unregisterLinuxAutostart()
44+
default:
45+
return nil
46+
}
47+
}
48+
49+
func registerWindowsAutostart(binaryPath string) error {
50+
cmd := exec.Command("reg", "add",
51+
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`,
52+
"/v", "sap-devs-tray",
53+
"/t", "REG_SZ",
54+
"/d", binaryPath,
55+
"/f",
56+
)
57+
return cmd.Run()
58+
}
59+
60+
func unregisterWindowsAutostart() error {
61+
cmd := exec.Command("reg", "delete",
62+
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`,
63+
"/v", "sap-devs-tray",
64+
"/f",
65+
)
66+
return cmd.Run()
67+
}
68+
69+
func registerDarwinAutostart(binaryPath string) error {
70+
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
71+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
72+
<plist version="1.0">
73+
<dict>
74+
<key>Label</key>
75+
<string>com.sap-devs.tray</string>
76+
<key>ProgramArguments</key>
77+
<array>
78+
<string>%s</string>
79+
</array>
80+
<key>RunAtLoad</key>
81+
<true/>
82+
</dict>
83+
</plist>`, binaryPath)
84+
85+
home, _ := os.UserHomeDir()
86+
path := filepath.Join(home, "Library", "LaunchAgents", "com.sap-devs.tray.plist")
87+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
88+
return err
89+
}
90+
return os.WriteFile(path, []byte(plist), 0644)
91+
}
92+
93+
func unregisterDarwinAutostart() error {
94+
home, _ := os.UserHomeDir()
95+
path := filepath.Join(home, "Library", "LaunchAgents", "com.sap-devs.tray.plist")
96+
_ = exec.Command("launchctl", "unload", path).Run()
97+
return os.Remove(path)
98+
}
99+
100+
func registerLinuxAutostart(binaryPath string) error {
101+
entry := fmt.Sprintf(`[Desktop Entry]
102+
Type=Application
103+
Name=sap-devs Tray
104+
Exec=%s
105+
Terminal=false
106+
StartupNotify=false
107+
X-GNOME-Autostart-enabled=true
108+
`, binaryPath)
109+
110+
home, _ := os.UserHomeDir()
111+
dir := filepath.Join(home, ".config", "autostart")
112+
if err := os.MkdirAll(dir, 0755); err != nil {
113+
return err
114+
}
115+
return os.WriteFile(filepath.Join(dir, "sap-devs-tray.desktop"), []byte(entry), 0644)
116+
}
117+
118+
func unregisterLinuxAutostart() error {
119+
home, _ := os.UserHomeDir()
120+
path := filepath.Join(home, ".config", "autostart", "sap-devs-tray.desktop")
121+
return os.Remove(path)
122+
}

0 commit comments

Comments
 (0)