diff --git a/backend/src/services/slicerPreferenceService.ts b/backend/src/services/slicerPreferenceService.ts index 8d86d2c..ebee6c3 100644 --- a/backend/src/services/slicerPreferenceService.ts +++ b/backend/src/services/slicerPreferenceService.ts @@ -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 { diff --git a/bridge/README.md b/bridge/README.md index 17027da..c379fde 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -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=&slicer=&filename=` @@ -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): diff --git a/bridge/cmd/thingport-bridge/main.go b/bridge/cmd/thingport-bridge/main.go index b2fa403..e22fd88 100644 --- a/bridge/cmd/thingport-bridge/main.go +++ b/bridge/cmd/thingport-bridge/main.go @@ -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"}, } } diff --git a/bridge/cmd/thingport-bridge/main_test.go b/bridge/cmd/thingport-bridge/main_test.go index 256d340..c572cfa 100644 --- a/bridge/cmd/thingport-bridge/main_test.go +++ b/bridge/cmd/thingport-bridge/main_test.go @@ -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", diff --git a/bridge/config.example.json b/bridge/config.example.json index c9adf21..106614e 100644 --- a/bridge/config.example.json +++ b/bridge/config.example.json @@ -8,6 +8,12 @@ "orcaslicer": { "command": "/usr/bin/orca-slicer", "args": [] + }, + "prusaslicer": { + "command": "/usr/bin/prusa-slicer" + }, + "cura": { + "command": "/usr/bin/cura" } } } diff --git a/bridge/thingport-bridge b/bridge/thingport-bridge new file mode 100755 index 0000000..2ed336b Binary files /dev/null and b/bridge/thingport-bridge differ diff --git a/frontend/src/constants/settingsOptions.ts b/frontend/src/constants/settingsOptions.ts index 392d65a..0d83b03 100644 --- a/frontend/src/constants/settingsOptions.ts +++ b/frontend/src/constants/settingsOptions.ts @@ -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" }, ]; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7827e50..4314da7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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." } }, diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 3bf76de..e38b6cc 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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." } }, diff --git a/frontend/src/pages/ProfilePage/SlicerPicker.tsx b/frontend/src/pages/ProfilePage/SlicerPicker.tsx index 027137e..f3c0eae 100644 --- a/frontend/src/pages/ProfilePage/SlicerPicker.tsx +++ b/frontend/src/pages/ProfilePage/SlicerPicker.tsx @@ -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) { {saving && } - {value === "bambustudio" && ( + {isBridgedSlicer(value) && ( - {t("profile.slicer.bridgeRequiredPrefix")}{" "} + {t("profile.slicer.bridgeRequiredPrefix", { + slicer: SLICER_OPTIONS.find(opt => opt.id === value)?.label ?? value, + })}{" "} {t("profile.slicer.bridgeRequiredLink")} )} diff --git a/frontend/src/utils/slicerLaunch.ts b/frontend/src/utils/slicerLaunch.ts index 8204f92..8ee46d5 100644 --- a/frontend/src/utils/slicerLaunch.ts +++ b/frontend/src/utils/slicerLaunch.ts @@ -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