Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
172 changes: 172 additions & 0 deletions cmd/bpm-tray/main.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
17 changes: 11 additions & 6 deletions internal/webui/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading