chore: update licence and perform comment clean up

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-28 22:34:47 +02:00
1 parent 950ba3f375
commit e111dc27d7
315 files changed
+1855 -4293

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
"": {
"name": "thingport-grab",
"version": "1.1.2",
"license": "MIT",
"license": "AGPL-3.0-only",
"devDependencies": {
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/exec": "^7.1.0",
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"version": "1.1.2",
"description": "One-click import of MakerWorld, Thingiverse, and Printables models into your self-hosted Thingport instance.",
"license": "MIT",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "tsx scripts/build.ts chrome --watch",
+5 -10
View File
@@ -19,15 +19,12 @@ function compileScss(file: string): { css: string; watchFiles: string[] } {
return { css: result.css, watchFiles: result.loadedUrls.filter((u) => u.protocol === "file:").map((u) => fileURLToPath(u)) };
}
/** `import css from "./x.scss?inline"` -> the compiled CSS as a string (for the content script's
* shadow root); a plain `import "./x.scss"` -> CSS esbuild emits next to the importing entry
* (the popup's popup.css). */
/** `?inline` imports compile to a CSS string (for the shadow root); plain imports emit a CSS file. */
const scssPlugin: esbuild.Plugin = {
name: "scss",
setup(build) {
// Paths are kept relative to the extension folder: esbuild writes them into the bundles as
// `// scss:<path>` comments, and an absolute path would leak the build machine's folder layout
// into the shipped code -- and stop a store reviewer's rebuild matching the upload byte for byte.
// Relative paths: esbuild writes them into the bundle, and absolute ones would leak the build
// machine's layout and break reproducible store builds.
build.onResolve({ filter: /\.scss(\?inline)?$/ }, (args) => {
const inline = args.path.endsWith("?inline");
const file = path.resolve(args.resolveDir, args.path.replace(/\?inline$/, ""));
@@ -45,7 +42,6 @@ const scssPlugin: esbuild.Plugin = {
},
};
/** Static files and the manifest, written after every (re)build. */
function staticFilesPlugin(target: Target, outdir: string, pkg: { version: string; description: string }): esbuild.Plugin {
return {
name: "static-files",
@@ -74,11 +70,10 @@ async function buildTarget(target: Target, watch: boolean): Promise<void> {
},
outdir,
bundle: true,
// Classic scripts everywhere: content scripts can't be modules, and one format keeps the
// background identical between Chrome's service worker and Firefox's event page.
// Content scripts can't be modules, and one format keeps Chrome's and Firefox's background identical.
format: "iife",
target: ["chrome110", "firefox115"],
// Readable output -- store reviewers (AMO in particular) read the submitted code.
// Store reviewers (AMO in particular) read the submitted code.
minify: false,
sourcemap: watch ? "inline" : false,
legalComments: "none",
+5 -9
View File
@@ -1,6 +1,5 @@
// Generates each browser's manifest.json from one definition. Chrome and Edge run the background as
// an MV3 service worker; Firefox has no extension service workers and runs the same bundle as an
// event page via background.scripts, plus its own gecko settings.
// Generates each browser's manifest.json. Firefox has no extension service workers, so it runs the
// same bundle as an event page.
export const TARGETS = ["chrome", "firefox", "edge"] as const;
export type Target = (typeof TARGETS)[number];
@@ -24,12 +23,11 @@ export function buildManifest(target: Target, pkg: { version: string; descriptio
icons: iconSet("color"),
action: {
default_popup: "popup.html",
// Dark/inactive until a page shows the floating icon -- see background/tabIcon.ts.
default_icon: iconSet("dark"),
},
background: target === "firefox" ? { scripts: ["background.js"] } : { service_worker: "background.js" },
permissions: ["storage", "tabs", "cookies", "downloads"],
// The user's own instance origin, requested at setup time (see popup/index.ts).
// The user's instance origin, requested at setup.
optional_host_permissions: ["*://*/*"],
content_scripts: [{ matches: PROVIDER_MATCHES, js: ["content.js"], run_at: "document_idle" }],
};
@@ -37,12 +35,10 @@ export function buildManifest(target: Target, pkg: { version: string; descriptio
if (target === "firefox") {
manifest.browser_specific_settings = {
gecko: {
// Ties every signed version together as updates of one add-on -- never change it once
// builds have been distributed (see README).
// Never change once builds have been distributed: it ties signed versions together.
id: "grab@thingport.app",
strict_min_version: "109.0",
// Only covers data sent to the developer or a third party; the user's own self-hosted
// instance is neither (see README).
// The user's own instance counts as neither the developer nor a third party.
data_collection_permissions: { required: ["none"] },
},
};
+9 -31
View File
@@ -3,9 +3,8 @@
// npm run screenshots
//
// Opens a visible Chromium window (Printables' Cloudflare check turns headless browsers away) with
// dist/chrome loaded, and serves a small mock Thingport instance on localhost so the popup and the
// import panels show realistic data without a real account. The provider pages are the live sites:
// the first model linked from each site's front page, unless given explicitly, e.g.
// dist/chrome loaded, and serves a mock Thingport instance on localhost. The provider pages are the
// live sites: the first model linked from each front page, unless given explicitly, e.g.
//
// npm run screenshots -- --makerworld=https://makerworld.com/en/models/123 --printables=...
//
@@ -23,7 +22,6 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const EXTENSION_DIR = path.join(ROOT, "dist", "chrome");
const OUT_DIR = path.join(ROOT, "docs", "screenshots");
const PAGE_VIEWPORT = { width: 1280, height: 800 };
// What the popup shows as the instance address; the browser resolves it to the local mock server.
const DEMO_HOST = "thingport.home.arpa";
const POPUP_WIDTH = 320;
@@ -34,10 +32,7 @@ const PROVIDERS: { name: ProviderName; label: string; home: string; modelLink: R
{ name: "printables", label: "Printables", home: "https://www.printables.com/", modelLink: /^https:\/\/www\.printables\.com\/model\/\d+-[^/?#]+$/ },
];
// ---- Mock Thingport instance --------------------------------------------------------------------
/** Just enough of Thingport's API for the popup and the single-model panel. CORS is open, so the
* extension reaches it without the host permission a real setup would grant. */
/** CORS is open, so the extension reaches it without a host permission. */
type MockState = { inspectTitle: string | null };
function startMockInstance(state: MockState): Promise<{ url: string; close: () => void }> {
@@ -81,8 +76,6 @@ function startMockInstance(state: MockState): Promise<{ url: string; close: () =
});
}
// ---- Browser helpers ----------------------------------------------------------------------------
function argValue(name: string): string | undefined {
const prefix = `--${name}=`;
return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
@@ -97,7 +90,7 @@ async function launch(mockPort: number): Promise<{ context: BrowserContext; work
args: [
`--disable-extensions-except=${EXTENSION_DIR}`,
`--load-extension=${EXTENSION_DIR}`,
// Without this, Cloudflare (Printables) recognizes the automated browser and never lets it in.
// Otherwise Cloudflare (Printables) recognizes the automated browser.
"--disable-blink-features=AutomationControlled",
`--host-resolver-rules=MAP ${DEMO_HOST}:80 127.0.0.1:${mockPort}`,
],
@@ -107,7 +100,6 @@ async function launch(mockPort: number): Promise<{ context: BrowserContext; work
return { context, worker, extensionId };
}
/** Best-effort: clicks away a cookie/consent banner so it doesn't cover the screenshot. */
async function dismissBanners(page: Page): Promise<void> {
const button = page.getByRole("button", { name: /^(accept( all)?( cookies)?|allow all|agree|i agree|got it|ok)$/i }).first();
if (await button.isVisible().catch(() => false)) await button.click({ timeout: 2000 }).catch(() => undefined);
@@ -115,14 +107,12 @@ async function dismissBanners(page: Page): Promise<void> {
async function openPage(page: Page, url: string): Promise<void> {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
// Room for a Cloudflare check to clear and the page's own client-side render to settle.
await page.waitForFunction(() => !/just a moment/i.test(document.title), undefined, { timeout: 60000 });
await page.waitForTimeout(3000);
await dismissBanners(page);
}
/** The first `count` distinct model pages linked from the provider's front page -- the first one
* replaced by --<provider>=<url> when given. */
/** The first one is replaced by --<provider>=<url> when given. */
async function findModelUrls(page: Page, provider: (typeof PROVIDERS)[number], count: number): Promise<string[]> {
await openPage(page, provider.home);
const links = await page.$$eval("a[href]", (as) => as.map((a) => (a as HTMLAnchorElement).href.split(/[?#]/)[0]));
@@ -136,9 +126,8 @@ async function findModelUrls(page: Page, provider: (typeof PROVIDERS)[number], c
type ModelInfo = { url: string; title: string; image: string | null };
async function readModelInfo(page: Page): Promise<ModelInfo> {
// The page's <h1> for the name (og:title is stale on Thingiverse and suffixed elsewhere) and its
// largest visible image for the thumbnail (og:image is Thingiverse's logo). One expression, no
// inner function declarations: tsx would wrap those in a `__name` helper the page doesn't have.
// og:title/og:image are unreliable here. No inner function declarations: tsx would wrap them in
// a `__name` helper the page doesn't have.
const meta = await page.evaluate(() => ({
title: document.querySelector("h1")?.textContent?.trim() || document.title,
image:
@@ -147,13 +136,11 @@ async function readModelInfo(page: Page): Promise<ModelInfo> {
.map((img) => ({ src: img.currentSrc, area: img.getBoundingClientRect().width * img.getBoundingClientRect().height }))
.toSorted((a, b) => b.area - a.area)[0]?.src ?? null,
}));
// MakerWorld has no <h1>, so its name comes from the tab title -- minus the site's own suffix,
// matching the clean title the real backend's /import/inspect returns.
// MakerWorld has no <h1>, so strip the site's suffix from the tab title.
const title = meta.title.replace(/\s*-\s*Free 3D Print Model\b.*$/i, "").replace(/\s*[-|]\s*MakerWorld$/i, "");
return { url: page.url(), title, image: meta.image };
}
/** The recent-imports strip stores thumbnails as data URLs (see background/recentImports.ts). */
async function toDataUrl(imageUrl: string | null): Promise<string | null> {
if (!imageUrl) return null;
try {
@@ -181,8 +168,6 @@ async function screenshotViewport(page: Page, file: string): Promise<void> {
await page.screenshot({ path: path.join(OUT_DIR, file), type: "jpeg", quality: 82 });
}
// ---- Run ----------------------------------------------------------------------------------------
async function main(): Promise<void> {
await rm(OUT_DIR, { recursive: true, force: true });
await mkdir(OUT_DIR, { recursive: true });
@@ -194,11 +179,8 @@ async function main(): Promise<void> {
const page = context.pages()[0] ?? (await context.newPage());
try {
// Not configured yet: the setup form, and the grayed-out bubble's dialog on a model page.
await screenshotPopup(context, extensionId, "popup-setup.png", "light");
// One model per provider for its panel screenshot, plus a couple more to fill the popup's
// recent-imports row (it shows five).
const models = new Map<ProviderName, ModelInfo>();
const recentModels: ModelInfo[] = [];
for (const provider of PROVIDERS) {
@@ -217,7 +199,6 @@ async function main(): Promise<void> {
await page.locator(".tg-modal").waitFor();
await screenshotViewport(page, "setup-dialog-makerworld.jpg");
// Connected, with a few imports behind it.
const recent = await Promise.all(
recentModels.slice(0, 5).map(async (model, i) => ({
printId: `demo-${i}`,
@@ -235,7 +216,6 @@ async function main(): Promise<void> {
await screenshotPopup(context, extensionId, "popup-connected.png", "light");
await screenshotPopup(context, extensionId, "popup-connected-dark.png", "dark");
// The import panel on each provider.
for (const provider of PROVIDERS) {
const model = models.get(provider.name)!;
mockState.inspectTitle = model.title;
@@ -244,9 +224,7 @@ async function main(): Promise<void> {
await page.locator('.tg-panel [data-action="import"]').waitFor({ timeout: 20000 });
await screenshotViewport(page, `panel-${provider.name}.jpg`);
// And the result of clicking Import, once. Printables rather than MakerWorld: a MakerWorld
// import first clicks the live page's own Download button to capture the file URL (see
// content/makerworld/downloadResolver.ts), which would start a real download here.
// Not MakerWorld: its import clicks the page's real Download button.
if (provider.name === "printables") {
await page.locator('.tg-panel [data-action="import"]').click();
await page.getByRole("link", { name: "Open in Thingport" }).waitFor({ timeout: 20000 });
+3 -6
View File
@@ -23,10 +23,8 @@ const OUT_DIR = path.join(ROOT, "docs", "store");
const SCREENSHOTS_DIR = path.join(ROOT, "docs", "screenshots");
const STORE_SCREENSHOT = { width: 1280, height: 800 };
// The content script's real stylesheet, for templates that show the in-page UI (tile-large.html).
// It's written for a shadow root, so its `:host` custom properties are re-scoped to `.tg-scope`.
// Transitions are switched off: the stylesheet lands after the page has rendered, and a button
// would otherwise be captured mid-fade from its default gray to green.
// The content script's stylesheet, with `:host` properties re-scoped to `.tg-scope`. Transitions
// are off so buttons aren't captured mid-fade.
const CONTENT_CSS =
sass.compile(path.join(ROOT, "src", "content", "styles", "content.scss")).css.replaceAll(":host", ".tg-scope") +
"\n*, *::before, *::after { transition: none !important; }";
@@ -42,7 +40,6 @@ await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
try {
for (const asset of ASSETS) {
// deviceScaleFactor 1: the stores want these exact pixel sizes.
const page = await browser.newPage({ viewport: { width: asset.width, height: asset.height }, deviceScaleFactor: 1 });
await page.goto(pathToFileURL(path.join(TEMPLATES, asset.template)).href, { waitUntil: "load" });
await page.addStyleTag({ content: CONTENT_CSS });
@@ -52,7 +49,7 @@ try {
console.log(`docs/store/${asset.out} ${asset.width}x${asset.height}`);
}
// The stores only take PNG screenshots, at 1280x800 (the README's are 2x JPEGs).
// The stores only take PNG screenshots at 1280x800.
await mkdir(path.join(OUT_DIR, "screenshots"), { recursive: true });
const page = await browser.newPage({ viewport: STORE_SCREENSHOT, deviceScaleFactor: 1 });
for (const file of (await readdir(SCREENSHOTS_DIR)).filter((f) => f.endsWith(".jpg")).toSorted()) {
+3 -6
View File
@@ -2,11 +2,8 @@
//
// tsx scripts/zip.ts chrome firefox edge sources
//
// -> dist/zips/thingport-grab-<target>.zip, the contents of dist/<target>/ with manifest.json at the
// zip root (what the Chrome Web Store, addons.mozilla.org and Edge Add-ons all expect), and
// dist/zips/thingport-grab-sources.zip -- the source + build setup AMO asks for whenever the
// submitted code is generated by a build step, so a reviewer can reproduce it
// (`npm ci && npm run build:firefox`).
// -> dist/zips/thingport-grab-<target>.zip with manifest.json at the root, and
// thingport-grab-sources.zip, the source AMO asks for to reproduce the build.
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
@@ -17,7 +14,7 @@ import { TARGETS, isTarget } from "./manifest";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const OUT = path.join(ROOT, "dist", "zips");
// Fixed timestamp so the same input always produces a byte-identical zip.
// Fixed timestamp for byte-identical zips.
const MTIME = new Date("2020-01-01T00:00:00Z");
const SOURCE_ENTRIES = ["package.json", "package-lock.json", "tsconfig.json", ".oxlintrc.json", "README.md", "scripts", "src", "public"];
const IGNORED = new Set([".DS_Store", "Thumbs.db"]);
+6 -19
View File
@@ -1,8 +1,5 @@
// Every fetch() to the user's Thingport instance goes through here. Content scripts and the popup
// never fetch directly -- they send API_CALL (or a higher-level message) to the background and get
// plain JSON back -- so auth (login + silent re-login on expiry/401) and the instance URL live in
// exactly one place. The host permission for the saved instance origin (requested at setup, see
// popup/index.ts) is what lets this reach a self-hosted instance regardless of its CORS config.
// Every fetch to the Thingport instance goes through here, so auth (including silent re-login) and
// the instance URL live in one place. The host permission granted at setup bypasses CORS.
import type { LoginResult } from "../shared/api";
import { apiUrl } from "../shared/storage";
@@ -12,9 +9,7 @@ import { getLiveMakerworldCookie, maybeSyncMakerworldCookie } from "./makerworld
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
// Identifies these requests as Thingport Grab's, which the instance never asks for a captcha
// (Administration > Captcha): the extension signs in and imports in the background, with nowhere
// to show one. See the backend's services/captchaService.ts isExtensionRequest.
// The instance never asks the extension for a captcha (see captchaService.ts).
const CLIENT_HEADERS = { "X-Thingport-Client": "grab" };
type Credentials = Pick<ConfiguredConfig, "instanceUrl" | "email" | "password">;
@@ -24,14 +19,11 @@ async function errorDetail(res: Response, fallback: string): Promise<string> {
const body = (await res.json()) as { detail?: string } | null;
if (body && body.detail) return body.detail;
} catch {
// ignore -- keep the generic message
}
return fallback;
}
/** (Re-)authenticates against the given instance/credentials and persists the resulting token.
* Called by ensureToken on first use / near expiry, and again once on a 401 (a password change
* or server-side session revocation shouldn't require reopening the popup to recover from). */
/** Also called once on a 401, so a password change doesn't require reopening the popup. */
export async function loginAndStoreToken(credentials: Credentials): Promise<string> {
const res = await fetch(apiUrl(credentials.instanceUrl, "/login"), {
method: "POST",
@@ -59,12 +51,8 @@ export async function requireConfig(): Promise<ConfiguredConfig> {
return config;
}
/** Generic authenticated call to the stored instance -- every content-script/popup action goes
* through this rather than a bespoke message per endpoint. `path` is the API path after `/api`
* (e.g. "/collections"). Any `/import*` call whose body targets a MakerWorld URL gets the live
* browser cookie attached automatically (see makerworldCookie.ts) unless the caller already set
* one -- this is what lets a user who has never touched Profile > MakerWorld still import from
* MakerWorld via the extension. */
/** `path` is after `/api`. MakerWorld `/import*` calls get the live browser cookie attached unless
* the caller set one. */
export async function apiCall<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
const config = await requireConfig();
@@ -95,7 +83,6 @@ export async function apiCall<T = unknown>(method: string, path: string, body?:
return (await res.json()) as T;
}
/** Authenticated GET of a binary resource on the instance (e.g. a thumbnail). */
export async function apiFetchBlob(config: ConfiguredConfig, path: string): Promise<Blob | null> {
const res = await fetch(apiUrl(config.instanceUrl, path), {
headers: { ...CLIENT_HEADERS, Authorization: `Bearer ${await ensureToken(config)}` },
+2 -6
View File
@@ -22,14 +22,10 @@ export async function getState(): Promise<ExtensionState> {
};
}
// The host permission for the chosen instance origin is requested by the popup itself, not here --
// chrome.permissions.request() must run within the user gesture that triggered it (the popup's
// own submit click), which doesn't survive a runtime.sendMessage hop into the background. By the
// time SAVE_CONFIG arrives, the popup has already been granted (or refused, in which case it
// never sends this) that permission.
// The popup requests the host permission itself: it needs the submit's user gesture.
export async function saveConfig({ instanceUrl, email, password }: { instanceUrl: string; email: string; password: string }): Promise<null> {
const normalized = normalizeInstanceUrl(instanceUrl);
// Validate before persisting -- a typo'd URL or wrong password shouldn't silently save.
// Validate before persisting.
await loginAndStoreToken({ instanceUrl: normalized, email, password });
await chrome.storage.local.set({ instanceUrl: normalized, email, password, disabled: false });
return null;
+5 -12
View File
@@ -1,16 +1,11 @@
// MakerWorld click-and-capture download resolution. The content script clicks the model page's own
// real "Download" button; this captures the browser download that click triggers, cancels it
// immediately, and reads the resolved URL off it before erasing it from the downloads list. That
// sidesteps every guess about MakerWorld's resolution API (query params, nonce, headers): whatever
// request the page's own JS makes for a real click is the one captured. Requires "downloads".
// The content script clicks MakerWorld's real Download button; this captures the resulting browser
// download, cancels it, and reads its URL. That avoids guessing MakerWorld's resolution API.
//
// Two messages rather than one (ARM, then AWAIT) because the click itself has to happen in the
// content script between arming and awaiting, and one message only gets one reply.
// Two messages (ARM, then AWAIT) because the click happens in the content script in between.
const DOWNLOAD_CAPTURE_TIMEOUT_MS = 8000;
// Only one capture is ever in flight -- single-model imports and the guided collection loop both
// resolve one model at a time.
// Only one capture is ever in flight.
let pending: { resolve: (url: string | null) => void; timeoutId: ReturnType<typeof setTimeout> } | null = null;
let currentCapture: Promise<string | null> | null = null;
@@ -21,12 +16,10 @@ chrome.downloads.onCreated.addListener((item) => {
clearTimeout(timeoutId);
chrome.downloads.cancel(item.id).catch(() => undefined);
chrome.downloads.erase({ id: item.id }).catch(() => undefined);
// A blob: URL means the page fetched the file itself rather than navigating to a real remote
// URL -- not fetchable by the backend, so treated as "nothing captured" and the caller falls back.
// A blob: URL isn't fetchable by the backend, so it counts as nothing captured.
resolve(/^https?:\/\//i.test(item.url) ? item.url : null);
});
/** Primes the onCreated listener above right before the content script clicks Download. */
export function armDownloadCapture(): null {
if (pending) {
clearTimeout(pending.timeoutId);
+5 -18
View File
@@ -3,7 +3,6 @@ import type { ImportSinglePayload } from "../shared/messages";
import { apiCall } from "./api";
import { recordRecentImport } from "./recentImports";
// Same ~1s cadence as the web app's ImportJobContext.tsx and the content script's batch poll.
const JOB_POLL_INTERVAL_MS = 1000;
export async function pollJobToCompletion(jobId: string): Promise<ImportJob> {
@@ -14,21 +13,11 @@ export async function pollJobToCompletion(jobId: string): Promise<ImportJob> {
}
}
/** Runs a single-model import (direct or "choose files" zip entries) and, if a destination
* collection was picked, files the result into it -- as ONE message from the content script
* rather than two chained ones, so the whole sequence still completes even if the tab that started
* it navigates away or reloads a moment later. The background isn't torn down by a tab navigation
* the way a content script is, so once this has started, the import (and any collection filing)
* runs to completion regardless -- only the reply to a since-destroyed page can get lost. */
/** One message covering import and collection filing, so both complete even if the tab navigates
* away: the background outlives the content script. */
export async function importSingle({ url, entries, collectionId, resolved, title }: ImportSinglePayload): Promise<Print | null> {
// For a MakerWorld model, the content script resolves the actual download URL from the live page
// (see content/makerworld/downloadResolver.ts) -- passing it as resolved_download_url lets the
// backend skip its own resolution, the only two places able to trip MakerWorld's CAPTCHA and its
// 2-hour account-wide lockout. The profile id tells the backend which MakerWorld print profile
// the file is, so importing another profile of a model already in the library adds it as a
// second file instead of being skipped as a duplicate, and the page's design data spares the
// backend fetching the model page for its details (often Cloudflare-blocked for a server).
// Omitted when nothing was resolved.
// A page-resolved download lets the backend skip the resolution calls that trip MakerWorld's
// CAPTCHA. The profile id lets another profile of an existing model be added as a file.
const extra = resolved?.downloadUrl
? {
resolved_download_url: resolved.downloadUrl,
@@ -50,9 +39,7 @@ export async function importSingle({ url, entries, collectionId, resolved, title
if (collectionId && print?.id) {
await apiCall("POST", `/collection/${collectionId}/items/${print.id}`).catch(() => undefined);
}
// A no-op import (already in the library) isn't something the user just imported. Another
// MakerWorld print profile of a model they already had returns that same model, so it lands as
// the one entry, moved to the front.
// Skip no-op imports. Adding a profile returns the existing model, moved to the front.
if (print?.id && print.import_outcome !== "already_imported") {
await recordRecentImport(print, title ?? null).catch(() => undefined);
}
+4 -11
View File
@@ -1,7 +1,5 @@
// Background entry point (a service worker in Chrome/Edge, an event page in Firefox -- see
// scripts/manifest.ts). Owns the extension's config/auth state and every request to the user's
// Thingport instance; the content script and popup only ever talk to it through the typed
// messages in shared/messages.ts.
// Background entry point (service worker in Chrome/Edge, event page in Firefox). Owns config, auth
// and every request to the Thingport instance.
import { listen, type BackgroundMessages } from "../shared/messages";
import { apiCall } from "./api";
@@ -13,7 +11,6 @@ import { getRecentImports } from "./recentImports";
import { openSetup } from "./setup";
import { setTabIconState } from "./tabIcon";
/** Messages that only make sense from a content script (they act on the sender's tab). */
function senderTabId(sender: chrome.runtime.MessageSender): number {
if (!sender.tab?.id) throw new Error("This action is only available from a page");
return sender.tab.id;
@@ -37,13 +34,9 @@ listen<BackgroundMessages>({
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
// The moment a tab starts a NEW navigation (including to a site this extension never runs on),
// reset its icon to inactive -- a per-tab setIcon override otherwise persists until something
// changes it. If the destination is a provider page, its content script re-asserts the right
// state a moment later.
// Per-tab icon overrides persist, so reset on every new navigation; the content script re-asserts it.
if (changeInfo.status === "loading") void setTabIconState(tabId, false);
// Drives the guided MakerWorld collection import -- "complete" is what wakes the background back
// up even if it was suspended between steps.
// "complete" wakes a suspended background to drive the guided import.
if (changeInfo.status === "complete") void advanceJob(tabId);
});
+5 -17
View File
@@ -1,38 +1,26 @@
import type { ConfiguredConfig } from "./config";
import { apiCall } from "./api";
/** Reads the user's live MakerWorld session straight from the browser's cookie jar -- makerworld.com
* sets it HttpOnly, which blocks a page's own `document.cookie` (that's the whole reason the web
* app's Profile settings flow has the user paste it in manually), but chrome.cookies is a
* privileged, extension-only API allowed to read HttpOnly cookies. Requires the "cookies"
* permission plus host access to makerworld.com (from the static content_scripts match). Returns
* null (never throws) if the user isn't logged into MakerWorld in this browser, or cookie access
* is blocked -- callers fall back to whatever's already stored in Thingport. */
/** The session cookie is HttpOnly, so pages can't read it but chrome.cookies can. Null (never
* throws) when the user isn't logged in or access is blocked. */
export async function getLiveMakerworldCookie(): Promise<string | null> {
try {
const cookie = await chrome.cookies.get({ url: "https://makerworld.com", name: "token" });
if (!cookie) return null;
// Re-wrapped as "token=<value>" rather than the bare value -- the backend's
// extractMakerworldBearerToken (makerworldCloudApi.ts) has a "token=...;" regex path and a
// bare-token fallback that rejects any `=`, `;` or whitespace. A real session token can
// contain `=` (base64 padding), and a cookie value can never contain an unquoted `;`, so this
// format always takes the first, unambiguous path.
// "token=<value>": a bare token containing `=` (base64 padding) would be rejected by the backend.
return `token=${cookie.value}`;
} catch {
return null;
}
}
/** Best-effort: keeps Thingport's own stored makerworld_cookie (Profile > MakerWorld) in sync with
* whatever's live in the browser, so the plain web app's imports benefit too. Skipped once it's
* already in sync, and never lets a failure affect the import that triggered it -- callers fire
* this and move on. */
/** Best-effort: keeps the stored cookie in sync so the web app's imports benefit too. Never
* affects the triggering import. */
export async function maybeSyncMakerworldCookie(config: ConfiguredConfig, cookieValue: string): Promise<void> {
if (config.lastSyncedMakerworldCookie === cookieValue) return;
try {
await apiCall("PATCH", "/settings/makerworld", { cookie: cookieValue });
await chrome.storage.local.set({ lastSyncedMakerworldCookie: cookieValue });
} catch {
// Best-effort -- the import this was piggybacking on already has the live cookie regardless.
}
}
+19 -58
View File
@@ -1,16 +1,8 @@
// MakerWorld guided collection import.
// MakerWorld guided collection import: navigates the tab to each model's page and imports it
// there, one at a time. Real page loads pace the requests the way no config value can, which
// keeps MakerWorld's account-wide CAPTCHA from tripping.
//
// MakerWorld's anti-abuse system CAPTCHAs the whole account for hours the moment a burst of
// collection-import API calls looks automated -- even with pacing, a large collection can trip it
// almost immediately (see the backend's IMPORT_MAKERWORLD_CALL_DELAY_MS comment). Rather than
// firing every design's import calls back-to-back from the backend, this drives the tab to each
// model's own page, one at a time -- a real navigation + page load imposes pacing no config value
// can fake, and looks like a person browsing rather than a script. Each page load is a plain
// single-model import (importSingle), the same path a manual visit uses.
//
// State lives in chrome.storage (not a module variable) because the job outlives any single page
// load in the tab it's driving, and an MV3 background can be suspended and woken again between
// steps -- tabs.onUpdated firing later is what wakes it, at which point it re-reads this.
// State lives in chrome.storage because an MV3 background can be suspended between steps.
import type { ImportStatus } from "../shared/api";
import type { MakerworldJob, MakerworldJobError } from "../shared/messages";
@@ -20,14 +12,9 @@ import { importSingle } from "./importJobs";
const JOB_STORAGE_KEY = "makerworldCollectionJob";
const JOB_ERROR_STORAGE_KEY = "makerworldJobError";
// Pacing after each import, on top of however long the model page itself took to load --
// deliberately not configurable, since a shorter value would undercut the point of this feature.
// Not configurable: a shorter delay defeats the point.
const STEP_DELAY_MS = 5000;
// tabs.sendMessage has no timeout of its own -- if the content script's resolution ever stalls
// (its own fetches are bounded, but e.g. the listener could throw before replying) the job would
// otherwise sit forever behind a progress overlay that never moves. Comfortably above the content
// script's own worst case (an 8s click capture, then up to two 8s-bounded fallback fetches), so it
// only ever fires for a genuinely stuck resolution.
// tabs.sendMessage has no timeout; this is well above the content script's own worst case.
const DOWNLOAD_RESOLVE_TIMEOUT_MS = 30000;
async function getJob(): Promise<MakerworldJob | null> {
@@ -46,10 +33,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([promise, new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))]);
}
/** Started once the user clicks "Start import" on a MakerWorld collection page -- `urls` is every
* not-yet-imported design's page URL (already-imported ones were filed into the destination
* collection by the content script before this runs). Stores the job, then makes the first move;
* tabs.onUpdated's "complete" handler drives every step after that. */
/** `urls` are the not-yet-imported designs. tabs.onUpdated drives every step after the first. */
export async function startJob(tabId: number, { urls, collectionId, originalUrl }: { urls: string[]; collectionId: string | null; originalUrl: string }): Promise<null> {
if (!urls.length) return null;
await setJob({
@@ -66,8 +50,7 @@ export async function startJob(tabId: number, { urls, collectionId, originalUrl
return null;
}
/** The overlay's Abort button -- returns the tab to where the run started and discards the rest of
* the queue. Nothing already imported is undone. */
/** Returns the tab to where the run started. Nothing already imported is undone. */
export async function abortJob(tabId: number): Promise<null> {
const job = await getJob();
if (!job || job.tabId !== tabId) return null;
@@ -76,9 +59,7 @@ export async function abortJob(tabId: number): Promise<null> {
return null;
}
/** The job driving this tab, or (when there's none) a just-stopped job's error -- read-and-clear,
* surfaced exactly once, on the page that loads right after the job stops itself (always the
* original collection page, see advanceJob), not on a later unrelated visit. */
/** A just-stopped job's error is read-and-clear, so it's shown exactly once. */
export async function getJobForTab(tabId: number | undefined): Promise<{ job: MakerworldJob | null; error: MakerworldJobError | null }> {
const job = tabId != null ? await getJob() : null;
if (job && job.tabId === tabId) return { job, error: null };
@@ -88,18 +69,12 @@ export async function getJobForTab(tabId: number | undefined): Promise<{ job: Ma
return { job: null, error };
}
/** True as long as nothing else (most relevantly forceAdvanceJob) has already moved the job past
* the step a call started on. advanceJob and forceAdvanceJob can race to finish the same step --
* a large model can take long enough to import that someone clicks "Import next" before the
* original request comes back -- and this keeps a late automatic completion from double-advancing
* or clobbering a step the manual path already moved past. */
/** Keeps a late automatic completion from double-advancing a step "Import next" already moved past. */
async function isStillAtStep(tabId: number, stepIndex: number): Promise<boolean> {
const current = await getJob();
return Boolean(current && current.tabId === tabId && current.index === stepIndex);
}
/** Paces, then moves to the next URL -- or, after the last one, back to the collection page the
* run started from. Shared by the normal completion and the manual "Import next". */
async function moveForward(tabId: number, job: MakerworldJob): Promise<void> {
job.index += 1;
if (job.index >= job.urls.length) {
@@ -109,7 +84,7 @@ async function moveForward(tabId: number, job: MakerworldJob): Promise<void> {
}
await setJob(job);
await sleep(STEP_DELAY_MS);
// The user may have hit Abort during the pacing delay -- re-check before navigating on.
// The user may have hit Abort during the delay.
const stillActive = await getJob();
if (!stillActive || stillActive.tabId !== tabId) return;
job.awaitingLoad = true;
@@ -117,10 +92,8 @@ async function moveForward(tabId: number, job: MakerworldJob): Promise<void> {
await chrome.tabs.update(tabId, { url: job.urls[job.index] });
}
/** Runs once the tab finishes loading the current step's model page: imports it, paces, moves on.
* Any import failure stops the whole run rather than skipping past it: this almost always means
* MakerWorld itself just rejected the request (CAPTCHA, rate limit, expired session), and firing
* more requests at that point would only make it worse. */
/** Any failure stops the run: it almost always means MakerWorld rejected the request, and more
* requests would make it worse. */
export async function advanceJob(tabId: number): Promise<void> {
const job = await getJob();
if (!job || job.tabId !== tabId || !job.awaitingLoad) return;
@@ -129,23 +102,19 @@ export async function advanceJob(tabId: number): Promise<void> {
await setJob(job);
const currentUrl = job.urls[stepIndex];
// Ask the content script on the page we just navigated to (it has DOM access to __NEXT_DATA__
// and a same-origin fetch carrying the real session cookie) to resolve the download URL itself.
// This matters most here: it's this per-model loop that would otherwise fire MakerWorld
// resolution calls back-to-back. Best-effort -- null lets the backend resolve it as before.
// The content script resolves the download itself with the page's session. Best-effort: null
// lets the backend resolve it.
let resolved = null;
try {
const reply = await withTimeout(sendToTab(tabId, "RESOLVE_MAKERWORLD_DOWNLOAD_URL"), DOWNLOAD_RESOLVE_TIMEOUT_MS);
if (reply && reply.ok) resolved = reply.data;
} catch {
// No listener yet, or the tab navigated away already -- fine, see above.
}
try {
await importSingle({ url: currentUrl, collectionId: job.collectionId, resolved });
} catch (err) {
// A manual "Import next" may have moved the job past this step while the request was in
// flight -- then this failure is stale and says nothing about the current step.
// A manual "Import next" may have moved past this step while the request was in flight.
if (!(await isStillAtStep(tabId, stepIndex))) return;
await setJob(null);
const error: MakerworldJobError = { message: err instanceof Error ? err.message : String(err), imported: job.imported, total: job.total };
@@ -154,18 +123,13 @@ export async function advanceJob(tabId: number): Promise<void> {
return;
}
// Same staleness check on the success path, so a slow-but-alive request can't double-advance.
if (!(await isStillAtStep(tabId, stepIndex))) return;
job.imported += 1;
await moveForward(tabId, job);
}
/** The overlay's "Import next" -- a manual escape hatch for when the automatic advance never comes
* back. The likeliest cause isn't MakerWorld: a model with an unusually large file can take long
* enough to import that MV3's background lifecycle limits kill this extension's background
* mid-request, silently dropping the await above (the import itself still completes server-side).
* So this confirms the stuck step actually landed in Thingport first (and files it into the
* destination collection if that got skipped too) before crediting it and continuing. */
/** Manual escape hatch: MV3 can kill the background mid-request on a large import. Confirms the
* stuck step actually landed in Thingport before continuing. */
export async function forceAdvanceJob(tabId: number): Promise<null> {
const job = await getJob();
if (!job || job.tabId !== tabId) return null;
@@ -179,15 +143,12 @@ export async function forceAdvanceJob(tabId: number): Promise<null> {
}
}
} catch {
// Status check failed (instance unreachable, etc.) -- proceed anyway; the user already saw it
// land in their library before reaching for this button.
// Status check failed: proceed anyway, the user saw it land before clicking.
}
await moveForward(tabId, job);
return null;
}
/** The driven tab was closed outright -- no further tabs.onUpdated will ever act on the job, so
* drop it rather than leave it orphaned in storage. */
export async function dropJobIfForTab(tabId: number): Promise<void> {
const job = await getJob();
if (job && job.tabId === tabId) await setJob(null);
+5 -12
View File
@@ -1,7 +1,5 @@
// The popup's "Recent imports" strip: the last few models imported through this extension, in this
// browser -- kept entirely in extension storage, title and thumbnail included, so the popup never
// has to ask the instance for anything. The flip side: an entry reflects the model as it was when
// imported (a later rename or delete in Thingport doesn't show here).
// The popup's "Recent imports" strip, kept entirely in extension storage. Entries reflect the model
// as it was when imported.
import type { Print } from "../shared/api";
import type { RecentImport } from "../shared/messages";
@@ -18,14 +16,11 @@ async function readStored(): Promise<RecentImport[]> {
return (stored[RECENT_IMPORTS_STORAGE_KEY] as RecentImport[] | undefined) ?? [];
}
/** `print` is POST /import's response (or just `{ id }` for a zip import, whose job reports only
* the id -- `titleHint`, the page's own title, covers that case). Entries are scoped to the
* instance + account they were imported into, so switching either shows that one's own list. */
/** `titleHint` covers zip imports, whose job reports only the id. Scoped to instance + account. */
export async function recordRecentImport(print: Print, titleHint: string | null): Promise<void> {
const config = await getStoredConfig();
if (!isConfigured(config)) return;
const instanceUrl = normalizeInstanceUrl(config.instanceUrl);
// Same cover the web app's model cards use, falling back to the first photo.
const thumbPath = print.thumb_url || print.preview_images?.[0]?.url || null;
const entry: RecentImport = {
printId: print.id,
@@ -35,16 +30,14 @@ export async function recordRecentImport(print: Print, titleHint: string | null)
instanceUrl,
email: config.email,
};
// Another MakerWorld print profile of a model already in the list is the same model -- it moves
// to the front rather than taking a second slot.
// Another profile of a listed model moves it to the front instead of adding a slot.
const rest = (await readStored()).filter(
(e) => !(e.printId === entry.printId && e.instanceUrl === instanceUrl && e.email === entry.email),
);
await chrome.storage.local.set({ [RECENT_IMPORTS_STORAGE_KEY]: [entry, ...rest].slice(0, RECENT_IMPORTS_KEPT) });
}
/** Downloads the model's cover once and shrinks it to a small square JPEG data URL (a few KB),
* center-cropped the way the popup shows it. */
/** A small center-cropped square JPEG data URL. */
async function fetchThumbDataUrl(config: ConfiguredConfig, thumbPath: string): Promise<string | null> {
const blob = await apiFetchBlob(config, thumbPath);
if (!blob) return null;
+3 -7
View File
@@ -1,10 +1,6 @@
/** Opens the setup form for the content script's setup modal (the grayed-out icon on a supported
* page before the extension is configured). Content scripts can't open the popup or request host
* permissions themselves, and the click that led here doesn't carry over a runtime message, so:
* the toolbar popup where the browser lets an extension open it without one (Chrome 127+),
* otherwise the same page in a tab -- either way an extension page, which is what the popup's
* permission prompt on Save needs. `returnTabId` lets the tab version switch back to the page the
* user came from once saved. */
/** Content scripts can't open the popup or request host permissions, so open the popup where the
* browser allows it (Chrome 127+), else the same page in a tab. `returnTabId` lets the tab switch
* back once saved. */
export async function openSetup(returnTabId: number | undefined): Promise<"popup" | "tab"> {
try {
await chrome.action.openPopup();
+2 -6
View File
@@ -1,9 +1,5 @@
// The toolbar icon is active (color) only for a tab where the floating in-page icon is actually
// showing right now -- configured, enabled, AND a recognized, not-already-imported provider page.
// Everywhere else it's the dark/inactive one (the manifest's action.default_icon). The content
// script reports this per page via SET_TAB_ICON_STATE; index.ts resets it the moment a new
// navigation starts, so a tab doesn't keep showing "active" after leaving for a page this
// extension never runs on.
// The toolbar icon is active only while the floating in-page icon is showing. Content scripts
// report it via SET_TAB_ICON_STATE; index.ts resets it on every navigation.
function iconPaths(active: boolean): Record<number, string> {
const prefix = active ? "thingport-icon-color" : "thingport-icon-dark";
+4 -8
View File
@@ -1,12 +1,9 @@
import type { Classification } from "../shared/urls";
/** Set when the model is already in the library but this page's MakerWorld print profile might
* not be (see the backend's checkImportStatus) -- the icon and panel then offer to add the
* profile's file to the existing model rather than presenting it as a new import. */
/** The model is in the library but this MakerWorld profile may not be, so offer to add it. */
export type LibraryState = { state: "profile_missing" | "profile_unknown"; printId: string | null };
/** Everything the panel's flows need to know about the page the icon is showing on. Set once per
* page by index.ts's init() (title is filled in later, from /import/inspect or the page). */
/** Set once per page by init(); title is filled in later. */
export type ImportContext = {
url: string;
instanceUrl: string;
@@ -21,9 +18,8 @@ export function setContext(context: ImportContext | null): void {
current = context;
}
/** The current page's context. Throws once the page has changed underneath a still-running flow
* (an SPA navigation calls unmount(), which clears it) -- flows that must survive that capture
* what they need up front instead. */
/** Throws once an SPA navigation has cleared it; flows that must survive capture what they need
* up front. */
export function ctx(): ImportContext {
if (!current) throw new Error("The page changed while this was running");
return current;
+9 -24
View File
@@ -1,6 +1,5 @@
// Content script entry point (runs on MakerWorld, Thingiverse and Printables). Decides whether the
// current page gets the floating icon, and in which state; every network call goes through the
// background (see runtime.ts). The panel flows live in panels/, the in-page shell in shell.ts.
// Content script entry point (MakerWorld, Thingiverse, Printables). Network calls go through the
// background (see runtime.ts).
import type { ImportStatus } from "../shared/api";
import { listen, send, type ContentMessages } from "../shared/messages";
@@ -17,13 +16,11 @@ import { mountFab, mountHost, mountPanel, renderPanel, setPanelOpen, unmountHost
declare global {
interface Window {
/** Guards against a second injection of this script into the same page. */
thingportGrabInjected?: boolean;
}
}
// Bumped on every re-init so a status check/mount still in flight for a page the user has already
// navigated away from discards its result instead of mounting a stale icon for the wrong URL.
// Bumped on every re-init so stale in-flight checks don't mount an icon for the wrong URL.
let initToken = 0;
function reportTabIconState(active: boolean): void {
@@ -40,8 +37,7 @@ async function init(): Promise<void> {
const myToken = ++initToken;
const isStale = () => myToken !== initToken;
// A guided MakerWorld collection import in progress for this tab takes over the whole page, on
// every model page it visits -- checked first, since none of the normal detection applies then.
// A guided collection import takes over the whole page.
const jobRes = await send("GET_MAKERWORLD_JOB");
if (isStale()) return;
if (jobRes?.ok && jobRes.data.job) {
@@ -64,9 +60,7 @@ async function init(): Promise<void> {
return;
}
// Not set up yet: still show the icon on a page it could import from, grayed out, so the
// extension is discoverable where it matters -- clicking it explains what's missing and offers
// the setup form. The toolbar icon stays inactive until it's configured.
// Not set up: still show a grayed-out icon on importable pages so the extension is discoverable.
if (!stateRes.data.configured) {
const root = mountHost();
mountFab(root, {
@@ -92,8 +86,7 @@ async function init(): Promise<void> {
library = { state: status.state, printId: status.print_id ?? null };
}
} catch {
// If the status check fails (instance unreachable, bad credentials, etc.) still show the
// icon -- the panel's own error state surfaces the real problem when they try it.
// Show the icon anyway; the panel surfaces the real error.
}
}
if (isStale()) return;
@@ -108,8 +101,7 @@ async function init(): Promise<void> {
});
reportTabIconState(true);
// A guided import that just stopped early always lands back here, on the collection page it
// started from -- surface why immediately rather than leaving the user to wonder.
// A guided import that stopped early lands back here, so show why.
if (jobRes?.ok && jobRes.data.error) {
const { message, imported, total } = jobRes.data.error;
setPanelOpen(true);
@@ -117,10 +109,8 @@ async function init(): Promise<void> {
}
}
// MakerWorld, Printables and Thingiverse are client-side-routed SPAs -- moving from one model to
// another changes location.href without a page load, so this script runs once per *tab*, not per
// page. Patching history.pushState/replaceState covers every History-API router (all three);
// popstate covers back/forward; the interval is a cheap safety net for anything bypassing both.
// All three sites are SPAs, so this script runs once per tab. Patch pushState/replaceState, listen
// for popstate, and poll as a safety net.
function installNavigationWatcher(): void {
let lastHref = location.href;
const onLocationChange = () => {
@@ -144,17 +134,12 @@ function installNavigationWatcher(): void {
if (!window.thingportGrabInjected) {
window.thingportGrabInjected = true;
// Re-evaluate immediately when the setup is saved, the instance changes, or "Extension enabled"
// is flipped in the popup -- otherwise the icon would only catch up on the next navigation.
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local" || !CONFIG_CHANGE_KEYS.some((key) => key in changes)) return;
unmount();
void init();
});
// The guided MakerWorld collection import (background/makerworldJob.ts) asks the page it just
// navigated to for the model's download URL -- resolving it here, from the live page, is what
// keeps that back-to-back loop from tripping MakerWorld's CAPTCHA.
listen<ContentMessages>({
RESOLVE_MAKERWORLD_DOWNLOAD_URL: () => resolveMakerworldDownloadUrl(location.href).catch(() => null),
});
@@ -1,26 +1,16 @@
// MakerWorld client-side download-URL resolution.
//
// Resolves a model's actual file URL from the live page instead of asking the backend to (the
// backend's tryMakerworldCloudApi/resolveMakerworldDownloadUrl both call MakerWorld's resolution
// APIs, the only two places able to trip its CAPTCHA and the resulting 2-hour account-wide lockout,
// see makerworldCaptcha.ts). Doing it here has two real advantages: the fetch is same-origin, so
// the browser attaches the real, current session cookie automatically, and the request looks
// exactly like the page's own JS making it rather than a batch from one server IP. Falls through to
// null on any failure -- a pure enhancement; the backend then resolves it exactly as it always has.
// Resolves the MakerWorld file URL in the page instead of the backend, whose resolution calls are
// what trip MakerWorld's account-wide CAPTCHA. Same-origin fetches carry the real session and look
// like the page's own traffic. Returns null on any failure so the backend can resolve it instead.
import type { ResolvedDownload } from "../../shared/messages";
import { send } from "../../shared/messages";
import { parseMakerworldModelUrl } from "../../shared/urls";
import { makerworldDesignForImport, pickMakerworldInstanceId, readMakerworldDesignForPage } from "./pageData";
// Mirrors every outbound fetch on the backend side (all built on an AbortController timeout) -- a
// plain fetch() has no timeout at all, so a MakerWorld endpoint that stalls or holds an interactive
// challenge open (observed live on a model with extra download options) would otherwise hang this
// resolution -- and everything waiting on it, up to the guided job itself -- forever.
// fetch() has no timeout, and some endpoints hold an interactive challenge open indefinitely.
const MAKERWORLD_API_FETCH_TIMEOUT_MS = 8000;
/** Same field shape the backend's extractDownloadUrlFromResponse checks -- a plain
* {url|downloadUrl|download_url}, optionally nested one level under `data`. */
/** {url|downloadUrl|download_url}, optionally nested under `data`. */
function extractDownloadUrl(data: unknown): string | null {
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
const candidates = [data as Record<string, unknown>];
@@ -41,7 +31,6 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
try {
const headers: Record<string, string> = { Accept: "application/json" };
if (nonce) headers["X-Nonce"] = nonce;
// credentials default to "same-origin" -- the real session cookie rides along automatically.
const res = await fetch(url, { headers, signal: controller.signal });
if (!res.ok) return null;
return await res.json();
@@ -52,10 +41,7 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
}
}
/** Best-effort match for the page's own "Download" button -- text-based (MakerWorld's class names
* are build hashes that change on redeploy), visible elements only so a hidden duplicate (e.g. in
* an unopened dropdown) isn't clicked instead. With several design "instances", the first match is
* the default/selected one. */
/** Matched by text (class names are build hashes), visible elements only. */
function findDownloadButton(): HTMLElement | null {
for (const el of document.querySelectorAll<HTMLElement>("button, a")) {
if (!/^download$/i.test((el.textContent || "").trim())) continue;
@@ -65,9 +51,8 @@ function findDownloadButton(): HTMLElement | null {
return null;
}
/** Clicks the page's real Download button and captures the resulting download's URL, canceling it
* immediately (see background/downloadCapture.ts). Null when there's no button, nothing was
* captured in time, or it was a blob: URL -- always "try the fallback", never a thrown error. */
/** Captures and cancels the download the real button starts (see background/downloadCapture.ts).
* Null means try the fallback. */
async function captureViaRealClick(): Promise<string | null> {
const button = findDownloadButton();
if (!button) return null;
@@ -83,11 +68,10 @@ async function fetchInstanceDownloadUrl(instanceId: string, nonce: string | null
return extractDownloadUrl(await fetchMakerworldApiJson(apiUrl, nonce));
}
/** Mirrors the backend's two-step resolution (importResolvers.ts's resolveMakerworldDownloadUrl)
* -- instance-scoped endpoint first, model-scoped as a fallback -- run from the page instead. */
/** Same two steps as the backend: instance-scoped endpoint, then model-scoped. */
async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | null> {
const page = readMakerworldDesignForPage(pageUrl);
if (!page) return null; // stale or missing page data -- let the backend resolve from the URL
if (!page) return null; // the backend resolves from the URL
const { design, nonce } = page;
const instanceId = pickMakerworldInstanceId(design, page.requestedInstanceId);
if (instanceId) {
@@ -102,14 +86,11 @@ async function resolveFromPageApi(pageUrl: string): Promise<ResolvedDownload | n
: null;
}
/** The single entry point: tries the real-click capture first (not a guess, so far higher
* fidelity), falling back to reconstructing the API call. `pageUrl` is the model page the caller
* means to import, captured before any await. */
/** `pageUrl` is captured by the caller before any await. */
export async function resolveMakerworldDownloadUrl(pageUrl: string): Promise<ResolvedDownload | null> {
const viaClick = await captureViaRealClick().catch(() => null);
if (viaClick) {
// The page's button downloads whichever profile the page has selected, which follows the URL
// hash. Without trustworthy page data, the hash is all there is to go on.
// The button downloads the page's selected profile, which follows the URL hash.
const page = readMakerworldDesignForPage(pageUrl);
if (!page) return { downloadUrl: viaClick, instanceId: parseMakerworldModelUrl(pageUrl)?.requestedInstanceId ?? null };
return {
@@ -121,9 +102,7 @@ export async function resolveMakerworldDownloadUrl(pageUrl: string): Promise<Res
return resolveFromPageApi(pageUrl).catch(() => null);
}
/** One particular print profile's file, resolved from the page like the rest (for importing
* several profiles -- the page's Download button only ever gives the selected one). Null when it
* can't be; the backend then resolves that profile from a #profileId- link itself. */
/** The Download button only gives the selected profile, so other profiles resolve via the API. */
export async function resolveMakerworldProfileDownload(pageUrl: string, instanceId: string): Promise<ResolvedDownload | null> {
const page = readMakerworldDesignForPage(pageUrl);
if (!page) return null;
+9 -28
View File
@@ -1,5 +1,3 @@
// Reading MakerWorld's own page data (Next.js's __NEXT_DATA__) for the model the page shows.
import { parseMakerworldModelUrl } from "../../shared/urls";
export type MakerworldCreator = { uid?: string | number; name?: string };
@@ -11,8 +9,6 @@ export type MakerworldDesign = {
instances?: MakerworldInstance[];
};
/** Which print profiles an import takes -- the panel's "Print profiles" choice. Same meaning as the
* backend's MakerworldProfileScope. */
export type MakerworldProfileScope = "url" | "designer" | "all";
export type MakerworldPage = { design: MakerworldDesign; nonce: string | null; requestedInstanceId: string | null };
@@ -35,7 +31,6 @@ function getPath(obj: unknown, ...keys: string[]): unknown {
return current;
}
/** The design and nonce out of a __NEXT_DATA__ blob, if it's for `designId`. */
function designFromNextData(nextData: unknown, designId: string): Omit<MakerworldPage, "requestedInstanceId"> | null {
const design = getPath(nextData, "props", "pageProps", "design") as MakerworldDesign | undefined;
if (!design || typeof design !== "object" || design.id == null || String(design.id) !== designId) return null;
@@ -43,15 +38,11 @@ function designFromNextData(nextData: unknown, designId: string): Omit<Makerworl
return { design, nonce: typeof nonce === "string" && nonce.trim() ? nonce : null };
}
// Page data fetched for models reached by a client-side route change (see
// loadMakerworldDesignForPage), by design id -- for this tab's lifetime.
// Page data fetched after client-side navigation, by design id.
const fetchedDesigns = new Map<string, Omit<MakerworldPage, "requestedInstanceId">>();
/** The page's MakerWorld design data, or null if missing -- or stale: Next.js only writes
* __NEXT_DATA__ on a full page load, so after a client-side route change from one model to
* another it still describes the first-loaded design, and resolving from it would pair this
* page's metadata with that other model's 3MF. Then the model's own page data, if
* loadMakerworldDesignForPage has fetched it. */
/** Null when missing or stale: Next.js only writes __NEXT_DATA__ on a full page load, so after
* client-side navigation it describes the previous model. Falls back to fetched page data. */
export function readMakerworldDesignForPage(pageUrl: string): MakerworldPage | null {
const expected = parseMakerworldModelUrl(pageUrl);
if (!expected) return null;
@@ -61,10 +52,8 @@ export function readMakerworldDesignForPage(pageUrl: string): MakerworldPage | n
const PAGE_FETCH_TIMEOUT_MS = 10000;
/** readMakerworldDesignForPage, fetching the model's own page when the one in the tab is stale --
* i.e. when the user got here by clicking through MakerWorld, which is the usual way. The same
* request a reload would make (same origin, the user's own session), made once per model: without
* it there's no profile list to offer and nothing to resolve downloads from. */
/** Fetches the model's own page once when the tab's data is stale (the usual case after clicking
* through MakerWorld). */
export async function loadMakerworldDesignForPage(pageUrl: string): Promise<MakerworldPage | null> {
const current = readMakerworldDesignForPage(pageUrl);
if (current) return current;
@@ -88,8 +77,7 @@ export async function loadMakerworldDesignForPage(pageUrl: string): Promise<Make
}
}
/** Same precedence as the backend's resolveMakerworldViaCloudApi: the requested profile (only if
* this design actually has it), then the design's default, then the first one. */
/** The requested profile (if the design has it), then the default, then the first. */
export function pickMakerworldInstanceId(design: MakerworldDesign, requestedInstanceId: string | null): string | null {
const instances = Array.isArray(design.instances) ? design.instances.filter((inst) => inst && inst.id) : [];
if (requestedInstanceId && instances.some((inst) => String(inst.id) === requestedInstanceId)) return requestedInstanceId;
@@ -97,9 +85,7 @@ export function pickMakerworldInstanceId(design: MakerworldDesign, requestedInst
return instances.length ? String(instances[0].id) : null;
}
/** Profile ids to import for `scope` -- mirrors the backend's selectMakerworldProfiles: the
* link's profile (else the default) first, then the designer's own profiles (a profile whose
* instanceCreator is the design's designCreator), or every profile. */
/** Mirrors the backend's selectMakerworldProfiles. */
export function makerworldProfileIds(design: MakerworldDesign, scope: MakerworldProfileScope, requestedInstanceId: string | null): string[] {
const primary = pickMakerworldInstanceId(design, requestedInstanceId);
if (!primary) return [];
@@ -112,13 +98,10 @@ export function makerworldProfileIds(design: MakerworldDesign, scope: Makerworld
return [primary, ...wanted.filter((id) => id !== primary)];
}
// A design's details past this size (a very long description, say) aren't sent -- the request
// would outgrow the backend's JSON body limit, and the backend reads the page itself instead.
// Larger designs would exceed the backend's JSON body limit; it reads the page itself instead.
const MAX_IMPORT_DESIGN_CHARS = 64 * 1024;
/** The parts of the page's design an import uses -- title, tags, description, creator, cover,
* gallery, categories -- sent with the import so the backend doesn't have to fetch this page
* again for them (see the backend's makerworldMetaFromExtension). Null when too big to send. */
/** Sent with the import so the backend doesn't refetch the page. Null when too big. */
export function makerworldDesignForImport(design: MakerworldDesign): Record<string, unknown> | null {
const source = design as Record<string, unknown>;
const out: Record<string, unknown> = {};
@@ -135,8 +118,6 @@ export function makerworldDesignForImport(design: MakerworldDesign): Record<stri
return JSON.stringify(out).length <= MAX_IMPORT_DESIGN_CHARS ? out : null;
}
/** The title of the print profile named in the page URL's hash -- null when there's no hash or
* the page data is stale (see readMakerworldDesignForPage). */
export function currentMakerworldProfileTitle(pageUrl: string): string | null {
const page = readMakerworldDesignForPage(pageUrl);
if (!page || !page.requestedInstanceId || !Array.isArray(page.design.instances)) return null;
+4 -12
View File
@@ -1,16 +1,11 @@
// Full-page covers: the guided MakerWorld import's progress overlay (shown on every model page the
// job drives the tab through) and the collection scan overlay (shown while scrolling a MakerWorld
// collection to its end). Deliberately cover everything, host page included -- there's nothing
// useful to do on the page meanwhile other than wait or abort.
// Full-page overlays for the guided MakerWorld import and the collection scan. They cover the whole
// page on purpose: there's nothing to do meanwhile but wait or abort.
import type { MakerworldJob } from "../shared/messages";
import { send } from "../shared/messages";
import { fillIcons } from "../shared/icon";
// How long the job overlay waits, with no sign the job moved on, before offering the manual
// "Import next" escape hatch -- long enough to never appear during an ordinary step (page load +
// import + the job's pacing delay), short enough that a genuinely stuck step (see
// background/makerworldJob.ts's forceAdvanceJob) doesn't leave someone staring at a frozen bar.
// Long enough never to show during a normal step, short enough for a stuck one.
const JOB_STUCK_REVEAL_MS = 20000;
function overlayHtml(title: string, countHtml: string, extra = ""): string {
@@ -45,8 +40,7 @@ export function mountJobOverlay(root: ShadowRoot, job: MakerworldJob): void {
void send("ABORT_MAKERWORLD_COLLECTION_JOB");
});
// Normally never fires: the page navigates on (tearing this script and its timer down) well
// before then. Only a step still sitting here after the delay gets the button.
// Normally the page navigates on first, tearing this timer down.
const nextBtn = overlay.querySelector<HTMLButtonElement>("[data-action=next]")!;
setTimeout(() => {
nextBtn.hidden = false;
@@ -60,8 +54,6 @@ export function mountJobOverlay(root: ShadowRoot, job: MakerworldJob): void {
export type ScanOverlay = { update(count: number): void; remove(): void };
/** Painted over the icon/panel (same shadow root) while the collection scrolls to its end --
* scrolling the page around while a small corner card updates its text is easy to miss. */
export function mountScanOverlay(root: ShadowRoot): ScanOverlay {
const overlay = document.createElement("div");
overlay.className = "tg-overlay";
+3 -4
View File
@@ -1,5 +1,5 @@
// Batch flow for listing pages whose entries the backend can list (Thingiverse Likes/Collections,
// Printables Collections). MakerWorld collections use the guided flow in makerworldCollection.ts.
// Batch flow for Thingiverse Likes/Collections and Printables Collections. MakerWorld collections
// use the guided flow in makerworldCollection.ts.
import type { BatchEntriesResult, ImportJob } from "../../shared/api";
import { ctx } from "../context";
@@ -67,8 +67,7 @@ async function runBatchImport(): Promise<void> {
async function pollJobWithProgress(jobId: string, instanceUrl: string): Promise<void> {
for (;;) {
// The panel was torn down (e.g. an SPA route change) since the last tick -- stop polling. The
// job itself keeps running server-side either way; this just stops watching it.
// The panel is gone; the job keeps running server-side.
if (!isPanelMounted()) return;
const job = await api<ImportJob>("GET", `/import/jobs/${jobId}`);
if (!isPanelMounted()) return;
@@ -1,5 +1,4 @@
// "Add to collection" picker -- single-model imports only; a batch import lands in Thingport's own
// auto-named collection for that batch, matching the web app.
// Single-model imports only; batch imports get their own auto-named collection.
import type { Collection } from "../../shared/api";
import { api, escapeHtml } from "../runtime";
@@ -17,7 +16,7 @@ export async function collectionPickerHtml(): Promise<string> {
<select id="tg-collection" class="tg-select"><option value="">No collection</option>${options}</select>
`;
} catch {
return ""; // Collections failed to load -- import still works without the picker.
return ""; // import still works without the picker
}
}
-1
View File
@@ -3,7 +3,6 @@ import { loadBatchEntries } from "./batch";
import { loadMakerworldGuidedCollection } from "./makerworldCollection";
import { loadSingleItem } from "./single";
/** Loads the panel's first step for the current page, the first time the panel is opened. */
export async function loadPanel(): Promise<void> {
const { kind, provider } = ctx().classification;
if (kind === "single") await loadSingleItem();
@@ -1,9 +1,6 @@
// MakerWorld guided collection import. MakerWorld (unlike the other providers) trips a multi-hour
// account-wide CAPTCHA lockout from a burst of collection-import API calls, even with pacing -- see
// background/makerworldJob.ts. This avoids MakerWorld's collection-listing API entirely: every
// model id is scraped from the page's own DOM after scrolling it to the end -- the same requests
// MakerWorld's site already makes for a person scrolling by hand. No entry-selection step (unlike
// the batch flow) -- every not-yet-imported model is queued.
// MakerWorld collection import. Avoids MakerWorld's collection-listing API (a burst of those calls
// trips an account-wide CAPTCHA) by scrolling the page and scraping model ids from the DOM.
// Every not-yet-imported model is queued.
import type { Collection, ImportStatus } from "../../shared/api";
import { request } from "../../shared/messages";
@@ -14,16 +11,13 @@ import { api, escapeHtml, sleep } from "../runtime";
import { getShadowRoot, onPanelAction, renderPanel } from "../shell";
import { errorHtml, statusHtml, successHtml } from "./results";
// Wait before the first scroll/check: the page can still be mid-hydration (most often right after
// navigating back from a finished guided run), and checking too early can see zero cards, which the
// stagnant-round check below would mistake for "already at the end".
// The page may still be hydrating; checking too early sees zero cards and looks like the end.
const SCAN_START_DELAY_MS = 1500;
const SCAN_ROUND_DELAY_MS = 700;
const SCAN_MAX_ROUNDS = 300; // generous: 153 models at ~20/page is ~8 loads
const SCAN_STAGNANT_LIMIT = 6;
/** True once MakerWorld's own "No more data" end-of-list marker shows -- matched on rendered text,
* not a class name (MakerWorld's CSS-module classes are build hashes that change on redeploy). */
/** Matched on text: MakerWorld's class names are build hashes. */
function hasNoMoreDataMarker(): boolean {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
@@ -32,7 +26,6 @@ function hasNoMoreDataMarker(): boolean {
return false;
}
/** Every distinct design id linked from the DOM -- a card links to "/en/models/1954043-...". */
function extractModelIds(): string[] {
const ids = new Set<string>();
for (const a of document.querySelectorAll('a[href*="/models/"]')) {
@@ -42,10 +35,7 @@ function extractModelIds(): string[] {
return [...ids];
}
/** Repeatedly scrolls the last loaded card into view -- the standard nudge for an
* intersection-observer infinite scroll, and more reliable than scrolling the window when the real
* scroll container is some nested element. Stops on the "No more data" marker, or once the count
* stops growing for several rounds (a safety net if that wording changes); never loops forever. */
/** Scrolls the last card into view until the end marker shows or the count stops growing. */
async function scrollToEnd(onProgress: (count: number) => void): Promise<void> {
let lastCount = -1;
let stagnantRounds = 0;
@@ -65,9 +55,7 @@ async function scrollToEnd(onProgress: (count: number) => void): Promise<void> {
}
}
/** This user's Thingport collection matching the MakerWorld collection's title (case-insensitive,
* like the backend's uniqueness check), created if missing. Null on any failure -- the import
* still proceeds, it just has nowhere to file into. */
/** Case-insensitive match on title, created if missing. Null on failure; the import proceeds. */
async function findOrCreateCollection(name: string | null): Promise<string | null> {
const trimmed = (name || "").trim();
if (!trimmed) return null;
@@ -135,8 +123,7 @@ async function startGuidedImport(toImport: ModelStatus[], alreadyImported: Model
renderPanel(statusHtml("Preparing your collection…"));
const collectionId = await findOrCreateCollection(collectionTitle);
// Already-imported models are filed here, synchronously, rather than by the background job --
// they need no page visit, so there's nothing to pace.
// Already-imported models need no page visit, so they're filed here directly.
if (alreadyImported.length && collectionId) {
renderPanel(statusHtml(`Adding ${alreadyImported.length} existing model${alreadyImported.length === 1 ? "" : "s"} to your collection…`));
for (const entry of alreadyImported) {
@@ -151,5 +138,4 @@ async function startGuidedImport(toImport: ModelStatus[], alreadyImported: Model
renderPanel(statusHtml("Starting guided import…"));
await request("START_MAKERWORLD_COLLECTION_JOB", { urls: toImport.map((e) => e.url), collectionId, originalUrl });
// The tab is about to navigate to the first model's page; the job overlay takes over from there.
}
+13 -34
View File
@@ -1,6 +1,5 @@
// Single-model flow. Thingiverse Things and Printables Models go straight to import; a MakerWorld
// model link (and any other generic single link) goes through /import/inspect first -- mirroring
// the web app's useUploadImport.tsx branching.
// Thingiverse and Printables import directly; other links go through /import/inspect first, like
// the web app's useUploadImport.tsx.
import type { InspectResult, ZipEntriesResult } from "../../shared/api";
import { request } from "../../shared/messages";
@@ -17,11 +16,8 @@ import { onPanelAction, panelQuery, panelQueryAll, renderPanel } from "../shell"
import { collectionPickerHtml, selectedCollectionId } from "./collectionPicker";
import { errorHtml, statusHtml, successHtml } from "./results";
/** Best-effort model name for the heading, read from the page -- only for pages that skip
* /import/inspect (and so never get its resolved `title`). The page's own <h1> first: both sites
* are SPAs, and Thingiverse never updates og:title on client-side navigation (it stays the site's
* generic "Thingiverse - The community for Open Hardware"), while Printables' carries a
* "| Download free STL model | Printables.com" suffix. */
/** Model name for pages that skip /import/inspect. The <h1> first: Thingiverse's og:title goes
* stale on SPA navigation and Printables' has a suffix. */
function guessPageTitle(): string | null {
const h1 = document.querySelector("h1")?.textContent?.trim();
if (h1) return h1;
@@ -29,14 +25,10 @@ function guessPageTitle(): string | null {
return og || document.title.trim() || null;
}
// Between importing one print profile and the next: the resolution requests come from the page
// itself, but several in a quick burst still read as automated to MakerWorld.
// Several profile resolutions in a quick burst still look automated to MakerWorld.
const PROFILE_GAP_MS = 2000;
/** The "Print profiles" choice, on a MakerWorld model page with more than one profile: the one
* selected on the page (the default -- the web app calls it "Print profile from the link"),
* then whichever of "all the designer's" and "the designer's and the community's" would import
* more than that -- the latter only when there are community ones. */
/** The page's selected profile first, then "designer's" and "all" where they'd import more. */
async function profilesPickerHtml(): Promise<string> {
const { url, classification } = ctx();
if (classification.provider !== "makerworld" || classification.type !== "model") return "";
@@ -59,7 +51,6 @@ function selectedProfileScope(): MakerworldProfileScope {
return value === "designer" || value === "all" ? value : "url";
}
/** `url` pointing at one particular print profile. */
function profileUrl(url: string, instanceId: string): string {
return `${url.split("#")[0]}#profileId-${instanceId}`;
}
@@ -70,8 +61,7 @@ function importHeading(): string {
}
export async function loadSingleItem(): Promise<void> {
// Nothing inspect would tell us matters for an add-profile (a MakerWorld profile is always a
// single 3MF), and inspecting would make the backend resolve the download just to show the panel.
// A MakerWorld profile is always one 3MF, so inspecting would only cost a download resolution.
if (ctx().library) {
renderPanel(addProfileHtml(await profilesPickerHtml()));
onPanelAction("import", () => void runDirectImport());
@@ -79,9 +69,6 @@ export async function loadSingleItem(): Promise<void> {
}
renderPanel(statusHtml("Checking link…"));
const { provider, type } = ctx().classification;
// Printables' generic page-fetch flow is Cloudflare-gated and a Thingiverse Thing always resolves
// through its own API path -- see useUploadImport.tsx's identical special-case. Neither needs (or
// can safely use) /import/inspect.
const skipInspect = (provider === "thingiverse" && type === "thing") || (provider === "printables" && type === "model");
let zipFilename: string | null = null;
@@ -120,9 +107,7 @@ export async function loadSingleItem(): Promise<void> {
onPanelAction("choose-files", () => void loadZipEntries());
}
/** The model's already in the library, but this page's print profile isn't known to be -- each
* MakerWorld profile has its own 3MF, stored as its own file on the one model. No collection
* picker: the model's collections are already whatever they are. */
/** The model's in the library but this profile may not be. */
function addProfileHtml(profilesPicker: string): string {
const { library, url, instanceUrl } = ctx();
const profileName = currentMakerworldProfileTitle(url);
@@ -173,8 +158,7 @@ async function loadZipEntries(): Promise<void> {
}
async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
// Captured up front: if the page navigates (an SPA route change) while the import is in flight,
// the context is cleared from under this still-running function (see unmount in index.ts).
// Captured up front: SPA navigation clears the context mid-import.
const collectionId = selectedCollectionId();
const scope = selectedProfileScope();
if (scope !== "url") {
@@ -183,15 +167,12 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
}
const { url, instanceUrl, classification, title } = ctx();
renderPanel(statusHtml("Importing…"));
// Only meaningful for a MakerWorld model page -- see downloadResolver.ts for why resolving it
// here beats leaving it to the backend.
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl(url).catch(() => null)
: null;
try {
// One message, not two: the import and (per collectionId) filing it into a collection both
// run to completion in the background even if this page is gone by the time it finishes.
// One message so import and collection filing finish even if the page is gone.
const print = await request("IMPORT_SINGLE", { url, entries: opts?.entries, collectionId, resolved, title });
const link = print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`;
if (print?.import_outcome === "profile_added") {
@@ -206,10 +187,8 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
}
}
/** Imports several print profiles of the MakerWorld model on this page, one after another: the
* first creates the model (or finds it in the library), each later one is added to it as another
* file -- the backend skips any it already has. Each profile's file is resolved in the page, as
* for a single import. Stops early on a MakerWorld CAPTCHA, which would fail every later one. */
/** The first profile creates (or finds) the model; later ones are added as files. Stops on a
* CAPTCHA, which would fail every later one. */
async function runProfilesImport(scope: MakerworldProfileScope, collectionId: string | null): Promise<void> {
const { url, instanceUrl, title } = ctx();
const page = await loadMakerworldDesignForPage(url);
@@ -227,7 +206,7 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
for (const [index, instanceId] of ids.entries()) {
renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`));
if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS));
// The first is the link's own profile: the page's real Download button gives exactly that one.
// The page's Download button gives exactly the link's profile.
const resolved =
index === 0
? await resolveMakerworldDownloadUrl(url).catch(() => null)
+1 -2
View File
@@ -1,7 +1,6 @@
import { request } from "../shared/messages";
/** Authenticated call to the user's Thingport instance, proxied through the background (which owns
* the credentials and host permission) -- resolves with the parsed JSON or throws its error. */
/** Proxied through the background, which owns the credentials and host permission. */
export function api<T>(method: string, path: string, body?: unknown): Promise<T> {
return request("API_CALL", { method, path, body }) as Promise<T>;
}
+2 -6
View File
@@ -1,9 +1,5 @@
// The grayed-out icon's dialog (supported page, extension not configured yet). Explains what's
// missing and hands off to the extension's own setup form (background/setup.ts) rather than
// collecting anything here: saving needs a host-permission prompt only an extension page can show,
// and the password never goes into this page's DOM. Saving writes the config to storage, which
// index.ts's storage listener turns into unmount() + init(): the dialog goes away and the normal,
// active icon takes its place.
// The grayed-out icon's dialog. Setup happens in an extension page: saving needs a host-permission
// prompt, and the password never enters this page's DOM.
import { send } from "../shared/messages";
import { fillIcons } from "../shared/icon";
+4 -10
View File
@@ -1,6 +1,4 @@
// The shadow-rooted host every piece of in-page UI lives in (the floating icon, its panel, the
// setup modal, the full-page overlays), so none of it is styled by -- or styles -- the provider's
// own page.
// Shadow-rooted host for all in-page UI, isolated from the provider page's styles.
import { createIcon } from "../shared/icon";
import css from "./styles/content.scss?inline";
@@ -46,14 +44,13 @@ export function mountFab(root: ShadowRoot, { variant, label, title, onClick }: F
root.appendChild(button);
}
/** The panel above the icon. `onFirstOpen` runs once, the first time it's opened. Returns the
* toggle the icon's click handler calls. */
/** `onFirstOpen` runs once. Returns the toggle for the icon's click handler. */
export function mountPanel(root: ShadowRoot, onFirstOpen: () => void): () => void {
const panel = document.createElement("div");
panel.className = "tg-panel";
panel.hidden = true;
// Outside contentEl (renderPanel's target) so it survives every re-render.
// Outside contentEl so it survives re-renders.
const closeBtn = document.createElement("button");
closeBtn.className = "tg-close";
closeBtn.type = "button";
@@ -84,15 +81,12 @@ export function setPanelOpen(open: boolean): void {
if (panelEl) panelEl.hidden = !open;
}
/** Whether the panel still exists -- false once unmountHost() ran (e.g. an SPA route change). */
export function isPanelMounted(): boolean {
return contentEl !== null;
}
export function renderPanel(html: string): void {
// A no-op, not a bug, once the panel's been torn down mid-flow -- a still-in-flight step (like
// the batch progress poll) has nothing left to draw into, but the request/job it's watching keeps
// running regardless (see background/importJobs.ts).
// The panel may have been torn down mid-flow; the background work continues regardless.
if (!contentEl) return;
contentEl.innerHTML = html;
}
+4 -10
View File
@@ -1,6 +1,5 @@
// Toolbar popup (also opened as a tab by background/setup.ts when the browser won't open the popup
// for the in-page setup dialog). Shows the setup form until the extension is configured, then the
// enable switch and the recent-imports strip.
// Toolbar popup (or a tab, when the browser won't open the popup): the setup form until configured,
// then the enable switch and recent imports.
import { fillIcons } from "../shared/icon";
import { send, type ExtensionState } from "../shared/messages";
@@ -9,14 +8,11 @@ import { clearError, els, showError } from "./dom";
import { loadRecentImports } from "./recentImports";
import "./styles/popup.scss";
// Set when background/setup.ts had to open this page in a tab from a page's setup dialog: once
// saved, go back to that page -- its icon has already switched to the active one -- instead of
// leaving this tab behind.
// When opened as a tab from a page's setup dialog, return there once saved.
const returnTabId = Number.parseInt(new URLSearchParams(location.search).get("returnTab") ?? "", 10);
fillIcons(document);
/** The instance URL as shown under the title -- the scheme is noise at this size. */
function displayInstanceUrl(instanceUrl: string): string {
return instanceUrl.replace(/^https?:\/\//i, "");
}
@@ -88,9 +84,7 @@ els.setupForm.addEventListener("submit", async (event) => {
els.saveBtn.disabled = true;
els.saveBtn.textContent = "Saving…";
try {
// Must be called right here, not in the background's SAVE_CONFIG handler --
// permissions.request() needs the user gesture this submit carries, which a sendMessage hop
// would lose.
// Must happen here: permissions.request() needs this submit's user gesture.
const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
if (!granted) {
showError("Thingport Grab needs permission to reach this instance to work.");
+1 -4
View File
@@ -1,5 +1,3 @@
// The "Recent imports" strip -- read from extension storage only (see background/recentImports.ts).
import { send, type RecentImport } from "../shared/messages";
import { els } from "./dom";
@@ -9,8 +7,7 @@ function recentItemEl(item: RecentImport): HTMLAnchorElement {
link.href = item.url;
link.title = item.title || "";
link.setAttribute("aria-label", item.title || "Imported model");
// Opened from here rather than a plain target="_blank": a link click inside an extension popup
// isn't guaranteed to open a tab, and the popup should close once it has.
// A plain link click in a popup isn't guaranteed to open a tab, and the popup should close after.
link.addEventListener("click", (event) => {
event.preventDefault();
void chrome.tabs.create({ url: item.url });
+1 -2
View File
@@ -1,5 +1,4 @@
// The slices of Thingport's API responses this extension reads. Not exhaustive -- only the fields
// actually used here; see the backend's dto.ts for the full shapes.
// Only the fields this extension reads; see the backend's dto.ts for the full shapes.
export type ImportOutcome = "created" | "profile_added" | "already_imported";
+2 -5
View File
@@ -2,10 +2,8 @@ import iconSvg from "../assets/thingport-icon-color.svg";
let template: SVGSVGElement | null = null;
/** A fresh, decorative copy of the Thingport icon as an inline <svg> node -- inline so nothing has
* to be web-accessible or pass the host page's image CSP. The <title> (whose id would repeat when
* the icon appears more than once in a document) is dropped, and it's hidden from assistive tech
* since every use sits next to its own text or aria-label. */
/** Inline so nothing needs to be web-accessible or pass the page's image CSP. The <title> is
* dropped (its id would repeat) and the icon is aria-hidden. */
export function createIcon(): SVGSVGElement {
if (!template) {
const parsed = new DOMParser().parseFromString(iconSvg, "image/svg+xml").documentElement as unknown as SVGSVGElement;
@@ -19,7 +17,6 @@ export function createIcon(): SVGSVGElement {
return document.importNode(template, true);
}
/** Fills every `[data-icon]` placeholder under `root` with the icon. */
export function fillIcons(root: ParentNode): void {
for (const slot of root.querySelectorAll("[data-icon]")) slot.replaceChildren(createIcon());
}
+5 -19
View File
@@ -1,7 +1,5 @@
// Typed runtime-message protocol between the content script / popup and the background script,
// plus the one message the background sends *to* a content script. Every reply has the same
// `{ ok, data } | { ok: false, error }` envelope, so callers either inspect it (`send`) or let it
// throw (`request`).
// Typed runtime messages between the content script/popup and the background. Replies use a
// `{ ok, data } | { ok: false, error }` envelope: `send` returns it, `request` throws on error.
import type { Print } from "./api";
@@ -16,10 +14,8 @@ export type RecentImport = {
email: string;
};
/** A MakerWorld file URL resolved from the live page, with the print profile it belongs to (null
* when unknown). See content/makerworld/downloadResolver.ts. */
/** `design` is the page's own design data for the import (see makerworldDesignForImport) --
* absent when the page data couldn't be trusted to be this model's. */
/** See content/makerworld/downloadResolver.ts. */
/** `design` is absent when the page data couldn't be trusted to be this model's. */
export type ResolvedDownload = { downloadUrl: string; instanceId: string | null; design?: Record<string, unknown> | null };
export type ImportSinglePayload = {
@@ -38,8 +34,6 @@ export type MakerworldJob = {
index: number;
imported: number;
total: number;
/** True exactly while a navigation this job triggered is in flight -- see
* background/makerworldJob.ts. */
awaitingLoad: boolean;
};
@@ -47,13 +41,11 @@ export type MakerworldJobError = { message: string; imported: number; total: num
export type ApiCallPayload = { method: string; path: string; body?: unknown };
/** Messages handled by the background script: payload in, `data` out. */
export type BackgroundMessages = {
GET_STATE: { payload: void; result: ExtensionState };
SAVE_CONFIG: { payload: { instanceUrl: string; email: string; password: string }; result: null };
SET_DISABLED: { payload: { disabled: boolean }; result: null };
GET_RECENT_IMPORTS: { payload: void; result: RecentImport[] };
/** "popup" if the toolbar popup opened, "tab" if the setup form opened in a tab instead. */
OPEN_SETUP: { payload: void; result: "popup" | "tab" };
SET_TAB_ICON_STATE: { payload: { active: boolean }; result: null };
API_CALL: { payload: ApiCallPayload; result: unknown };
@@ -66,11 +58,9 @@ export type BackgroundMessages = {
FORCE_ADVANCE_MAKERWORLD_JOB: { payload: void; result: null };
ARM_DOWNLOAD_CAPTURE: { payload: void; result: null };
AWAIT_DOWNLOAD_CAPTURE: { payload: void; result: string | null };
/** The job driving this tab, if any; otherwise a just-stopped job's error (read once). */
GET_MAKERWORLD_JOB: { payload: void; result: { job: MakerworldJob | null; error: MakerworldJobError | null } };
};
/** Messages the background sends to a tab's content script. */
export type ContentMessages = {
RESOLVE_MAKERWORLD_DOWNLOAD_URL: { payload: void; result: ResolvedDownload | null };
};
@@ -83,7 +73,6 @@ type PayloadArgs<P> = [P] extends [void] ? [] : [P];
export type BackgroundType = keyof BackgroundMessages;
export type BackgroundResult<K extends BackgroundType> = BackgroundMessages[K]["result"];
/** Sends to the background and returns the raw reply envelope. */
export function send<K extends BackgroundType>(
type: K,
...payload: PayloadArgs<BackgroundMessages[K]["payload"]>
@@ -92,7 +81,6 @@ export function send<K extends BackgroundType>(
return chrome.runtime.sendMessage(message);
}
/** Sends to the background and resolves with `data`, or throws the reply's error. */
export async function request<K extends BackgroundType>(
type: K,
...payload: PayloadArgs<BackgroundMessages[K]["payload"]>
@@ -116,9 +104,7 @@ type Handler<M extends AnyMessages, K extends keyof M> = (
) => Promise<M[K]["result"]> | M[K]["result"];
export type Handlers<M extends AnyMessages> = { [K in keyof M]: Handler<M, K> };
/** Registers one runtime.onMessage listener that dispatches to `handlers` by message type and
* wraps the result (or thrown error) in the reply envelope. Message types not in `handlers` are
* left for other listeners. */
/** Message types not in `handlers` are left for other listeners. */
export function listen<M extends AnyMessages>(handlers: Partial<Handlers<M>>): void {
chrome.runtime.onMessage.addListener((message: { type?: string; payload?: unknown }, sender, sendResponse) => {
const handler = message && typeof message.type === "string" ? handlers[message.type as keyof M] : undefined;
+2 -9
View File
@@ -1,5 +1,4 @@
// chrome.storage.local keys and the shape stored under them. Everything the extension persists
// lives in extension-local storage -- nothing is synced across browsers.
// chrome.storage.local keys and their shapes. Nothing is synced across browsers.
export const STORAGE_KEYS = {
instanceUrl: "instanceUrl",
@@ -8,9 +7,7 @@ export const STORAGE_KEYS = {
disabled: "disabled",
token: "token",
tokenExpiresAt: "tokenExpiresAt",
// The last MakerWorld `token` cookie value pushed to this account's Thingport-stored
// makerworld_cookie -- lets the cookie sync skip a redundant PATCH when the live browser cookie
// hasn't changed since. See background/makerworldCookie.ts.
// Lets the cookie sync skip a redundant PATCH.
lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie",
} as const;
@@ -24,8 +21,6 @@ export type StoredConfig = {
lastSyncedMakerworldCookie?: string;
};
/** Keys whose change means "re-evaluate whether/how this page shows the icon" -- see
* content/index.ts's storage listener. */
export const CONFIG_CHANGE_KEYS: readonly string[] = [
STORAGE_KEYS.instanceUrl,
STORAGE_KEYS.email,
@@ -33,8 +28,6 @@ export const CONFIG_CHANGE_KEYS: readonly string[] = [
STORAGE_KEYS.disabled,
];
/** Strips a trailing slash so `${instanceUrl}/api/...` never ends up with a doubled slash,
* regardless of whether the user typed one when saving the URL in the popup. */
export function normalizeInstanceUrl(raw: string | undefined | null): string {
return (raw || "").trim().replace(/\/+$/, "");
}
+3 -13
View File
@@ -1,8 +1,4 @@
// Provider URL recognition. Deliberately duplicated from
// frontend/src/components/uploads/useUploadImport.tsx (the web app's own import-link classifier)
// and backend/src/services/{makerworldCloudApi,thingiverseApi,printablesApi}.ts (the single-model
// URL parsers) rather than shared across the project boundary -- keep them in sync by hand if a
// provider ever changes its URL shape.
// Provider URL recognition, duplicated from the frontend and backend parsers. Keep in sync by hand.
export type Provider = "makerworld" | "thingiverse" | "printables";
@@ -32,15 +28,11 @@ export function parseMakerworldModelUrl(url: string): { designId: string; reques
if (!parsed || !parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
const m = parsed.pathname.match(/\/models?\/(\d+)/i);
if (!m) return null;
// The print profile picked on the page, if any (e.g. #profileId-123456) -- same as the
// backend's parseMakerworldModelUrl.
const hashMatch = parsed.hash.match(/profileid-(\d+)/i);
return { designId: m[1], requestedInstanceId: hashMatch ? hashMatch[1] : null };
}
/** Mirrors the backend's buildImportSourceUrl (importService.ts) for MakerWorld -- lets the guided
* collection import turn a bare design id into a real page URL to navigate the tab to, and to
* check import status for, without a round trip through the backend just to reconstruct it. */
/** Mirrors the backend's buildImportSourceUrl. */
export function makerworldModelUrl(designId: string): string {
return `https://makerworld.com/en/models/${designId}`;
}
@@ -83,9 +75,7 @@ export function isPrintablesCollectionUrl(url: string): boolean {
return Boolean(parsed && isHost(parsed, "printables.com") && /\/collections\/\d+/i.test(parsed.pathname));
}
/** Classifies a page for the floating icon's behavior. Returns null for anything not recognized
* (icon stays hidden). `kind: "single"` pages get the already-imported dedup check before
* showing the icon; `kind: "batch"` pages (a listing of many designs) always show it. */
/** Null keeps the icon hidden. "single" pages get a dedup check; "batch" pages always show it. */
export function classifyUrl(url: string): Classification | null {
if (isMakerworldCollectionUrl(url)) return { kind: "batch", provider: "makerworld", type: "collection" };
if (isThingiverseLikesUrl(url)) return { kind: "batch", provider: "thingiverse", type: "likes" };
+2 -3
View File
@@ -1,6 +1,5 @@
// esbuild loaders (see scripts/build.ts): an .svg import is its markup as a string (inlined, so
// nothing has to be web-accessible or pass the host page's image CSP), and a .scss import with
// `?inline` is the compiled CSS as a string (for the content script's shadow root).
// esbuild loaders (see scripts/build.ts): .svg imports are markup strings, `?inline` .scss imports
// are compiled CSS strings.
declare module "*.svg" {
const markup: string;
export default markup;