fix(extension): send devmodelname and match makerworld button design

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-30 20:26:15 +02:00
1 parent 2380c566e5
commit 955513c5c5
5 files changed
+187 -59

No files matched your search

+4 -4
View File
@@ -107,8 +107,8 @@ As a developer-mode "unpacked" extension, until the Chrome Web Store listing is
MakerWorld's files are Bambu Studio projects, which PrusaSlicer, Cura, Anycubic Slicer Next,
Creality Print, Elegoo Slicer and Snapmaker Orca often open without their colors or print settings.
On a MakerWorld model page, the extension adds a **Download normalized** button next to MakerWorld's
own Download button. Clicking it downloads the selected print profile's file, converts it, and saves
On a MakerWorld model page, the extension adds a **Download normalized** button under MakerWorld's
own "Open in Bambu Studio" (or Download) button. Clicking it downloads the selected print profile's file, converts it, and saves
a `<name>-normalized.3mf` that keeps painted colors, filament colors, plates and the designer's
settings (walls, layer height, infill, supports). Multi-part objects may be merged into one,
per-object overrides and layer color changes are dropped, and you pick your own printer profile.
@@ -120,8 +120,8 @@ Hover the bulb on the button for the same summary.
- **Where it runs:** entirely in your browser, the same conversion Thingport's "Open normalized"
does on the server. The file goes nowhere but your downloads folder.
- **Limits:** you need to be signed in to MakerWorld, as for its own Download button, and files over
50 MB are too big to convert in the browser -- the button says so. It's found by its label, so it
only appears while MakerWorld is in English.
50 MB are too big to convert in the browser -- the button says so. MakerWorld's button is found by
its label, so it only appears while MakerWorld is in English.
## Setup
@@ -5,11 +5,25 @@
import type { ResolvedDownload } from "../../shared/messages";
import { send } from "../../shared/messages";
import { parseMakerworldModelUrl } from "../../shared/urls";
import { makerworldDesignForImport, pickMakerworldInstanceId, readMakerworldDesignForPage } from "./pageData";
import {
loadMakerworldDesignForPage,
makerworldDesignForImport,
pickMakerworldInstanceId,
readMakerworldDesignForPage,
type MakerworldDesign,
} from "./pageData";
// fetch() has no timeout, and some endpoints hold an interactive challenge open indefinitely.
const MAKERWORLD_API_FETCH_TIMEOUT_MS = 8000;
// What MakerWorld's own web client sends with its API calls; it answers 403 without them.
const MAKERWORLD_CLIENT_HEADERS = {
"X-BBL-Client-Name": "MakerWorld",
"X-BBL-Client-Type": "web",
"X-BBL-Client-Version": "00.00.00.01",
"X-BBL-App-Source": "makerworld",
};
/** {url|downloadUrl|download_url}, optionally nested under `data`. */
function extractDownloadUrl(data: unknown): string | null {
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
@@ -29,7 +43,7 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAKERWORLD_API_FETCH_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Accept: "application/json" };
const headers: Record<string, string> = { Accept: "application/json", ...MAKERWORLD_CLIENT_HEADERS };
if (nonce) headers["X-Nonce"] = nonce;
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) return null;
@@ -42,14 +56,11 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
}
/** Matched by text (class names are build hashes), visible elements only. */
export function isDownloadButton(el: Element): el is HTMLElement {
if (!/^download$/i.test((el.textContent || "").trim())) return false;
return (el as HTMLElement).offsetParent !== null; // hidden (display:none or detached)
}
export function findDownloadButton(): HTMLElement | null {
function findDownloadButton(): HTMLElement | null {
for (const el of document.querySelectorAll<HTMLElement>("button, a")) {
if (isDownloadButton(el)) return el;
if (!/^download$/i.test((el.textContent || "").trim())) continue;
if (el.offsetParent === null) continue; // hidden (display:none or detached)
return el;
}
return null;
}
@@ -66,19 +77,31 @@ async function captureViaRealClick(): Promise<string | null> {
return res && res.ok ? res.data : null;
}
async function fetchInstanceDownloadUrl(instanceId: string, nonce: string | null): Promise<string | null> {
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=`;
return extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, nonce));
/** The printer the profile was made for, which MakerWorld's own download request names. */
function instanceDevModelName(design: MakerworldDesign, instanceId: string): string {
const instance = design.instances?.find((inst) => inst && String(inst.id) === instanceId) as
Record<string, unknown> | undefined;
const extension = instance?.extention as { modelInfo?: { compatibility?: { devModelName?: unknown } } } | undefined;
const name = extension?.modelInfo?.compatibility?.devModelName;
return typeof name === "string" ? name : "";
}
/** Sent exactly as MakerWorld's own "Download 3MF" sends it. */
async function fetchInstanceDownloadUrl(design: MakerworldDesign, instanceId: string): Promise<string | null> {
const devModelName = encodeURIComponent(instanceDevModelName(design, instanceId));
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=${devModelName}`;
return extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, null));
}
/** Same two steps as the backend: instance-scoped endpoint, then model-scoped. */
async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | null> {
const page = readMakerworldDesignForPage(pageUrl);
// Fetched when the tab's own page data is stale, i.e. after clicking through MakerWorld.
const page = await loadMakerworldDesignForPage(pageUrl);
if (!page) return null; // the backend resolves from the URL
const { design, nonce } = page;
const instanceId = pickMakerworldInstanceId(design, page.requestedInstanceId);
if (instanceId) {
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, nonce);
const downloadUrl = await fetchInstanceDownloadUrl(design, instanceId);
if (downloadUrl) return { downloadUrl, instanceId, design: makerworldDesignForImport(design) };
}
// Model-level, i.e. the default profile's file.
@@ -111,8 +134,8 @@ export async function resolveMakerworldProfileDownload(
pageUrl: string,
instanceId: string,
): Promise<ResolvedDownload | null> {
const page = readMakerworldDesignForPage(pageUrl);
const page = await loadMakerworldDesignForPage(pageUrl);
if (!page) return null;
const downloadUrl = await fetchInstanceDownloadUrl(instanceId, page.nonce).catch(() => null);
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch(() => null);
return downloadUrl ? { downloadUrl, instanceId, design: makerworldDesignForImport(page.design) } : null;
}
@@ -1,11 +1,12 @@
// "Download normalized" next to MakerWorld's own Download button, for people whose slicer mis-reads
// "Download normalized" next to MakerWorld's own download/open button, for people whose slicer mis-reads
// Bambu Studio projects: not linked to a Thingport instance at all, or linked with such a slicer
// picked. The file is downloaded and converted in this tab; it never goes anywhere else.
import { createIcon } from "../../shared/icon";
import { NORMALIZE_3MF_SLICERS } from "../../shared/slicers";
import { api } from "../runtime";
import css from "../styles/normalized.scss?inline";
import { findDownloadButton, isDownloadButton, resolveMakerworldDownloadUrl } from "./downloadResolver";
import { resolveMakerworldDownloadUrl } from "./downloadResolver";
import { normalizeBambu3mf } from "./normalize";
// Converting holds the file, its unzipped model XML and the result in memory at once.
@@ -32,6 +33,7 @@ function bulbIcon(): Element {
let host: HTMLElement | null = null;
let observer: MutationObserver | null = null;
let themeObserver: MutationObserver | null = null;
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
function explanation(slicerLabel: string | null): string {
@@ -143,11 +145,13 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
const button = element("button", "tg-nd");
button.type = "button";
button.setAttribute("aria-describedby", "tg-nd-info");
const icon = element("span", "tg-nd__info");
icon.append(bulbIcon());
const logo = element("span", "tg-nd__logo");
logo.append(createIcon());
const label = element("span", "tg-nd__label");
label.textContent = LABEL;
button.append(icon, label);
const icon = element("span", "tg-nd__info");
icon.append(bulbIcon());
button.append(logo, label, icon);
const bubble = element("div", "tg-nd-bubble");
bubble.id = "tg-nd-info";
bubble.setAttribute("role", "tooltip");
@@ -163,7 +167,9 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
const rect = button.getBoundingClientRect();
const left = Math.min(Math.max(8, rect.left), window.innerWidth - bubble.offsetWidth - 8);
bubble.style.left = `${left}px`;
bubble.style.top = `${rect.bottom + 8}px`;
// Above when there's no room below, e.g. in MakerWorld's bottom-stuck action bar.
const below = rect.bottom + 8 + bubble.offsetHeight <= window.innerHeight;
bubble.style.top = `${below ? rect.bottom + 8 : Math.max(8, rect.top - 8 - bubble.offsetHeight)}px`;
if (error) bubbleTimer = setTimeout(hideBubble, ERROR_SHOW_MS);
};
const hideBubble = () => {
@@ -197,43 +203,100 @@ function buildHost(pageUrl: string, slicerLabel: string | null): HTMLElement {
return el;
}
/** Keeps the button right after MakerWorld's, which can render late or be re-rendered away. */
// MakerWorld's main action, matched by label (class names are build hashes): usually the "Open in
// Bambu Studio" split button, whose menu holds "Download 3MF" and "Download STL/CAD Files".
const ACTION_LABELS = [/^open in .+/i, /^download\b/i];
const MAX_LABEL_LENGTH = 40;
function findActionLabel(): HTMLElement | null {
const candidates = [...document.querySelectorAll<HTMLElement>("button, a, span, div")].filter((el) => {
if (el.childElementCount > 2 || el.offsetParent === null) return false;
const text = (el.textContent || "").trim();
return text.length <= MAX_LABEL_LENGTH && ACTION_LABELS.some((label) => label.test(text));
});
// Innermost, so wrappers repeating the same text don't win.
const leaves = candidates.filter((el) => !candidates.some((other) => other !== el && el.contains(other)));
for (const label of ACTION_LABELS) {
const match = leaves.find((el) => label.test((el.textContent || "").trim()));
if (match) return match;
}
return null;
}
/** The clickable element around the label, then the outermost wrapper that's still just the
* control (e.g. both halves of the split button). */
function controlBox(label: HTMLElement): HTMLElement {
let box = label.closest<HTMLElement>("button, a, [role=button]") ?? label;
// The cursor is inherited, so the control is the outermost element that still has it.
while (
box.parentElement &&
box.parentElement !== document.body &&
getComputedStyle(box.parentElement).cursor === "pointer"
)
box = box.parentElement;
const { width, height } = box.getBoundingClientRect();
for (let parent = box.parentElement; parent && parent !== document.body; parent = parent.parentElement) {
const rect = parent.getBoundingClientRect();
// Grows only by the split button's arrow, never by a whole extra row.
if (Math.abs(rect.height - height) > 1 || rect.width > width * 1.5) break;
box = parent;
}
return box;
}
let anchor: HTMLElement | null = null;
/** Keeps the button next to MakerWorld's, which can render late or be re-rendered away. */
function place(): void {
if (!host) return;
const anchor = host.previousElementSibling;
if (host.isConnected && anchor && isDownloadButton(anchor)) return;
const button = findDownloadButton();
if (!button) {
if (host.isConnected && anchor?.isConnected && anchor.offsetParent !== null && host.previousElementSibling === anchor)
return;
const label = findActionLabel();
if (!label) {
host.remove();
anchor = null;
return;
}
button.after(host);
// A flex/grid gap already spaces it; the margin is for plain inline layouts.
const gap = getComputedStyle(button.parentElement!).columnGap;
host.style.marginLeft = gap && gap !== "normal" && gap !== "0px" ? "0" : "";
anchor = controlBox(label);
anchor.after(host);
// Beside it in a row, otherwise underneath at full width; a gap already spaces it.
const parent = getComputedStyle(anchor.parentElement!);
const inRow = /flex/.test(parent.display) && parent.flexDirection.startsWith("row");
const gap = inRow ? parent.columnGap : parent.rowGap;
host.toggleAttribute("data-below", !inRow);
host.toggleAttribute("data-gapped", Boolean(gap) && gap !== "normal" && gap !== "0px");
}
function mount(pageUrl: string, slicerLabel: string | null): void {
unmountNormalizedDownload();
host = buildHost(pageUrl, slicerLabel);
let scheduled = false;
observer = new MutationObserver(() => {
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
place();
});
});
};
observer = new MutationObserver(schedule);
observer.observe(document.body, { childList: true, subtree: true });
// MakerWorld sets its theme as a class on <html>, which the shadow root's CSS can't see.
const syncTheme = () => host?.toggleAttribute("data-dark", document.documentElement.classList.contains("dark"));
themeObserver = new MutationObserver(syncTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
syncTheme();
place();
}
export function unmountNormalizedDownload(): void {
observer?.disconnect();
observer = null;
themeObserver?.disconnect();
themeObserver = null;
host?.remove();
host = null;
anchor = null;
clearTimeout(bubbleTimer);
}
+64 -18
View File
@@ -1,6 +1,6 @@
// The "Download normalized" button next to MakerWorld's own Download button (see
// The "Download normalized" button next to MakerWorld's own download/open button (see
// makerworld/normalizedDownload.ts). Its own shadow root, so it sits in the page's layout without
// sharing styles either way. Warning-colored like the web app's "Open normalized" button.
// sharing styles either way.
@use "../../styles/tokens" as t;
@use "../../styles/mixins" as m;
@@ -12,24 +12,50 @@
margin-left: t.$space-2;
}
// Under a full-width button, like MakerWorld's split "Open in Bambu Studio" one.
:host([data-below]) {
display: flex;
margin: t.$space-3 0 0;
}
:host([data-gapped]) {
margin: 0;
}
// MakerWorld's primary action button (its "Open in Bambu Studio" split button), copied as-is.
.tg-nd {
@include m.isolated(inline-flex);
align-items: center;
gap: 6px;
min-height: 36px;
padding: 6px 14px 6px 10px;
border: 1.5px solid t.c("warning");
border-radius: t.$radius-md;
background: t.c("surface");
color: t.c("warning");
font-weight: 600;
gap: t.$space-2;
min-height: 48px;
padding: 0 16px;
border: 0;
border-radius: 8px;
background: #00b800;
color: #ffffff;
font:
700 16px/24px "Open Sans",
system-ui,
"Segoe UI",
Roboto,
sans-serif;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s ease;
transition: filter 0.15s ease;
@include m.focus-ring;
&:hover:not(:disabled) {
background: t.c("warning-soft");
filter: brightness(0.94);
}
// MakerWorld's dark theme: a lighter green with dark text.
:host([data-dark]) & {
background: #08bf08;
color: #242424;
}
:host([data-below]) & {
flex: 1;
}
&:disabled {
@@ -37,22 +63,42 @@
opacity: 0.75;
}
&__label {
flex: 1;
text-align: center;
}
&__logo,
&__info {
display: inline-flex;
width: 18px;
height: 18px;
flex: none;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
svg {
width: 18px;
height: 18px;
fill: currentColor;
width: 20px;
height: 20px;
}
}
// The logo takes the text color, so it shows on any button color.
&__logo path {
fill: currentColor;
}
&__logo path[stroke] {
fill: none;
stroke: currentColor;
}
&__info svg {
fill: currentColor;
}
&__spinner {
width: 14px;
height: 14px;
margin: 2px;
box-sizing: border-box;
border: 2px solid currentColor;
border-right-color: transparent;
-4
View File
@@ -41,8 +41,6 @@ $palette-light: (
"hover": rgba(0, 0, 0, 0.06),
"danger": #d32f2f,
"danger-soft": rgba(211, 47, 47, 0.08),
"warning": #ed6c02,
"warning-soft": rgba(237, 108, 2, 0.08),
"switch-off": #c4c4c4,
"backdrop": rgba(0, 0, 0, 0.5),
"backdrop-strong": rgba(0, 0, 0, 0.75),
@@ -64,8 +62,6 @@ $palette-dark: map.merge(
"hover": rgba(255, 255, 255, 0.08),
"danger": #ef6b6b,
"danger-soft": rgba(239, 107, 107, 0.12),
"warning": #ffa726,
"warning-soft": rgba(255, 167, 38, 0.12),
"switch-off": #555555,
"shadow": rgba(0, 0, 0, 0.6),
)