mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: allow open in Cura slicer
This commit is contained in:
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
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"orcaslicer": {
|
||||
"command": "/usr/bin/orca-slicer",
|
||||
"args": []
|
||||
},
|
||||
"prusaslicer": {
|
||||
"command": "/usr/bin/prusa-slicer"
|
||||
},
|
||||
"cura": {
|
||||
"command": "/usr/bin/cura"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user