feat: allow open in Cura slicer

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-11 12:54:56 +02:00
1 parent 84326ee670
commit e84fbca5f9
11 files changed
+118 -25

No files matched your search

@@ -1,9 +1,10 @@
import { prisma } from "../db";
// Slicers that register their own URL protocol for opening a remote model directly (e.g.
// bambustudio://) -- the only ones worth offering here, since the whole point of this preference
// is a future "open in {slicer}" launch via that protocol. Anything else falls under "other".
export const SLICER_IDS = ["bambustudio", "orcaslicer", "prusaslicer", "crealityprintlink", "other"] as const;
// Slicers offered for a future "open in {slicer}" launch. Most register their own URL protocol
// (e.g. bambustudio://); a couple (bambustudio, prusaslicer, cura) instead route through the
// Thingport Bridge helper -- see frontend's utils/slicerLaunch.ts BRIDGED_SLICERS for why.
// Anything else falls under "other".
export const SLICER_IDS = ["bambustudio", "orcaslicer", "prusaslicer", "cura", "crealityprintlink", "other"] as const;
export type SlicerId = (typeof SLICER_IDS)[number];
function isSlicerId(value: string): value is SlicerId {
+20 -7
View File
@@ -1,12 +1,19 @@
# Thingport Bridge
This helper registers the custom protocol `thingport://`, downloads the requested file, and
launches it in the slicer Thingport 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 Thingport 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.
launches it in the slicer Thingport asked for. It exists because some slicers' own URL protocol
handlers won't take a link straight from a self-hosted Thingport instance:
- Bambu Studio's `bambustudio://` and PrusaSlicer's `prusaslicer://` both only accept links from a
domain allowlist (Bambu Lab's own storefronts; printables.com plus a handful of sites Prusa has
manually whitelisted, respectively) that a self-hosted instance is never on, so "Open in
{Slicer}" would otherwise silently do nothing.
- Cura's `cura://open` handler isn't domain-restricted, but its OS protocol registration is
unreliable across its AppImage/Flatpak/Microsoft Store builds, so it's routed the same way for
consistency.
OrcaSlicer's protocol handler accepts any HTTP(S) URL directly with no such restriction, so it's
launched straight from the browser and never needs this bridge.
Protocol format:
`thingport://open?url=<download-url>&slicer=<id>&filename=<name>`
@@ -158,10 +165,16 @@ Environment override (per slicer): `THINGPORT_SLICER_BAMBUSTUDIO=/path/to/BambuS
## Slicer IDs
- bambustudio
- orcaslicer
- orcaslicer (not actually routed through the Bridge -- see above -- but still a valid `slicer=`
value and `config.json`/env-var override key)
- prusaslicer
- cura
- other (opens with the OS default app)
Cura's AppImage and Flatpak builds aren't on `PATH` and won't be auto-detected. If "Open in Cura"
falls back to opening with your OS default app, add a `cura` entry to `config.json` (see
`config.example.json`) or set `THINGPORT_SLICER_CURA=/path/to/cura`.
## Testing
After install, paste this into your browser's address bar (replace the URL):
+32 -4
View File
@@ -13,6 +13,7 @@ import (
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
@@ -28,10 +29,13 @@ type Config struct {
Slicers map[string]SlicerConfig `json:"slicers,omitempty"`
}
// scheme is only for protocols Thingport 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
// thingport'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.
// scheme is only for protocols Thingport can't hand off directly to a slicer's own URL handler:
// Bambu Studio's bambustudio:// and PrusaSlicer's prusaslicer:// both only accept links from a
// domain allowlist (Bambu Lab's storefronts; printables.com plus sites Prusa has manually
// whitelisted) that a self-hosted Thingport instance is never on, and Cura's cura://open only
// works when its OS protocol registration actually took (unreliable across its
// AppImage/Flatpak/Microsoft Store builds). OrcaSlicer accepts any HTTP(S) URL through its own
// protocol, so it's launched straight from the browser and never reaches here.
const scheme = "thingport"
func main() {
@@ -309,6 +313,16 @@ func findWindowsCommand(id string, candidates map[string][]string) string {
}
for _, base := range bases {
full := filepath.Join(base, rel)
// Cura's install directory embeds its version (e.g. "UltiMaker Cura 5.8"), so its
// candidate is a glob pattern rather than a fixed path -- everyone else's is a plain
// path and fileExists() is enough.
if strings.Contains(rel, "*") {
if matches, err := filepath.Glob(full); err == nil && len(matches) > 0 {
sort.Strings(matches)
return matches[len(matches)-1]
}
continue
}
if fileExists(full) {
return full
}
@@ -340,6 +354,14 @@ func windowsCandidates() map[string][]string {
"bambustudio": {"Bambu Studio\\BambuStudio.exe"},
"orcaslicer": {"OrcaSlicer\\OrcaSlicer.exe"},
"prusaslicer": {"PrusaSlicer\\PrusaSlicer.exe"},
// UltiMaker/Ultimaker Cura's install dir embeds its version number, so these are globs
// (see findWindowsCommand) rather than fixed paths; the rebrand from "Ultimaker" to
// "UltiMaker" happened around 5.7, so both spellings are worth trying.
"cura": {
"UltiMaker Cura */UltiMaker-Cura.exe",
"Ultimaker Cura */UltiMaker-Cura.exe",
"Ultimaker Cura */Cura.exe",
},
}
}
@@ -348,6 +370,10 @@ func linuxCandidates() map[string][]string {
"bambustudio": {"bambu-studio", "BambuStudio"},
"orcaslicer": {"orca-slicer", "OrcaSlicer"},
"prusaslicer": {"prusa-slicer", "PrusaSlicer"},
// Only catches a PATH-visible install (e.g. via snap, which symlinks into /snap/bin) --
// AppImage and Flatpak builds aren't discoverable this way and need a config.json entry
// or THINGPORT_SLICER_CURA override instead (see bridge/README.md).
"cura": {"cura", "UltiMaker-Cura"},
}
}
@@ -356,6 +382,8 @@ func macCandidates() map[string][]string {
"bambustudio": {"/Applications/BambuStudio.app"},
"orcaslicer": {"/Applications/OrcaSlicer.app"},
"prusaslicer": {"/Applications/PrusaSlicer.app", "/Applications/Original Prusa Drivers/PrusaSlicer.app"},
// Rebranded from "Ultimaker Cura" to "UltiMaker Cura" around version 5.7.
"cura": {"/Applications/UltiMaker Cura.app", "/Applications/Ultimaker Cura.app"},
}
}
+29
View File
@@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -30,6 +32,33 @@ func TestRedactURLHidesTokenQueryValue(t *testing.T) {
}
}
func TestFindWindowsCommandResolvesVersionedCuraInstall(t *testing.T) {
base := t.TempDir()
t.Setenv("LOCALAPPDATA", base)
t.Setenv("ProgramFiles", "")
t.Setenv("ProgramFiles(x86)", "")
// Cura's install dir embeds its version, so only a glob (see findWindowsCommand) resolves it.
// The older version's dir has no exe in it, to confirm an empty match is skipped rather than
// returned as a false positive.
older := filepath.Join(base, "UltiMaker Cura 5.7")
newer := filepath.Join(base, "UltiMaker Cura 5.8")
if err := os.MkdirAll(older, 0755); err != nil {
t.Fatalf("mkdir older: %v", err)
}
if err := os.MkdirAll(newer, 0755); err != nil {
t.Fatalf("mkdir newer: %v", err)
}
exePath := filepath.Join(newer, "UltiMaker-Cura.exe")
if err := os.WriteFile(exePath, nil, 0644); err != nil {
t.Fatalf("write exe: %v", err)
}
if got := findWindowsCommand("cura", windowsCandidates()); got != exePath {
t.Fatalf("findWindowsCommand(\"cura\", ...) = %q, expected %q", got, exePath)
}
}
func TestHandleProtocolRejectsUntrustedShapesBeforeDownload(t *testing.T) {
tests := []string{
"https://thingport.example/file",
+6
View File
@@ -8,6 +8,12 @@
"orcaslicer": {
"command": "/usr/bin/orca-slicer",
"args": []
},
"prusaslicer": {
"command": "/usr/bin/prusa-slicer"
},
"cura": {
"command": "/usr/bin/cura"
}
}
}
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -4,11 +4,13 @@ export type SlicerOption = { id: string; label: string };
// (e.g. bambustudio://) -- the only ones a future "open in {slicer}" launch could actually use.
// IDs must match backend's services/slicerPreferenceService.ts SLICER_IDS, and (id !== "other")
// must equal the exact scheme the slicer registers -- e.g. Creality Print's is "crealityprintlink"
// (crealityprintlink://open?file=...), not "creality", per its libslic3r/Utils.hpp.
// (crealityprintlink://open?file=...), not "creality", per its libslic3r/Utils.hpp. Some of these
// ids don't route through their own scheme at all -- see utils/slicerLaunch.ts's BRIDGED_SLICERS.
export const SLICER_OPTIONS: SlicerOption[] = [
{ id: "bambustudio", label: "Bambu Studio" },
{ id: "orcaslicer", label: "OrcaSlicer" },
{ id: "prusaslicer", label: "PrusaSlicer" },
{ id: "cura", label: "Cura" },
{ id: "crealityprintlink", label: "Creality Print" },
{ id: "other", label: "Other / Manual" },
];
+1 -1
View File
@@ -190,7 +190,7 @@
"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.",
"bridgeRequiredPrefix": "Bambu Studio needs the Thingport Bridge helper app installed to open files from here --",
"bridgeRequiredPrefix": "{{slicer}} needs the Thingport Bridge helper app installed to open files from here --",
"bridgeRequiredLink": "get it from the Download page."
}
},
+1 -1
View File
@@ -190,7 +190,7 @@
"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ą.",
"bridgeRequiredPrefix": "Bambu Studio failams atverti iš čia reikalinga įdiegta Thingport Bridge pagalbinė programa --",
"bridgeRequiredPrefix": "{{slicer}} failams atverti iš čia reikalinga įdiegta Thingport Bridge pagalbinė programa --",
"bridgeRequiredLink": "atsisiųskite ją atsisiuntimo puslapyje."
}
},
@@ -15,6 +15,7 @@ import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import { setCachedSlicerPreference } from "../../hooks/useSlicerPreference";
import { isBridgedSlicer } from "../../utils/slicerLaunch";
type Props = {
onUnauthorized?: () => void;
@@ -88,9 +89,11 @@ export default function SlicerPicker({ onUnauthorized }: Props) {
</FormControl>
{saving && <CircularProgress size={16} />}
</Stack>
{value === "bambustudio" && (
{isBridgedSlicer(value) && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
{t("profile.slicer.bridgeRequiredPrefix")}{" "}
{t("profile.slicer.bridgeRequiredPrefix", {
slicer: SLICER_OPTIONS.find(opt => opt.id === value)?.label ?? value,
})}{" "}
<Link component={RouterLink} to="/downloads">{t("profile.slicer.bridgeRequiredLink")}</Link>
</Typography>
)}
+16 -5
View File
@@ -3,11 +3,22 @@
// directly.
const BRIDGE_SCHEME = "thingport";
// Bambu Studio's bambustudio:// handler only opens files served from an allowlist of domains it
// trusts (Bambu Lab's own storefronts) -- a self-hosted Thingport 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"]);
// Bambu Studio's bambustudio:// and PrusaSlicer's prusaslicer:// handlers both only open files
// served from a domain allowlist (Bambu Lab's own storefronts; printables.com plus a handful of
// sites Prusa has manually whitelisted, respectively) -- a self-hosted Thingport instance is
// never on either list, so the direct protocol silently no-ops. Cura does register cura://open,
// but only when it was installed in a way that actually wires up OS protocol handling (not
// reliable across its AppImage/Flatpak/Microsoft Store builds), so it's routed the same way for
// consistency. All three are handed to the Bridge instead, which downloads the file itself and
// execs the local slicer directly, bypassing both the domain check and protocol registration.
// OrcaSlicer accepts any HTTP(S) URL through its own handler, so it still bypasses the Bridge.
const BRIDGED_SLICERS = new Set(["bambustudio", "prusaslicer", "cura"]);
/** Whether slicerId launches through the Thingport Bridge helper app rather than the slicer's
* own URL protocol -- used to show the "install the Bridge" hint for the right slicers. */
export function isBridgedSlicer(slicerId: string): boolean {
return BRIDGED_SLICERS.has(slicerId);
}
// 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 Thingport Bridge