feat: add Anycubic Slicer Next, Elegoo Slicer and Snapmaker Orca slicer options

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 20:31:54 +02:00
1 parent 6a6a87e38d
commit 9dd3e693e0
6 files changed
+109 -6

No files matched your search

@@ -1,10 +1,20 @@
import { prisma } from "../db";
// 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.
// (e.g. bambustudio://); some (bambustudio, prusaslicer, cura, anycubicslicernext) instead route
// through the Thingport Bridge helper -- see frontend's utils/slicerLaunch.ts BRIDGED_SLICERS.
// Anything else falls under "other".
export const SLICER_IDS = ["bambustudio", "orcaslicer", "prusaslicer", "cura", "crealityprintlink", "other"] as const;
export const SLICER_IDS = [
"bambustudio",
"orcaslicer",
"prusaslicer",
"cura",
"crealityprintlink",
"anycubicslicernext",
"elegooslicer",
"snapmaker-orca",
"other",
] as const;
export type SlicerId = (typeof SLICER_IDS)[number];
function isSlicerId(value: string): value is SlicerId {
+13
View File
@@ -11,6 +11,9 @@ handlers won't take a link straight from a self-hosted Thingport instance:
- 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.
- Anycubic Slicer Next is an OrcaSlicer fork that handles the same links, but it registers
OrcaSlicer's own `orcaslicer://` protocol instead of one of its own -- with both installed, only
one of them gets those links. The Bridge launches it by path, so the slicer you picked opens.
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.
@@ -169,12 +172,22 @@ Environment override (per slicer): `THINGPORT_SLICER_BAMBUSTUDIO=/path/to/BambuS
value and `config.json`/env-var override key)
- prusaslicer
- cura
- anycubicslicernext (Anycubic Slicer Next -- an OrcaSlicer fork that registers OrcaSlicer's own
`orcaslicer://` protocol, so it's launched by path to avoid opening OrcaSlicer instead when both
are installed)
- 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`.
Anycubic Slicer Next is looked for as `AnycubicSlicerNext.app` or `Anycubic Slicer Next.app` in
`/Applications` or `~/Applications` on macOS, under Program Files / AppData on Windows, and on `PATH`
on Linux. If yours is elsewhere (e.g. an AppImage), add an `anycubicslicernext` entry to
`config.json` or set `THINGPORT_SLICER_ANYCUBICSLICERNEXT=/path/to/it`.
On macOS, apps are looked for in both `/Applications` and your own `~/Applications`.
## Testing
After install, paste this into your browser's address bar (replace the URL):
+21 -1
View File
@@ -340,11 +340,19 @@ func findLinuxCommand(id string, candidates map[string][]string) string {
return ""
}
// findMacCommand checks each candidate app bundle in /Applications and then in the user's own
// ~/Applications (a common drag-to-install target that needs no admin rights).
func findMacCommand(id string, candidates map[string][]string) string {
home, _ := os.UserHomeDir()
for _, appPath := range candidates[id] {
if fileExists(appPath) {
return appPath
}
if home != "" && strings.HasPrefix(appPath, "/Applications/") {
if userApp := filepath.Join(home, appPath); fileExists(userApp) {
return userApp
}
}
}
return ""
}
@@ -362,6 +370,15 @@ func windowsCandidates() map[string][]string {
"Ultimaker Cura */UltiMaker-Cura.exe",
"Ultimaker Cura */Cura.exe",
},
// Anycubic's fork of OrcaSlicer. Its own orcaslicer:// handler would work too, but it
// shares that protocol with OrcaSlicer itself (whichever registered last wins), so it's
// launched by path instead. The install folder's exact name isn't documented, hence the
// variants and the trailing glob.
"anycubicslicernext": {
"AnycubicSlicerNext\\AnycubicSlicerNext.exe",
"Anycubic Slicer Next\\AnycubicSlicerNext.exe",
"Anycubic*/AnycubicSlicerNext.exe", // "/" in globs, like Cura's: Windows accepts it too
},
}
}
@@ -374,6 +391,8 @@ func linuxCandidates() map[string][]string {
// 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"},
// Same PATH-only caveat as Cura: an AppImage needs a config.json entry instead.
"anycubicslicernext": {"AnycubicSlicerNext", "anycubicslicernext", "anycubic-slicer-next"},
}
}
@@ -383,7 +402,8 @@ func macCandidates() map[string][]string {
"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"},
"cura": {"/Applications/UltiMaker Cura.app", "/Applications/Ultimaker Cura.app"},
"anycubicslicernext": {"/Applications/AnycubicSlicerNext.app", "/Applications/Anycubic Slicer Next.app"},
}
}
+34
View File
@@ -59,6 +59,40 @@ func TestFindWindowsCommandResolvesVersionedCuraInstall(t *testing.T) {
}
}
func TestFindWindowsCommandResolvesAnycubicSlicerNext(t *testing.T) {
base := t.TempDir()
t.Setenv("ProgramFiles", base)
t.Setenv("ProgramFiles(x86)", "")
t.Setenv("LOCALAPPDATA", "")
// The folder name isn't documented, so an unexpected one must still be found by the glob.
dir := filepath.Join(base, "Anycubic Slicer Next 1.3")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
exePath := filepath.Join(dir, "AnycubicSlicerNext.exe")
if err := os.WriteFile(exePath, nil, 0644); err != nil {
t.Fatalf("write exe: %v", err)
}
if got := findWindowsCommand("anycubicslicernext", windowsCandidates()); got != exePath {
t.Fatalf("findWindowsCommand(\"anycubicslicernext\", ...) = %q, expected %q", got, exePath)
}
}
func TestFindMacCommandChecksUserApplications(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
appPath := filepath.Join(home, "Applications", "AnycubicSlicerNext.app")
if err := os.MkdirAll(appPath, 0755); err != nil {
t.Fatalf("mkdir app: %v", err)
}
candidates := map[string][]string{"anycubicslicernext": {"/Applications/AnycubicSlicerNext.app"}}
if got := findMacCommand("anycubicslicernext", candidates); got != appPath {
t.Fatalf("findMacCommand(...) = %q, expected %q", got, appPath)
}
}
func TestHandleProtocolRejectsUntrustedShapesBeforeDownload(t *testing.T) {
tests := []string{
"https://thingport.example/file",
@@ -12,6 +12,9 @@ export const SLICER_OPTIONS: SlicerOption[] = [
{ id: "prusaslicer", label: "PrusaSlicer" },
{ id: "cura", label: "Cura" },
{ id: "crealityprintlink", label: "Creality Print" },
{ id: "anycubicslicernext", label: "Anycubic Slicer Next" },
{ id: "elegooslicer", label: "Elegoo Slicer" },
{ id: "snapmaker-orca", label: "Snapmaker Orca" },
{ id: "other", label: "Other / Manual" },
];
+25 -2
View File
@@ -12,7 +12,11 @@ const BRIDGE_SCHEME = "thingport";
// 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"]);
// Anycubic Slicer Next is an OrcaSlicer fork and understands the same links -- but it registers
// OrcaSlicer's own orcaslicer:// protocol rather than one of its own, so with both installed only
// one of them gets those links. Routing it through the Bridge, which launches it by path, opens
// the one the user actually picked.
const BRIDGED_SLICERS = new Set(["bambustudio", "prusaslicer", "cura", "anycubicslicernext"]);
/** 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. */
@@ -20,6 +24,24 @@ export function isBridgedSlicer(slicerId: string): boolean {
return BRIDGED_SLICERS.has(slicerId);
}
// Elegoo Slicer (elegooslicer://) and Snapmaker Orca (snapmaker-orca://) are OrcaSlicer forks
// that, unlike Anycubic's, register a protocol of their own and accept any HTTP(S) URL through
// it, so they launch directly like OrcaSlicer. Both name the downloaded file after the URL's last
// path segment, which a prepared print's URL (.../prepared-print) doesn't end in -- so each is
// also passed the filename the way its handler reads it: Elegoo from a filename= query parameter
// on the file URL itself, Snapmaker Orca from a name= parameter after the file= one.
function withFilenameHint(slicerId: string, fileUrl: string, filename: string): { fileUrl: string; extra: string } {
if (slicerId === "elegooslicer") {
const url = new URL(fileUrl, window.location.origin);
url.searchParams.set("filename", filename);
return { fileUrl: url.toString(), extra: "" };
}
if (slicerId === "snapmaker-orca") {
return { fileUrl, extra: `&name=${encodeURIComponent(filename)}` };
}
return { fileUrl, extra: "" };
}
// 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
// protocol, which downloads fileUrl locally before handing it to the slicer. Only meaningful for
@@ -30,5 +52,6 @@ export function slicerLaunchUrl(slicerId: string, fileUrl: string, filename?: st
if (filename) params.set("filename", filename);
return `${BRIDGE_SCHEME}://open?${params.toString()}`;
}
return `${slicerId}://open?file=${encodeURIComponent(fileUrl)}`;
const hinted = filename ? withFilenameHint(slicerId, fileUrl, filename) : { fileUrl, extra: "" };
return `${slicerId}://open?file=${encodeURIComponent(hinted.fileUrl)}${hinted.extra}`;
}