mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add slicer bridge
This commit is contained in:
1 parent
4d61c902e6
commit
96e41e3b2d
25 files changed
+1508
-11
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module printstash/bridge
|
||||
|
||||
go 1.21
|
||||
Executable
+34
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user