feat: add slicer bridge

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-09 17:48:48 +02:00
1 parent 4d61c902e6
commit 96e41e3b2d
25 files changed
+1508 -11

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
name: Bridge release
# Only runs when bridge/ (the slicer-bridge helper app) or this workflow changes -- everything
# else in the repo never touches it. PRs get a build-only check (all three OS binaries, no
# release); a push to main additionally publishes them as a new GitHub Release that the
# in-app Download page links to via /releases/latest.
on:
push:
branches: [main]
paths:
- "bridge/**"
- ".github/workflows/bridge-release.yml"
pull_request:
branches: [main]
paths:
- "bridge/**"
- ".github/workflows/bridge-release.yml"
workflow_dispatch: {}
concurrency:
group: bridge-release-${{ github.ref }}
cancel-in-progress: true
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
- name: Test
working-directory: bridge
run: go test ./...
- name: Build (cross-compiled, no cgo needed)
working-directory: bridge
env:
CGO_ENABLED: 0
GOOS: windows
GOARCH: amd64
run: go build -o dist/print-stash-bridge-windows-amd64.exe ./cmd/print-stash-bridge
- uses: actions/upload-artifact@v4
with:
name: bridge-windows-amd64
path: bridge/dist/print-stash-bridge-windows-amd64.exe
if-no-files-found: error
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
- name: Test
working-directory: bridge
run: go test ./...
- name: Build (cross-compiled, no cgo needed)
working-directory: bridge
env:
CGO_ENABLED: 0
GOOS: linux
GOARCH: amd64
run: go build -o dist/print-stash-bridge-linux-amd64 ./cmd/print-stash-bridge
- uses: actions/upload-artifact@v4
with:
name: bridge-linux-amd64
path: bridge/dist/print-stash-bridge-linux-amd64
if-no-files-found: error
build-macos:
# Custom-URL-scheme handling on macOS goes through Cocoa (see cmd/print-stash-bridge/
# urlhandler_darwin.m), so this binary needs cgo + the Xcode toolchain and can only be
# built on an actual Mac runner -- unlike Windows/Linux, it can't be cross-compiled.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
- name: Test
working-directory: bridge
run: go test ./...
- name: Build arm64 + amd64
working-directory: bridge
env:
CGO_ENABLED: 1
run: |
GOARCH=arm64 go build -o dist/print-stash-bridge-darwin-arm64 ./cmd/print-stash-bridge
GOARCH=amd64 go build -o dist/print-stash-bridge-darwin-amd64 ./cmd/print-stash-bridge
- name: Combine into a universal binary
working-directory: bridge
run: lipo -create -output dist/print-stash-bridge-macos dist/print-stash-bridge-darwin-arm64 dist/print-stash-bridge-darwin-amd64
- uses: actions/upload-artifact@v4
with:
name: bridge-macos
path: bridge/dist/print-stash-bridge-macos
if-no-files-found: error
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [build-windows, build-linux, build-macos]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten downloaded artifacts
run: |
mkdir -p release
mv dist/bridge-windows-amd64/print-stash-bridge-windows-amd64.exe release/
mv dist/bridge-linux-amd64/print-stash-bridge-linux-amd64 release/
mv dist/bridge-macos/print-stash-bridge-macos release/
chmod +x release/print-stash-bridge-linux-amd64 release/print-stash-bridge-macos
- name: Compute release tag
id: tag
run: echo "tag=bridge-$(date +%Y%m%d)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: PrintStash Bridge ${{ steps.tag.outputs.tag }}
body: |
Automated build of the PrintStash slicer bridge (see `bridge/README.md`) from
${{ github.sha }}.
- **Windows**: `print-stash-bridge-windows-amd64.exe` — run it once (double-click is fine) to install.
- **Linux**: `print-stash-bridge-linux-amd64` — `chmod +x`, then run it once to install.
- **macOS**: `print-stash-bridge-macos` (universal) — `chmod +x`, remove the quarantine flag, then run with `--install`. See the README for the exact commands.
make_latest: true
files: |
release/print-stash-bridge-windows-amd64.exe
release/print-stash-bridge-linux-amd64
release/print-stash-bridge-macos
+175
View File
@@ -0,0 +1,175 @@
# PrintStash Bridge
This helper registers the custom protocol `print-stash://`, downloads the requested file, and
launches it in the slicer PrintStash asked for. It exists because Bambu Studio's own
`bambustudio://` handler only accepts links from a small allowlist of domains it trusts (Bambu
Lab's own storefronts, mainly) -- a self-hosted PrintStash instance is never on that list, so
"Open in Bambu Studio" silently does nothing without this bridge in between. OrcaSlicer and
PrusaSlicer's protocol handlers accept any HTTP(S) URL directly, so they're launched straight
from the browser and never need it.
Protocol format:
`print-stash://open?url=<download-url>&slicer=<id>&filename=<name>`
The `url` parameter should be a direct HTTP(s) download link. PrintStash already includes the
auth token in that URL.
## Quick install (no shell commands)
Download the latest Bridge binary for your OS from the
[PrintStash releases](https://github.com/TautvydasDerzinskas/print-stash/releases/latest) page
(also linked from the in-app Download page) and run it once. It registers the `print-stash://`
protocol handler and copies itself into a stable location:
- Windows: `%LOCALAPPDATA%\PrintStash\Bridge\print-stash-bridge.exe`
- Linux: `~/.local/bin/print-stash-bridge`
- macOS: `~/Applications/PrintStash Bridge.app`
### Windows / Linux
Double-click the downloaded file (or run it with no arguments from a terminal). If your Linux
file manager blocks execution, mark the file as executable first (`chmod +x`) and run it once.
### macOS
macOS only recognizes a real `.app` bundle as a protocol handler, so install here is an explicit
step rather than a plain double-click:
```bash
chmod +x ~/Downloads/print-stash-bridge-macos
xattr -d com.apple.quarantine ~/Downloads/print-stash-bridge-macos # downloaded files are quarantined by Gatekeeper
~/Downloads/print-stash-bridge-macos --install
```
This copies itself into `~/Applications/PrintStash Bridge.app` (a background, Dock-less agent)
and registers the `print-stash://` scheme with Launch Services. If macOS still refuses to run it,
open System Settings -> Privacy & Security and allow it there instead of using `xattr`.
## Build
You need Go installed (1.21+ recommended). macOS builds also need Xcode's command line tools
(`xcode-select --install`) since the URL-open handling uses Cocoa via cgo, and can only be built
on macOS itself.
Windows (PowerShell):
```powershell
cd bridge
go build -o dist\print-stash-bridge.exe .\cmd\print-stash-bridge
```
Linux (bash):
```bash
cd bridge
go build -o dist/print-stash-bridge ./cmd/print-stash-bridge
```
macOS (bash), universal binary:
```bash
cd bridge
GOARCH=arm64 go build -o dist/print-stash-bridge-arm64 ./cmd/print-stash-bridge
GOARCH=amd64 go build -o dist/print-stash-bridge-amd64 ./cmd/print-stash-bridge
lipo -create -output dist/print-stash-bridge-macos dist/print-stash-bridge-arm64 dist/print-stash-bridge-amd64
```
## Install (manual)
Windows (PowerShell):
```powershell
cd bridge\scripts
.\install-windows.ps1 -BridgePath "..\dist\print-stash-bridge.exe"
```
Linux (bash):
```bash
cd bridge/scripts
./install-linux.sh ../dist/print-stash-bridge
```
You can also run the binary directly: `print-stash-bridge --install` (this is required, not just
convenient, on macOS -- see above).
If your shell blocks scripts, set the execution policy for the current user:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
## Auto-start at login (optional)
Not needed on any OS for correctness -- see "Quick install" above: Windows and Linux launch the
Bridge fresh, URL already attached, from the registered protocol handler on every click, and
macOS launches its app bundle on demand the same way, even after a reboot. There is nothing
sitting idle waiting to be started.
The only thing adding it as a login item buys you is skipping the ~1 second app-launch delay on
the very first click after starting up your Mac (Windows/Linux have no such delay to skip, since
each click is already a fresh, near-instant process start -- there's no persistent process to
pre-warm).
If you still want it, on macOS:
**Easiest** -- System Settings -> General -> Login Items & Extensions -> click **+** and add
`PrintStash Bridge` from `~/Applications`.
**Or, a LaunchAgent** -- save as `~/Library/LaunchAgents/com.printstash.bridge.plist` (replace
`YOUR_USERNAME`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.printstash.bridge</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USERNAME/Applications/PrintStash Bridge.app/Contents/MacOS/print-stash-bridge</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
then:
```bash
mkdir -p ~/Library/LaunchAgents
# save the file above as ~/Library/LaunchAgents/com.printstash.bridge.plist
launchctl load ~/Library/LaunchAgents/com.printstash.bridge.plist
```
## Config
Create a config file if you want custom slicer paths or arguments:
- Windows: `%APPDATA%\printstash-bridge\config.json`
- Linux: `~/.config/printstash-bridge/config.json`
- macOS: `~/Library/Application Support/printstash-bridge/config.json`
Use `config.example.json` as a template.
Environment override (per slicer): `PRINTSTASH_SLICER_BAMBUSTUDIO=/path/to/BambuStudio`
## Slicer IDs
- bambustudio
- orcaslicer
- prusaslicer
- other (opens with the OS default app)
## Testing
After install, paste this into your browser's address bar (replace the URL):
`print-stash://open?url=https://example.com/model.3mf&slicer=bambustudio&filename=model.3mf`
## Logs
- Windows: `%APPDATA%\printstash-bridge\bridge.log`
- Linux: `~/.config/printstash-bridge/bridge.log`
- macOS: `~/Library/Application Support/printstash-bridge/bridge.log`
## Notes
- The bridge downloads files into the user cache directory unless configured otherwise.
- If a slicer isn't found, the file opens with the OS default app instead.
- On macOS, the installed app keeps running in the background (no Dock icon) so repeat opens
don't pay the app-launch cost every time -- this mirrors how macOS delivers custom URL schemes
to already-registered apps via Apple Events rather than a fresh process per open, unlike
Windows/Linux.
@@ -0,0 +1,46 @@
package main
import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
func samePath(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
return filepath.Clean(a) == filepath.Clean(b)
}
func copyFile(src, dst string, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
if perm != 0 {
if err := os.Chmod(dst, perm); err != nil && runtime.GOOS != "windows" {
return err
}
}
return nil
}
@@ -0,0 +1,118 @@
//go:build darwin
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
)
const (
bundleName = "PrintStash Bridge.app"
bundleID = "com.printstash.bridge"
bundleExeName = "print-stash-bridge"
lsregisterBin = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister"
)
// macOS has no per-invocation registry entry like Windows/Linux -- a custom URL scheme is only
// honored for an actual .app bundle, discovered by LaunchServices from its Info.plist. So install
// here means: copy this binary into a real app bundle under ~/Applications, write the
// CFBundleURLTypes declaring the print-stash scheme, and re-register that bundle.
func installSelf() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", fmt.Errorf("resolve home dir: %w", err)
}
appDir := filepath.Join(home, "Applications", bundleName)
contentsDir := filepath.Join(appDir, "Contents")
macosDir := filepath.Join(contentsDir, "MacOS")
targetExe := filepath.Join(macosDir, bundleExeName)
if !samePath(exePath, targetExe) {
if err := copyFile(exePath, targetExe, 0755); err != nil {
return "", fmt.Errorf("copy binary: %w", err)
}
} else if err := os.Chmod(targetExe, 0755); err != nil {
return "", fmt.Errorf("set permissions: %w", err)
}
if err := os.WriteFile(filepath.Join(contentsDir, "Info.plist"), []byte(infoPlist()), 0644); err != nil {
return "", fmt.Errorf("write Info.plist: %w", err)
}
// Tell LaunchServices about the (re)written bundle so `print-stash://` resolves to it right
// away, without waiting for the periodic system rescan. Best-effort: a launch still works
// later even if this fails (e.g. lsregister moved in a future macOS release).
if _, err := os.Stat(lsregisterBin); err == nil {
_ = exec.Command(lsregisterBin, "-f", appDir).Run()
}
return targetExe, nil
}
func infoPlist() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PrintStash Bridge</string>
<key>CFBundleDisplayName</key>
<string>PrintStash Bridge</string>
<key>CFBundleIdentifier</key>
<string>` + bundleID + `</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>` + bundleExeName + `</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>PrintStash Bridge Protocol</string>
<key>CFBundleURLSchemes</key>
<array>
<string>print-stash</string>
</array>
</dict>
</array>
</dict>
</plist>
`
}
// runDefaultLaunch always means "LaunchServices opened the installed app bundle from a
// print-stash:// click" on macOS -- the URL itself arrives later as an Apple Event, never as
// argv, so there's nothing here to distinguish from any other bare launch. This blocks forever
// servicing those events. Installing (or reinstalling, e.g. after an update) is instead always
// explicit here: `print-stash-bridge --install` from Terminal.
func runDefaultLaunch() {
cfg, cfgDir := loadConfig()
setupLogging(cfg, cfgDir)
listenForAppleEventURLs(func(raw string) {
if err := handleProtocol(raw, cfg); err != nil {
log.Printf("error: %v", err)
}
})
}
@@ -0,0 +1,58 @@
//go:build linux
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func installSelf() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", fmt.Errorf("resolve home dir: %w", err)
}
installDir := filepath.Join(home, ".local", "bin")
targetPath := filepath.Join(installDir, "print-stash-bridge")
if !samePath(exePath, targetPath) {
if err := copyFile(exePath, targetPath, 0755); err != nil {
return "", fmt.Errorf("copy binary: %w", err)
}
} else if err := os.Chmod(targetPath, 0755); err != nil {
return "", fmt.Errorf("set permissions: %w", err)
}
desktopDir := filepath.Join(home, ".local", "share", "applications")
if err := os.MkdirAll(desktopDir, 0755); err != nil {
return "", fmt.Errorf("create desktop dir: %w", err)
}
desktopFile := filepath.Join(desktopDir, "print-stash-bridge.desktop")
desktop := fmt.Sprintf(
"[Desktop Entry]\nName=PrintStash Bridge\nExec=%s %%u\nType=Application\nTerminal=false\nMimeType=x-scheme-handler/print-stash;\n",
targetPath,
)
if err := os.WriteFile(desktopFile, []byte(desktop), 0644); err != nil {
return "", fmt.Errorf("write desktop entry: %w", err)
}
if xdgMime, err := exec.LookPath("xdg-mime"); err == nil {
_ = exec.Command(xdgMime, "default", "print-stash-bridge.desktop", "x-scheme-handler/print-stash").Run()
}
return targetPath, nil
}
func runDefaultLaunch() {
runInstallAndReport()
}
@@ -0,0 +1,13 @@
//go:build !windows && !linux && !darwin
package main
import "fmt"
func installSelf() (string, error) {
return "", fmt.Errorf("unsupported OS for installer")
}
func runDefaultLaunch() {
runInstallAndReport()
}
@@ -0,0 +1,75 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func installSelf() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("resolve executable: %w", err)
}
baseDir := os.Getenv("LOCALAPPDATA")
if baseDir == "" {
if cfgDir, err := os.UserConfigDir(); err == nil && cfgDir != "" {
baseDir = cfgDir
}
}
if baseDir == "" {
return "", fmt.Errorf("LOCALAPPDATA is not set")
}
targetDir := filepath.Join(baseDir, "PrintStash", "Bridge")
targetExe := filepath.Join(targetDir, "print-stash-bridge.exe")
if !samePath(exePath, targetExe) {
if err := copyFile(exePath, targetExe, 0755); err != nil {
return "", fmt.Errorf("copy binary: %w", err)
}
}
if err := registerProtocol(scheme, "PrintStash Bridge", targetExe); err != nil {
return "", err
}
return targetExe, nil
}
func runDefaultLaunch() {
runInstallAndReport()
}
func registerProtocol(protoScheme, label, targetExe string) error {
root := fmt.Sprintf(`HKCU\Software\Classes\%s`, protoScheme)
if err := regAdd(root, "/ve", "/t", "REG_SZ", "/d", "URL:"+label, "/f"); err != nil {
return err
}
if err := regAdd(root, "/v", "URL Protocol", "/t", "REG_SZ", "/d", "1", "/f"); err != nil {
return err
}
commandKey := root + `\shell\open\command`
command := fmt.Sprintf("\"%s\" \"%%1\"", targetExe)
if err := regAdd(commandKey, "/ve", "/t", "REG_SZ", "/d", command, "/f"); err != nil {
return err
}
return nil
}
func regAdd(key string, args ...string) error {
cmdArgs := append([]string{"add", key}, args...)
cmd := exec.Command("reg", cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("registry update failed: %w", err)
}
return nil
}
+396
View File
@@ -0,0 +1,396 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"
)
type SlicerConfig struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
}
type Config struct {
DownloadDir string `json:"download_dir,omitempty"`
LogFile string `json:"log_file,omitempty"`
Slicers map[string]SlicerConfig `json:"slicers,omitempty"`
}
// scheme is only for protocols PrintStash can't hand off directly to a slicer's own URL handler
// (Bambu Studio's bambustudio:// only accepts links from an allowlist of domains it trusts, which
// print-stash's own download URLs aren't on). OrcaSlicer and PrusaSlicer accept any HTTP(S) URL
// through their own protocol, so they're launched straight from the browser and never reach here.
const scheme = "print-stash"
func main() {
if len(os.Args) < 2 {
// On Windows/Linux the OS execs a fresh process with the URL as argv[1] every time, so a
// bare "no args" launch unambiguously means "run me to install". macOS instead delivers a
// print-stash:// open to an already-running app bundle out-of-band as an Apple Event --
// there's no argv equivalent -- so a bare launch there must be assumed to be that GUI
// case; mac install instead requires the explicit --install flag below (see
// runDefaultLaunch's darwin implementation).
runDefaultLaunch()
return
}
arg := strings.TrimSpace(os.Args[1])
switch arg {
case "", "-h", "--help":
printUsage()
return
case "install", "--install":
runInstallAndReport()
return
}
cfg, cfgDir := loadConfig()
setupLogging(cfg, cfgDir)
if err := handleProtocol(arg, cfg); err != nil {
log.Printf("error: %v", err)
}
}
func printUsage() {
fmt.Println("Usage:")
fmt.Println(" print-stash-bridge --install")
fmt.Println(" print-stash-bridge <print-stash://open?...>")
}
// runInstallAndReport is the shared body of "explicitly asked to install" -- via --install on
// any OS, or a bare no-args launch on Windows/Linux (see main's comment on runDefaultLaunch).
func runInstallAndReport() {
target, err := installSelf()
if err != nil {
fmt.Printf("Install failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Installed PrintStash Bridge at %s\n", target)
fmt.Println("You can now use \"Open in Bambu Studio\" from PrintStash.")
}
func handleProtocol(raw string, cfg Config) error {
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
if parsed.Scheme != scheme {
return fmt.Errorf("unexpected scheme: %s", parsed.Scheme)
}
action := strings.Trim(parsed.Host, "/")
if action == "" {
action = strings.Trim(parsed.Path, "/")
}
if action == "" {
action = "open"
}
if action != "open" {
return fmt.Errorf("unsupported action: %s", action)
}
q := parsed.Query()
downloadURL := strings.TrimSpace(q.Get("url"))
if downloadURL == "" {
return errors.New("missing url parameter")
}
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
return fmt.Errorf("unsupported url: %s", redactURL(downloadURL))
}
filename := strings.TrimSpace(q.Get("filename"))
if filename == "" {
if u, err := url.Parse(downloadURL); err == nil {
filename = path.Base(u.Path)
}
}
filename = sanitizeFilename(filename)
slicerID := strings.ToLower(strings.TrimSpace(q.Get("slicer")))
log.Printf("open request slicer=%s url=%s filename=%s", slicerID, redactURL(downloadURL), filename)
downloadDir := resolveDownloadDir(cfg)
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return fmt.Errorf("create download dir: %w", err)
}
targetPath := uniquePath(downloadDir, filename)
if err := downloadFile(downloadURL, targetPath); err != nil {
return err
}
if slicerID == "" || slicerID == "other" {
return openWithDefault(targetPath)
}
cmd, args := resolveSlicerCommand(slicerID, cfg)
if cmd == "" {
return openWithDefault(targetPath)
}
log.Printf("launch slicer command=%s args=%v", cmd, args)
return launchCommand(cmd, args, targetPath)
}
func setupLogging(cfg Config, cfgDir string) {
logPath := cfg.LogFile
if logPath == "" {
logPath = filepath.Join(cfgDir, "bridge.log")
}
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
log.SetOutput(os.Stderr)
return
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.SetOutput(os.Stderr)
return
}
log.SetOutput(f)
}
func loadConfig() (Config, string) {
cfgDir := configDir()
cfgPath := filepath.Join(cfgDir, "config.json")
cfg := Config{Slicers: map[string]SlicerConfig{}}
data, err := os.ReadFile(cfgPath)
if err != nil {
return cfg, cfgDir
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, cfgDir
}
if cfg.Slicers == nil {
cfg.Slicers = map[string]SlicerConfig{}
}
return cfg, cfgDir
}
func configDir() string {
dir, err := os.UserConfigDir()
if err != nil || dir == "" {
return filepath.Join(os.TempDir(), "printstash-bridge")
}
return filepath.Join(dir, "printstash-bridge")
}
func resolveDownloadDir(cfg Config) string {
if cfg.DownloadDir != "" {
return cfg.DownloadDir
}
dir, err := os.UserCacheDir()
if err != nil || dir == "" {
return filepath.Join(os.TempDir(), "printstash-bridge")
}
return filepath.Join(dir, "printstash-bridge")
}
func sanitizeFilename(name string) string {
name = strings.TrimSpace(name)
name = filepath.Base(name)
name = strings.Map(func(r rune) rune {
switch r {
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
return -1
}
if r < 32 {
return -1
}
return r
}, name)
name = strings.Trim(name, " .")
if name == "" {
return "model"
}
return name
}
func uniquePath(dir, name string) string {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
p := filepath.Join(dir, name)
for i := 1; fileExists(p); i++ {
p = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
}
return p
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func downloadFile(downloadURL, targetPath string) error {
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
if err != nil {
return fmt.Errorf("request: %w", err)
}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("download failed: %s", res.Status)
}
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, res.Body); err != nil {
return fmt.Errorf("save file: %w", err)
}
return nil
}
func resolveSlicerCommand(id string, cfg Config) (string, []string) {
id = strings.ToLower(strings.TrimSpace(id))
if id == "" || id == "other" {
return "", nil
}
if cfg.Slicers != nil {
if sc, ok := cfg.Slicers[id]; ok && sc.Command != "" {
return sc.Command, sc.Args
}
}
envKey := "PRINTSTASH_SLICER_" + strings.ToUpper(strings.ReplaceAll(id, "-", "_"))
if val := strings.TrimSpace(os.Getenv(envKey)); val != "" {
return val, nil
}
switch runtime.GOOS {
case "windows":
return findWindowsCommand(id, windowsCandidates()), nil
case "linux":
return findLinuxCommand(id, linuxCandidates()), nil
case "darwin":
return findMacCommand(id, macCandidates()), nil
default:
return "", nil
}
}
func findWindowsCommand(id string, candidates map[string][]string) string {
paths := candidates[id]
if len(paths) == 0 {
return ""
}
var bases []string
if pf := os.Getenv("ProgramFiles"); pf != "" {
bases = append(bases, pf)
}
if pf86 := os.Getenv("ProgramFiles(x86)"); pf86 != "" {
bases = append(bases, pf86)
}
if la := os.Getenv("LOCALAPPDATA"); la != "" {
bases = append(bases, la, filepath.Join(la, "Programs"))
}
for _, rel := range paths {
if filepath.IsAbs(rel) && fileExists(rel) {
return rel
}
for _, base := range bases {
full := filepath.Join(base, rel)
if fileExists(full) {
return full
}
}
}
return ""
}
func findLinuxCommand(id string, candidates map[string][]string) string {
for _, name := range candidates[id] {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
func findMacCommand(id string, candidates map[string][]string) string {
for _, appPath := range candidates[id] {
if fileExists(appPath) {
return appPath
}
}
return ""
}
func windowsCandidates() map[string][]string {
return map[string][]string{
"bambustudio": {"Bambu Studio\\BambuStudio.exe"},
"orcaslicer": {"OrcaSlicer\\OrcaSlicer.exe"},
"prusaslicer": {"PrusaSlicer\\PrusaSlicer.exe"},
}
}
func linuxCandidates() map[string][]string {
return map[string][]string{
"bambustudio": {"bambu-studio", "BambuStudio"},
"orcaslicer": {"orca-slicer", "OrcaSlicer"},
"prusaslicer": {"prusa-slicer", "PrusaSlicer"},
}
}
func macCandidates() map[string][]string {
return map[string][]string{
"bambustudio": {"/Applications/BambuStudio.app"},
"orcaslicer": {"/Applications/OrcaSlicer.app"},
"prusaslicer": {"/Applications/PrusaSlicer.app", "/Applications/Original Prusa Drivers/PrusaSlicer.app"},
}
}
func launchCommand(cmd string, args []string, filePath string) error {
if runtime.GOOS == "darwin" && strings.HasSuffix(strings.ToLower(cmd), ".app") {
allArgs := append([]string{"-a", cmd, filePath}, args...)
return exec.Command("open", allArgs...).Start()
}
allArgs := append([]string{}, args...)
allArgs = append(allArgs, filePath)
return exec.Command(cmd, allArgs...).Start()
}
func openWithDefault(filePath string) error {
switch runtime.GOOS {
case "windows":
return exec.Command("cmd", "/c", "start", "", filePath).Start()
case "linux":
return exec.Command("xdg-open", filePath).Start()
case "darwin":
return exec.Command("open", filePath).Start()
default:
return fmt.Errorf("unsupported os: %s", runtime.GOOS)
}
}
func redactURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
q := parsed.Query()
if q.Has("token") {
q.Set("token", "REDACTED")
parsed.RawQuery = q.Encode()
}
return parsed.String()
}
@@ -0,0 +1,46 @@
package main
import (
"strings"
"testing"
)
func TestSanitizeFilename(t *testing.T) {
tests := map[string]string{
" model.3mf ": "model.3mf",
"../unsafe:model?.stl": "unsafemodel.stl",
"\x00\x01": "model",
"folder\\another/file.step": "file.step",
}
for input, expected := range tests {
if actual := sanitizeFilename(input); actual != expected {
t.Fatalf("sanitizeFilename(%q) = %q, expected %q", input, actual, expected)
}
}
}
func TestRedactURLHidesTokenQueryValue(t *testing.T) {
redacted := redactURL("https://printstash.example/file?id=asset-1&token=secret-value")
if strings.Contains(redacted, "secret-value") {
t.Fatalf("redacted URL still contains the token: %s", redacted)
}
if !strings.Contains(redacted, "token=REDACTED") {
t.Fatalf("redacted URL does not contain the marker: %s", redacted)
}
}
func TestHandleProtocolRejectsUntrustedShapesBeforeDownload(t *testing.T) {
tests := []string{
"https://printstash.example/file",
"print-stash://delete?url=https://printstash.example/file",
"print-stash://open",
"print-stash://open?url=file:///tmp/model.stl",
}
for _, raw := range tests {
if err := handleProtocol(raw, Config{}); err == nil {
t.Fatalf("handleProtocol(%q) unexpectedly succeeded", raw)
}
}
}
@@ -0,0 +1,38 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
void RunURLListener(void);
*/
import "C"
import "log"
// onOpenURLCallback is set once before RunURLListener blocks the main thread inside Cocoa's run
// loop -- handleGetURLEvent (urlhandler_darwin.m) calls back into it for every print-stash://
// link the OS delivers via Apple Events, which is how macOS hands a custom URL scheme to an
// already-registered app bundle (there is no argv equivalent, unlike Windows/Linux).
var onOpenURLCallback func(raw string)
//export goHandleOpenURL
func goHandleOpenURL(cURL *C.char) {
raw := C.GoString(cURL)
if onOpenURLCallback != nil {
onOpenURLCallback(raw)
} else {
log.Printf("received url before listener ready: %s", redactURL(raw))
}
}
// listenForAppleEventURLs blocks forever, running as a Dock-less (LSUIElement) background agent
// that hands every print-stash:// open to handle. macOS reuses this running instance for
// subsequent link clicks instead of relaunching, so it just keeps servicing callback until the
// process is terminated.
func listenForAppleEventURLs(handle func(raw string)) {
onOpenURLCallback = handle
C.RunURLListener()
}
@@ -0,0 +1,42 @@
#import <Cocoa/Cocoa.h>
#include "_cgo_export.h"
@interface PSBridgeAppDelegate : NSObject <NSApplicationDelegate>
@end
@implementation PSBridgeAppDelegate
// Registered in applicationWillFinishLaunching, not applicationDidFinishLaunching: when this
// process is launched *by* a print-stash:// open (the common case -- the app isn't already
// running), the GetURL event carrying that very URL can be dispatched before "did finish" fires,
// and would otherwise be silently dropped. "Will finish" runs early enough to catch it.
- (void)applicationWillFinishLaunching:(NSNotification *)notification {
NSAppleEventManager *manager = [NSAppleEventManager sharedAppleEventManager];
[manager setEventHandler:self
andSelector:@selector(handleGetURLEvent:withReplyEvent:)
forEventClass:kInternetEventClass
andEventID:kAEGetURL];
}
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
NSString *urlString = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
if (urlString) {
goHandleOpenURL((char *)[urlString UTF8String]);
}
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender {
return NO;
}
@end
void RunURLListener(void) {
@autoreleasepool {
NSApplication *app = [NSApplication sharedApplication];
[app setActivationPolicy:NSApplicationActivationPolicyAccessory];
PSBridgeAppDelegate *delegate = [[PSBridgeAppDelegate alloc] init];
[app setDelegate:delegate];
[app run];
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"download_dir": "",
"log_file": "",
"slicers": {
"bambustudio": {
"command": "C:\\\\Program Files\\\\Bambu Studio\\\\BambuStudio.exe"
},
"orcaslicer": {
"command": "/usr/bin/orca-slicer",
"args": []
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module printstash/bridge
go 1.21
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env sh
set -e
BIN_SOURCE="${1:-}"
if [ -z "$BIN_SOURCE" ]; then
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_SOURCE="$SCRIPT_DIR/../dist/print-stash-bridge"
fi
if [ ! -f "$BIN_SOURCE" ]; then
echo "Binary not found: $BIN_SOURCE"
exit 1
fi
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
BIN_TARGET="$INSTALL_DIR/print-stash-bridge"
install -m 755 "$BIN_SOURCE" "$BIN_TARGET"
DESKTOP_DIR="$HOME/.local/share/applications"
mkdir -p "$DESKTOP_DIR"
DESKTOP_FILE="$DESKTOP_DIR/print-stash-bridge.desktop"
cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry]
Name=PrintStash Bridge
Exec=$BIN_TARGET %u
Type=Application
Terminal=false
MimeType=x-scheme-handler/print-stash;
EOF
xdg-mime default print-stash-bridge.desktop x-scheme-handler/print-stash >/dev/null 2>&1 || true
echo "Installed protocol handler at $BIN_TARGET"
+36
View File
@@ -0,0 +1,36 @@
param(
[string]$BridgePath = ""
)
$ErrorActionPreference = "Stop"
if (-not $BridgePath) {
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BridgePath = Join-Path $scriptDir "..\dist\print-stash-bridge.exe"
}
$BridgePath = (Resolve-Path $BridgePath).Path
$targetDir = Join-Path $env:LOCALAPPDATA "PrintStash\Bridge"
$targetExe = Join-Path $targetDir "print-stash-bridge.exe"
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
Copy-Item -Path $BridgePath -Destination $targetExe -Force
function Register-Protocol {
param(
[string]$Protocol,
[string]$Label,
[string]$TargetExe
)
$root = "HKCU:\Software\Classes\$Protocol"
New-Item -Path $root -Force | Out-Null
New-ItemProperty -Path $root -Name "(Default)" -Value "URL:$Label" -PropertyType String -Force | Out-Null
New-ItemProperty -Path $root -Name "URL Protocol" -Value "" -PropertyType String -Force | Out-Null
$commandKey = "$root\shell\open\command"
New-Item -Path $commandKey -Force | Out-Null
Set-ItemProperty -Path $commandKey -Name "(Default)" -Value "`"$TargetExe`" `"%1`"" -Force
}
Register-Protocol -Protocol "print-stash" -Label "PrintStash Bridge" -TargetExe $targetExe
Write-Host "Installed protocol handler at $targetExe"
+2
View File
@@ -16,6 +16,7 @@ import VerifyEmailPage from "./pages/VerifyEmailPage";
import ProfilePage from "./pages/ProfilePage";
import ChangeEmailPage from "./pages/ProfilePage/ChangeEmailPage";
import ChangePasswordPage from "./pages/ProfilePage/ChangePasswordPage";
import DownloadPage from "./pages/DownloadPage";
import AdminSettingsPage from "./pages/AdminSettingsPage";
import UsersPage from "./pages/UsersPage";
import LogsPage from "./pages/LogsPage";
@@ -138,6 +139,7 @@ function AppShell({
path="/profile/password"
element={<ChangePasswordPage onUnauthorized={onUnauthorized} />}
/>
<Route path="/download" element={<DownloadPage />} />
<Route
path="/admin-settings"
element={
@@ -74,6 +74,10 @@ function useRouteChrome() {
} else if (path === "/profile") {
title = t("profile.title");
onBack = () => navigate(-1);
} else if (path === "/download") {
// Overridden by DownloadPage's usePageHeader once translations resolve.
title = t("sidebar.downloadBridge");
onBack = () => navigate("/");
} else if (path.startsWith("/admin-settings")) {
title = t("adminSettings.pageTitle");
onBack = () => navigate("/");
@@ -14,6 +14,7 @@ import Tooltip from "@mui/material/Tooltip";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
import DownloadIcon from "@mui/icons-material/Download";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import SettingsIcon from "@mui/icons-material/Settings";
import PeopleIcon from "@mui/icons-material/People";
@@ -57,8 +58,8 @@ type Props = {
isAdmin: boolean;
};
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, and (for admins)
* Administration. Folder browsing lives inside the Models page itself, not here. */
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, Download Bridge, and
* (for admins) Administration. Folder browsing lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
@@ -72,6 +73,7 @@ export default function Sidebar({ isAdmin }: Props) {
const onDashboard = location.pathname === "/";
const onCollections = location.pathname.startsWith("/models/collections");
const onModels = (location.pathname.startsWith("/models") && !onCollections) || location.pathname.startsWith("/authors");
const onDownload = location.pathname.startsWith("/download");
const onAdminSettings = location.pathname.startsWith("/admin-settings");
const onAdminUsers = location.pathname.startsWith("/admin-users");
const onAdminLogs = location.pathname.startsWith("/admin-logs");
@@ -184,6 +186,26 @@ export default function Sidebar({ isAdmin }: Props) {
</ListItemButton>
)}
{collapsed ? (
<CollapsedNavIcon
icon={<DownloadIcon fontSize="small" />}
label={t("sidebar.downloadBridge")}
selected={onDownload}
onClick={() => navigate("/download")}
/>
) : (
<ListItemButton
selected={onDownload}
onClick={() => navigate("/download")}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.downloadBridge")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
)}
{isAdmin && (
<>
<Divider sx={{ my: 1 }} />
+20
View File
@@ -0,0 +1,20 @@
// PrintStash Bridge is a small helper app (source in /bridge, built by
// .github/workflows/bridge-release.yml) that "Open in Bambu Studio" hands off to instead of
// Bambu Studio's own bambustudio:// link -- see bridge/README.md for why. These are stable URLs:
// GitHub always resolves /releases/latest/download/<asset> to that asset on the most recent
// release, so they don't need updating when a new Bridge build ships.
const BRIDGE_RELEASES_BASE = "https://github.com/TautvydasDerzinskas/print-stash/releases/latest/download";
export const BRIDGE_RELEASES_PAGE = "https://github.com/TautvydasDerzinskas/print-stash/releases";
export type BridgeDownload = { os: "windows" | "macos" | "linux"; label: string; asset: string };
export const BRIDGE_DOWNLOADS: BridgeDownload[] = [
{ os: "windows", label: "Windows", asset: "print-stash-bridge-windows-amd64.exe" },
{ os: "macos", label: "macOS", asset: "print-stash-bridge-macos" },
{ os: "linux", label: "Linux", asset: "print-stash-bridge-linux-amd64" },
];
export function bridgeDownloadUrl(asset: string): string {
return `${BRIDGE_RELEASES_BASE}/${asset}`;
}
+27 -2
View File
@@ -90,7 +90,8 @@
"collapseSidebar": "Collapse sidebar",
"foldersHeadingWithCount": "Folders ({{count}})",
"models": "Models",
"collections": "Collections"
"collections": "Collections",
"downloadBridge": "Download Bridge"
},
"uploadBar": {
"uploading": "Uploading…",
@@ -188,7 +189,9 @@
"heading": "Slicer",
"description": "Your preferred slicer, used by a model's \"Open in Slicer\" action to launch it directly via that slicer's own link (e.g. bambustudio://).",
"label": "Slicer",
"failed": "Failed to save -- try again."
"failed": "Failed to save -- try again.",
"bridgeRequiredPrefix": "Bambu Studio needs the PrintStash Bridge helper app installed to open files from here --",
"bridgeRequiredLink": "get it from the Download page."
}
},
"addMenu": {
@@ -412,6 +415,28 @@
"collection_deleted": "Collection deleted"
}
}
},
"download": {
"pageTitle": "PrintStash Bridge",
"intro": "A small helper app that runs on your computer so \"Open in Slicer\" can hand a model straight to your slicer.",
"why": "Bambu Studio's own bambustudio:// link only accepts files from a short allowlist of domains Bambu Lab controls, and a self-hosted PrintStash never qualifies -- so \"Open in Bambu Studio\" would otherwise do nothing. The Bridge listens on its own print-stash:// link instead, downloads the file itself, and hands it to Bambu Studio locally. OrcaSlicer and PrusaSlicer already accept links directly and don't need it.",
"downloadHeading": "Download",
"install": {
"windows": "Run the downloaded .exe once (double-click is fine) -- it installs itself and registers the protocol.",
"macos": "chmod +x it, remove the Gatekeeper quarantine flag (xattr -d com.apple.quarantine), then run it once with --install.",
"linux": "Mark it executable (chmod +x) and run it once from a terminal."
},
"footerLinks": "Need the exact commands, or building from source? See the <releases>Bridge releases</releases> page.",
"autostart": {
"heading": "Run at startup (optional)",
"windowsLinuxNote": "Not needed on Windows or Linux -- the OS launches the Bridge fresh, with the link's URL already attached, every time you click \"Open in Bambu Studio\". There's no background process to keep running.",
"macosIntro": "Not required on macOS either, for the same reason -- it launches on demand even after a reboot. Adding it as a login item just means the very first click after starting up responds instantly instead of waiting about a second for the app to launch.",
"macosGuiHeading": "Easiest: System Settings",
"macosGuiSteps": "System Settings -> General -> Login Items & Extensions -> click + and add \"PrintStash Bridge\" from ~/Applications.",
"macosLaunchAgentHeading": "Or, a LaunchAgent (for Terminal users)",
"macosLaunchAgentIntro": "Save this as ~/Library/LaunchAgents/com.printstash.bridge.plist (replace YOUR_USERNAME with your actual username):",
"macosLaunchAgentLoad": "Then load it:"
}
}
},
"common": {
+27 -2
View File
@@ -90,7 +90,8 @@
"collapseSidebar": "Suskleisti šoninę juostą",
"foldersHeadingWithCount": "Aplankai ({{count}})",
"models": "Modeliai",
"collections": "Kolekcijos"
"collections": "Kolekcijos",
"downloadBridge": "Atsisiųsti Bridge"
},
"uploadBar": {
"uploading": "Įkeliama…",
@@ -188,7 +189,9 @@
"heading": "Pjaustyklė",
"description": "Jūsų pageidaujama pjaustyklė, naudojama modelio veiksmo „Atverti pjaustyklėje“, kad modelis būtų atidarytas tiesiogiai per tos pjaustyklės nuorodą (pvz. bambustudio://).",
"label": "Pjaustyklė",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą."
"failed": "Nepavyko išsaugoti -- bandykite dar kartą.",
"bridgeRequiredPrefix": "Bambu Studio failams atverti iš čia reikalinga įdiegta PrintStash Bridge pagalbinė programa --",
"bridgeRequiredLink": "atsisiųskite ją atsisiuntimo puslapyje."
}
},
"addMenu": {
@@ -412,6 +415,28 @@
"collection_deleted": "Kolekcija ištrinta"
}
}
},
"download": {
"pageTitle": "PrintStash Bridge",
"intro": "Nedidelė pagalbinė programa, veikianti jūsų kompiuteryje, kad „Atverti pjaustyklėje“ galėtų perduoti modelį tiesiai jūsų pjaustyklei.",
"why": "Bambu Studio nuosava bambustudio:// nuoroda priima failus tik iš trumpo, Bambu Lab kontroliuojamo domenų sąrašo, o savarankiškai talpinamas PrintStash niekada jame nepasirodys -- todėl „Atverti Bambu Studio“ paprasčiausiai nieko nedarytų. Vietoje to Bridge klausosi savo print-stash:// nuorodos, pats atsisiunčia failą ir perduoda jį Bambu Studio vietoje. OrcaSlicer ir PrusaSlicer nuorodas priima tiesiogiai, todėl jiems Bridge nereikalingas.",
"downloadHeading": "Atsisiųsti",
"install": {
"windows": "Vieną kartą paleiskite atsisiųstą .exe failą (užtenka dvigubo paspaudimo) -- jis pats įsidiegs ir užregistruos protokolą.",
"macos": "Suteikite leidimą vykdyti (chmod +x), pašalinkite Gatekeeper karantino žymą (xattr -d com.apple.quarantine), tada vieną kartą paleiskite su --install.",
"linux": "Pažymėkite kaip vykdomą (chmod +x) ir vieną kartą paleiskite iš terminalo."
},
"footerLinks": "Reikia tikslių komandų arba norite susikompiliuoti iš išeitinio kodo? Žr. <releases>Bridge leidimų</releases> puslapį.",
"autostart": {
"heading": "Automatinis paleidimas paleidžiant sistemą (nebūtina)",
"windowsLinuxNote": "„Windows“ ar „Linux“ tai nereikalinga -- kiekvieną kartą paspaudus „Atverti Bambu Studio“, operacinė sistema paleidžia Bridge iš naujo, jau su nuorodos URL. Nėra jokio fone veikiančio proceso, kurį reikėtų palaikyti.",
"macosIntro": "Tas pats galioja ir „macOS“ -- ji paleidžia programą pagal poreikį net ir po perkrovimo. Pridėjus ją kaip prisijungimo elementą, tiesiog pirmas paspaudimas po sistemos paleidimo įvyks akimirksniu, o ne po maždaug sekundės, kol programa pasileis.",
"macosGuiHeading": "Paprasčiausia: sistemos nustatymai",
"macosGuiSteps": "System Settings -> General -> Login Items & Extensions -> spauskite + ir pridėkite „PrintStash Bridge“ iš ~/Applications.",
"macosLaunchAgentHeading": "Arba LaunchAgent (naudojantiems terminalą)",
"macosLaunchAgentIntro": "Išsaugokite tai kaip ~/Library/LaunchAgents/com.printstash.bridge.plist (pakeiskite YOUR_USERNAME savo naudotojo vardu):",
"macosLaunchAgentLoad": "Tada įkelkite jį:"
}
}
},
"common": {
+144
View File
@@ -0,0 +1,144 @@
import { useTranslation, Trans } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import DownloadIcon from "@mui/icons-material/Download";
import LaptopWindowsIcon from "@mui/icons-material/LaptopWindows";
import AppleIcon from "@mui/icons-material/Apple";
import TerminalIcon from "@mui/icons-material/Terminal";
import CableIcon from "@mui/icons-material/Cable";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { BRIDGE_DOWNLOADS, BRIDGE_RELEASES_PAGE, bridgeDownloadUrl } from "../../constants/bridge";
const OS_ICON = { windows: LaptopWindowsIcon, macos: AppleIcon, linux: TerminalIcon };
// launchd doesn't expand `~`, so the plist needs an absolute path -- the user has to swap in
// their own username either way, since we can't know it here.
const MACOS_LAUNCH_AGENT_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.printstash.bridge</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USERNAME/Applications/PrintStash Bridge.app/Contents/MacOS/print-stash-bridge</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>`;
const MACOS_LAUNCH_AGENT_LOAD_CMD = `mkdir -p ~/Library/LaunchAgents
# save the file above as ~/Library/LaunchAgents/com.printstash.bridge.plist
launchctl load ~/Library/LaunchAgents/com.printstash.bridge.plist`;
function CodeBlock({ children }: { children: string }) {
return (
<Box
component="pre"
sx={{
fontFamily: "monospace",
fontSize: 12,
bgcolor: "action.hover",
p: 1.5,
borderRadius: 1,
overflowX: "auto",
m: 0,
}}
>
{children}
</Box>
);
}
export default function DownloadPage() {
const { t } = useTranslation(["app", "common"]);
usePageHeader({ title: t("sidebar.downloadBridge") });
return (
<Stack spacing={3} sx={{ maxWidth: 720 }}>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<CableIcon color="primary" sx={{ mt: 0.5 }} />
<Box>
<Typography variant="h6" fontWeight={600}>{t("download.pageTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("download.intro")}</Typography>
</Box>
</Stack>
<Alert severity="info" variant="outlined">
<Typography variant="body2">{t("download.why")}</Typography>
</Alert>
<Stack spacing={2}>
<Typography variant="subtitle1" fontWeight={600}>{t("download.downloadHeading")}</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
{BRIDGE_DOWNLOADS.map(({ os, label, asset }) => {
const Icon = OS_ICON[os];
return (
<Paper key={os} variant="outlined" sx={{ p: 2.5, flex: 1 }}>
<Stack spacing={1.5} alignItems="flex-start">
<Icon fontSize="large" />
<Typography variant="subtitle2" fontWeight={600}>{label}</Typography>
<Typography variant="caption" color="text.secondary">
{t(`download.install.${os}`)}
</Typography>
<Button
component="a"
href={bridgeDownloadUrl(asset)}
variant="outlined"
size="small"
startIcon={<DownloadIcon fontSize="small" />}
>
{t("common:download")}
</Button>
</Stack>
</Paper>
);
})}
</Stack>
</Stack>
<Stack spacing={1.5}>
<Typography variant="subtitle1" fontWeight={600}>{t("download.autostart.heading")}</Typography>
<Typography variant="body2" color="text.secondary">{t("download.autostart.windowsLinuxNote")}</Typography>
<Typography variant="body2" color="text.secondary">{t("download.autostart.macosIntro")}</Typography>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{t("download.autostart.macosGuiHeading")}
</Typography>
<Typography variant="body2" color="text.secondary">{t("download.autostart.macosGuiSteps")}</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{t("download.autostart.macosLaunchAgentHeading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t("download.autostart.macosLaunchAgentIntro")}
</Typography>
<CodeBlock>{MACOS_LAUNCH_AGENT_PLIST}</CodeBlock>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5, mb: 1 }}>
{t("download.autostart.macosLaunchAgentLoad")}
</Typography>
<CodeBlock>{MACOS_LAUNCH_AGENT_LOAD_CMD}</CodeBlock>
</Paper>
</Stack>
<Typography variant="caption" color="text.secondary">
<Trans
t={t}
i18nKey="download.footerLinks"
components={{
releases: <Link href={BRIDGE_RELEASES_PAGE} target="_blank" rel="noopener noreferrer" />,
}}
/>
</Typography>
</Stack>
);
}
@@ -134,7 +134,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
const openInSlicerHref = slicerOption && print.slicer_url
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url))
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
: undefined;
return (
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
@@ -9,6 +10,7 @@ import InputLabel from "@mui/material/InputLabel";
import Select, { type SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
@@ -86,6 +88,12 @@ export default function SlicerPicker({ onUnauthorized }: Props) {
</FormControl>
{saving && <CircularProgress size={16} />}
</Stack>
{value === "bambustudio" && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
{t("profile.slicer.bridgeRequiredPrefix")}{" "}
<Link component={RouterLink} to="/download">{t("profile.slicer.bridgeRequiredLink")}</Link>
</Typography>
)}
{status && <Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>{status}</Typography>}
</Paper>
</Box>
+21 -4
View File
@@ -1,6 +1,23 @@
// Builds the URL for a slicer's own registered protocol handler (e.g. bambustudio://), which is
// how "Open in {Slicer}" hands a remote model file to the desktop app. Only meaningful for the
// SLICER_OPTIONS ids that register one -- never call this for "other".
export function slicerLaunchUrl(slicerId: string, fileUrl: string): string {
// The PrintStash Bridge helper app's own protocol (see bridge/README.md) -- it downloads the
// file itself and hands it to the local slicer, for slicers whose own URL handler won't take it
// directly.
const BRIDGE_SCHEME = "print-stash";
// Bambu Studio's bambustudio:// handler only opens files served from an allowlist of domains it
// trusts (Bambu Lab's own storefronts) -- a self-hosted PrintStash instance is never on it, so
// the direct protocol silently no-ops. Routed through the Bridge instead. OrcaSlicer and
// PrusaSlicer accept any HTTP(S) URL through their own handler, so they bypass the Bridge.
const BRIDGED_SLICERS = new Set(["bambustudio"]);
// Builds the URL that "Open in {Slicer}" navigates to: either the slicer's own registered
// protocol handler (e.g. orcaslicer://), or -- for a BRIDGED_SLICERS id -- the PrintStash Bridge
// protocol, which downloads fileUrl locally before handing it to the slicer. Only meaningful for
// the SLICER_OPTIONS ids that register a protocol; never call this for "other".
export function slicerLaunchUrl(slicerId: string, fileUrl: string, filename?: string): string {
if (BRIDGED_SLICERS.has(slicerId)) {
const params = new URLSearchParams({ url: fileUrl, slicer: slicerId });
if (filename) params.set("filename", filename);
return `${BRIDGE_SCHEME}://open?${params.toString()}`;
}
return `${slicerId}://open?file=${encodeURIComponent(fileUrl)}`;
}