diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e123100..de461a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,3 +26,31 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # bpm-tray (macOS menu bar app) needs Cocoa via cgo, so it can't cross- + # compile from the ubuntu runner above like bpm's CGO_ENABLED=0 build + # does — built separately on a real macOS runner and appended to the + # release goreleaser already created. + tray: + needs: release + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Build bpm-tray for both Apple Silicon and Intel + run: | + mkdir -p dist-tray + for arch in arm64 amd64; do + name="bpm-tray_${{ github.ref_name }}_darwin_${arch}" + CGO_ENABLED=1 GOARCH=$arch go build -o "dist-tray/$name" ./cmd/bpm-tray + tar -C dist-tray -czf "dist-tray/${name}.tar.gz" "$name" + rm "dist-tray/$name" + done + - name: Attach to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload ${{ github.ref_name }} dist-tray/*.tar.gz --clobber diff --git a/Makefile b/Makefile index c743c26..84956ee 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,11 @@ test: ## Run the test suite run: build ## Build and start the web UI on http://localhost:8080 ./bin/$(BINARY) serve --db bpm.db --addr :8080 +.PHONY: tray +tray: ## Build and run the macOS menu bar app (auto-discovers your Bambu Studio dirs) + go build -o bin/bpm-tray ./cmd/bpm-tray + ./bin/bpm-tray + .PHONY: docker docker: ## Build the Docker image (see docker-compose.yml to run it) docker compose build diff --git a/README.md b/README.md index 6abad9c..3b5f8b4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ docker compose up --build Whichever way you start it, open `http://localhost:8080`. Listing, viewing, and importing profiles works right away. To copy profiles between printers or restore a backup, add `--user-dir`/`--system-dir` (filament) and `--process-user-dir`/`--process-system-dir` (process/print) pointing at your real Bambu Studio directories — see the in-app **Help** page for exactly where those live and what each flag does. +**macOS menu bar app:** + +`bpm-tray` runs the same web UI as a small icon in your menu bar instead of a terminal command — no flags to remember, it finds your real Bambu Studio directories on its own. Download it from the [latest release](https://github.com/syscode-labs/bambu-profile-manager/releases/latest), or build it yourself with `make tray`. Click the icon to open the web UI or quit. + ## Day-to-day usage Everything below also works through the web UI — the CLI is for scripting or running headless. diff --git a/cmd/bpm-tray/main.go b/cmd/bpm-tray/main.go new file mode 100644 index 0000000..86f33ad --- /dev/null +++ b/cmd/bpm-tray/main.go @@ -0,0 +1,172 @@ +// bpm-tray is a macOS menu bar wrapper around bpm serve: no terminal window, +// no flags to remember — it auto-discovers your real Bambu Studio +// directories and just runs, with a menu to open the web UI or quit. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "fyne.io/systray" + "github.com/google/uuid" + + "github.com/syscod3/bambu-profile-manager/internal/bambuadapter" + "github.com/syscod3/bambu-profile-manager/internal/reconcile" + "github.com/syscod3/bambu-profile-manager/internal/service" + "github.com/syscod3/bambu-profile-manager/internal/storage/sqlite" + "github.com/syscod3/bambu-profile-manager/internal/webui" + + "net/http" +) + +const addr = ":8080" + +func main() { + systray.Run(onReady, func() {}) +} + +func onReady() { + systray.SetIcon(webui.LogoPNG) + systray.SetTooltip("Bambu Profile Manager") + + mOpen := systray.AddMenuItem("Open bpm", "Open the web UI in your browser") + mStatus := systray.AddMenuItem("Starting…", "") + mStatus.Disable() + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quit", "Stop bpm and quit") + + go func() { + if err := startServer(); err != nil { + mStatus.SetTitle("Failed to start: " + err.Error()) + return + } + mStatus.SetTitle("Running at " + addr) + }() + + go func() { + for { + select { + case <-mOpen.ClickedCh: + exec.Command("open", "http://localhost"+addr).Start() + case <-mQuit.ClickedCh: + systray.Quit() + return + } + } + }() +} + +// startServer wires up the same server cmd/bpm's `serve` builds, but with +// zero flags: db and backups live under ~/Library/Application Support/bpm, +// and Bambu Studio's real directories are auto-discovered rather than +// passed explicitly — a menu bar app has no terminal to pass flags into. +func startServer() error { + appDir, err := supportDir() + if err != nil { + return err + } + dbPath := filepath.Join(appDir, "bpm.db") + + repo, err := sqlite.Open(dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + + svc := &service.Service{Repo: repo, NewID: newUUID} + srv := &webui.Server{Svc: svc} + + if userDir, systemDir, ok := discoverBambuDir("filament"); ok { + svc.Adapter = &bambuadapter.LocalAdapter{Dir: userDir} + svc.Detector = reconcile.RewriteDetector{} + svc.BackupsDir = filepath.Join(appDir, "backups") + srv.UserDir = userDir + srv.SystemDirs = []string{systemDir} + } + if machineUserDir, machineSystemDir, ok := discoverBambuDir("machine"); ok { + srv.MachineDirs = []string{machineUserDir, machineSystemDir} + } + if processUserDir, processSystemDir, ok := discoverBambuDir("process"); ok { + processRepo, err := sqlite.Open(filepath.Join(appDir, "bpm-process.db")) + if err != nil { + return fmt.Errorf("open process db: %w", err) + } + srv.ProcessSvc = &service.Service{ + Repo: processRepo, + Adapter: &bambuadapter.LocalAdapter{Dir: processUserDir}, + Detector: reconcile.RewriteDetector{}, + NewID: newUUID, + BackupsDir: filepath.Join(appDir, "backups-process"), + } + srv.ProcessUserDir = processUserDir + srv.ProcessSystemDirs = []string{processSystemDir} + } + + if srv.UserDir != "" || srv.ProcessUserDir != "" { + go srv.PollRecognition(context.Background(), 5*time.Second) + } + + go func() { + if err := http.ListenAndServe(addr, srv.Routes()); err != nil { + fmt.Fprintln(os.Stderr, "bpm-tray: serve:", err) + } + }() + return nil +} + +// supportDir returns ~/Library/Application Support/bpm, creating it if +// needed — a menu bar app has no fixed working directory to default a +// relative "bpm.db" against, unlike the CLI's `serve`. +func supportDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, "Library", "Application Support", "bpm") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +// discoverBambuDir finds Bambu Studio's real user + system directories for +// kind ("filament", "process", or "machine") under the standard macOS +// install location. The user directory is named after a numeric account ID +// bpm can't guess, so this picks the first account-ID folder that actually +// has a kind subdirectory — matching how a single-user desktop app is +// expected to be used (one real Bambu Studio account signed in). +func discoverBambuDir(kind string) (userDir, systemDir string, ok bool) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", false + } + base := filepath.Join(home, "Library", "Application Support", "BambuStudio") + systemDir = filepath.Join(base, "system", "BBL", kind) + userBase := filepath.Join(base, "user") + + entries, err := os.ReadDir(userBase) + if err != nil { + return "", "", false + } + for _, e := range entries { + if !e.IsDir() || e.Name() == "default" { + continue + } + candidate := filepath.Join(userBase, e.Name(), kind) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, systemDir, true + } + } + return "", "", false +} + +func newUUID() (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + return id.String(), nil +} diff --git a/go.mod b/go.mod index 3dba08b..feecf2f 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/syscod3/bambu-profile-manager go 1.25.8 require ( + fyne.io/systray v1.12.2 github.com/google/uuid v1.6.0 modernc.org/sqlite v1.55.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/go.sum b/go.sum index 8a1f4b4..1f62e0e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/webui/assets.go b/internal/webui/assets.go index 5e2df18..5cc4340 100644 --- a/internal/webui/assets.go +++ b/internal/webui/assets.go @@ -5,15 +5,20 @@ import ( "net/http" ) +// LogoPNG is the app icon, embedded in the binary — single-binary +// distribution stays intact, no separate static file to ship or mount. +// Exported so cmd/bpm-tray can reuse it as the menu bar icon without +// duplicating the asset (go:embed patterns can't reach outside their own +// package's directory tree). +// //go:embed assets/logo.png -var logoPNG []byte +var LogoPNG []byte -// handleLogo serves the app icon embedded in the binary (assets/logo.png) -// — single-binary distribution stays intact, no separate static file to -// ship or mount. Aggressively cacheable: the file only changes on a new -// build, and go:embed gives every build a fresh byte slice anyway. +// handleLogo serves LogoPNG over HTTP. Aggressively cacheable: the file +// only changes on a new build, and go:embed gives every build a fresh byte +// slice anyway. func handleLogo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=604800") - w.Write(logoPNG) + w.Write(LogoPNG) }