mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Web app: bring designs saved at the old address over to app.solidcraft.io (#11630)
* Web app: a page that hands designs saved at the old address to app.solidcraft.io The stable web app moved from docs.solidcraft.io/app/ to app.solidcraft.io, and a browser keeps storage per address, so designs saved at the old one don't appear at the new one. The docs site gets app-transfer.html: framed by the move page at app.solidcraft.io it answers that page's requests, and opened on its own it offers each design as a file. It only reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Web app: name the browsers the one-step move was checked in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
# <site>/app-rc/ release candidate: the web build of the newest published v*-rc.* pre-release
|
||||
# <site>/app-edge/ edge: <edge-build>, built from main in this run
|
||||
#
|
||||
# It also adds app-transfer.html and app-transfer.js at the top of the site (packaging/web/transfer):
|
||||
# how designs saved at /app/ get to https://app.solidcraft.io/, in the browser's storage for this
|
||||
# address. It stays while people may still have designs here.
|
||||
#
|
||||
# Stable and the release candidate are not rebuilt: the release workflow already attaches
|
||||
# solidcraft-web-<version>.zip (packaging/web/package.sh, built by that tag's own workflow), and
|
||||
# a deploy takes that zip as it is. Rebuilding old tags here would need each tag's own build steps
|
||||
@@ -20,6 +24,9 @@ site="${1:?site directory required}"
|
||||
edge="${2:?edge build directory required}"
|
||||
repo="${GITHUB_REPOSITORY:?}"
|
||||
|
||||
# Outside /app/, so the app's service worker has no part in it.
|
||||
cp "$(dirname "${BASH_SOURCE[0]}")"/web/transfer/app-transfer.{html,js} "$site"/
|
||||
|
||||
# Version order, newest last: 0.31.0-rc.2 sorts after 0.31.0-rc.1 and before 0.31.0.
|
||||
version_sort() { sed 's/-/~/' | sort -V | sed 's/~/-/'; }
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>Save your SolidCraft designs as files</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 16px/1.5 system-ui, sans-serif; max-width: 44rem; margin: 2.5rem auto; padding: 0 1rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
||||
th, td { text-align: left; padding: .4rem .6rem .4rem 0; border-bottom: 1px solid #8884; }
|
||||
td:first-child { overflow-wrap: anywhere; }
|
||||
td.size, th:nth-child(2) { white-space: nowrap; }
|
||||
button { font: inherit; padding: .2rem .7rem; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="page" hidden>
|
||||
<h1>Save your SolidCraft designs as files</h1>
|
||||
<p>This page is at the old address of the SolidCraft web app. It lists the designs this browser
|
||||
keeps for that address, so you can save each one as a file and open it at
|
||||
<a href="https://app.solidcraft.io/">app.solidcraft.io</a> with File > Open. Nothing here is
|
||||
sent anywhere, and nothing at the old address is changed or deleted.</p>
|
||||
<p id="status" role="status">Looking for designs…</p>
|
||||
<table id="table" hidden>
|
||||
<thead><tr><th>Design</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<p>Chrome and Firefox can do this for you in one step:
|
||||
<a href="https://app.solidcraft.io/move-designs.html">bring your designs over to the new address</a>.</p>
|
||||
<p>The <a href="/app/">old app</a> can also do this: File > Files in this browser…, then Download beside each design.</p>
|
||||
</main>
|
||||
<noscript>This page needs JavaScript to read the designs your browser keeps for this address.</noscript>
|
||||
<script src="app-transfer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,237 @@
|
||||
// The old address's side of moving designs out of the browser's storage for
|
||||
// https://docs.solidcraft.io/app/ (browsers keep storage per address).
|
||||
//
|
||||
// In a frame of the exact origin NEW_ORIGIN (its move-designs.html, in tangency-labs/solidcraft-app)
|
||||
// this answers that page's requests and shows nothing. Opened on its own it lists the designs with
|
||||
// a button to save each as a file, for browsers that keep a frame's storage apart from the
|
||||
// address's own (Safari may; it couldn't be tested). Framed by anything else it does nothing.
|
||||
//
|
||||
// It only ever reads: no write and no delete. It leaves the remembered disk files (IndexedDB) alone:
|
||||
// a browser only lets the origin that picked a file use its handle, and the file is on the disk anyway.
|
||||
//
|
||||
// GitHub Pages can't send headers and frame-ancestors doesn't work in a meta tag, so anyone can
|
||||
// frame this page. That doesn't reach the designs: every answer is posted with NEW_ORIGIN as its
|
||||
// target, so the browser delivers it only to a parent of that origin, and a request is only heard
|
||||
// from that parent. The ancestor check adds a refusal when another page sits above the parent.
|
||||
"use strict";
|
||||
(() => {
|
||||
const NEW_ORIGIN = "https://app.solidcraft.io";
|
||||
const PROTOCOL = 1;
|
||||
/** solidcraft_io::MAX_DESIGN_BYTES: the largest design the app opens. */
|
||||
const MAX_FILE_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_FILES = 5000;
|
||||
/** The format number of a larger file isn't worth reading into memory. */
|
||||
const MAX_FORMAT_SCAN_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_SETTING_CHARS = 8 * 1024 * 1024;
|
||||
/** apps/solidcraft-web/src/files.rs: the stable channel's names, newest first. */
|
||||
const LOCAL_PREFIXES = ["solidcraft.file.", "solvecraft.file."];
|
||||
const RECOVERY = ["recovery.sc", "recovery.solidcraft", "recovery.solvecraft"];
|
||||
const RECOVERY_META = "recovery.json";
|
||||
/** Everything else in this site's local storage belongs to the documentation. */
|
||||
const SETTINGS = ["solidcraft.prefs", "solvecraft.prefs", "solidcraft.webgl", "egui_memory_ron"];
|
||||
|
||||
const storage = () => {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null; // Blocked.
|
||||
}
|
||||
};
|
||||
|
||||
/** A local-storage file (files.rs `decode`): `t` and text, or `x` and hex. */
|
||||
function decodeLocal(value) {
|
||||
if (value.startsWith("t")) return new Blob([value.slice(1)]);
|
||||
const hex = value.startsWith("x") ? value.slice(1) : "";
|
||||
const bytes = new Uint8Array(Math.floor(hex.length / 2));
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(2 * i, 2), 16);
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
|
||||
/** Every stored file: name → its blob and when it was written (ms, 0 when unknown). */
|
||||
async function storedFiles() {
|
||||
const files = new Map();
|
||||
let root = null;
|
||||
try {
|
||||
root = await navigator.storage.getDirectory();
|
||||
} catch {
|
||||
// No private file storage: only local storage can hold designs here.
|
||||
}
|
||||
if (root) {
|
||||
// Pre-release channels keep folders of their own: only the stable app's files are in the root.
|
||||
for await (const handle of root.values()) {
|
||||
if (handle.kind !== "file" || handle.name.endsWith(".crswap")) continue;
|
||||
if (files.size >= MAX_FILES) break;
|
||||
const file = await handle.getFile().catch(() => null);
|
||||
if (file) files.set(handle.name, { blob: file, modified: file.lastModified });
|
||||
}
|
||||
}
|
||||
const local = storage();
|
||||
if (local) {
|
||||
for (const prefix of LOCAL_PREFIXES) {
|
||||
for (let i = 0; i < local.length && files.size < MAX_FILES; i++) {
|
||||
const key = local.key(i);
|
||||
if (!key || !key.startsWith(prefix)) continue;
|
||||
const name = key.slice(prefix.length);
|
||||
if (!files.has(name)) files.set(name, { blob: decodeLocal(local.getItem(key) ?? ""), modified: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The design format of a design file (`solidcraft/N`), or null when it can't be told. */
|
||||
async function formatOf(blob) {
|
||||
if (blob.size > MAX_FORMAT_SCAN_BYTES) return null;
|
||||
try {
|
||||
const head = new Uint8Array(await blob.slice(0, 512).arrayBuffer());
|
||||
const view = new DataView(head.buffer);
|
||||
let text = null;
|
||||
if (head.length >= 30 && view.getUint32(0, true) === 0x04034b50) {
|
||||
// A container: a zip whose first entry is document.json (crates/io/src/design.rs).
|
||||
const nameLength = view.getUint16(26, true);
|
||||
const name = new TextDecoder().decode(head.subarray(30, 30 + nameLength));
|
||||
const sizeInHeader = (view.getUint16(6, true) & 8) === 0;
|
||||
if (name !== "document.json" || !sizeInHeader) return null;
|
||||
const start = 30 + nameLength + view.getUint16(28, true);
|
||||
const body = blob.slice(start, start + view.getUint32(18, true));
|
||||
const method = view.getUint16(8, true);
|
||||
if (method === 0) text = await body.text();
|
||||
else if (method === 8 && typeof DecompressionStream === "function") {
|
||||
const inflated = body.stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
text = await new Response(inflated).text();
|
||||
}
|
||||
} else if (head[0] === 0x7b) {
|
||||
text = await blob.text();
|
||||
}
|
||||
if (text === null) return null;
|
||||
const match = /^(?:solidcraft|solvecraft)\/(\d+)$/.exec(JSON.parse(text).format ?? "");
|
||||
return match ? Number(match[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app would show or recover: the autosave counts when it's the one the app would
|
||||
* offer, which takes its recovery.json too (files.rs `start`).
|
||||
*/
|
||||
async function listing() {
|
||||
const files = await storedFiles();
|
||||
const meta = files.get(RECOVERY_META);
|
||||
const autosave = meta ? (RECOVERY.find((name) => files.has(name)) ?? null) : null;
|
||||
let autosaveName = "";
|
||||
if (autosave && meta.blob.size < 1024 * 1024) {
|
||||
autosaveName = await meta.blob.text().then((t) => JSON.parse(t).name ?? "").catch(() => "");
|
||||
}
|
||||
const entries = new Map();
|
||||
for (const [name, file] of files) {
|
||||
if (name === RECOVERY_META || (RECOVERY.includes(name) && name !== autosave)) continue;
|
||||
entries.set(name, { name, ...file, autosave: name === autosave, autosaveName: name === autosave ? String(autosaveName) : "" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function answer(message) {
|
||||
switch (message.op) {
|
||||
case "hello":
|
||||
return {};
|
||||
case "list": {
|
||||
const out = [];
|
||||
for (const e of (await listing()).values()) {
|
||||
out.push({
|
||||
name: e.name,
|
||||
size: e.blob.size,
|
||||
modified: e.modified,
|
||||
autosave: e.autosave,
|
||||
autosaveName: e.autosaveName,
|
||||
format: await formatOf(e.blob),
|
||||
});
|
||||
}
|
||||
return { files: out };
|
||||
}
|
||||
case "read": {
|
||||
const entry = (await listing()).get(message.name);
|
||||
if (!entry) throw new Error("that design is no longer there");
|
||||
if (entry.blob.size > MAX_FILE_BYTES) throw new Error("that design is too large");
|
||||
return { bytes: await entry.blob.arrayBuffer() };
|
||||
}
|
||||
case "settings": {
|
||||
const local = storage();
|
||||
const settings = {};
|
||||
for (const key of SETTINGS) {
|
||||
const value = local?.getItem(key) ?? null;
|
||||
if (value !== null && value.length <= MAX_SETTING_CHARS) settings[key] = value;
|
||||
}
|
||||
return { settings };
|
||||
}
|
||||
default:
|
||||
throw new Error("unknown request");
|
||||
}
|
||||
}
|
||||
|
||||
function serve() {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window.parent || event.origin !== NEW_ORIGIN) return;
|
||||
const message = event.data;
|
||||
if (!message || message.solidcraftTransfer !== PROTOCOL || !Number.isSafeInteger(message.id)) return;
|
||||
answer(message).then(
|
||||
(result) => {
|
||||
const reply = { solidcraftTransfer: PROTOCOL, id: message.id, ok: true, ...result };
|
||||
window.parent.postMessage(reply, NEW_ORIGIN, result.bytes ? [result.bytes] : []);
|
||||
},
|
||||
(error) => {
|
||||
const reply = { solidcraftTransfer: PROTOCOL, id: message.id, ok: false, error: String(error?.message ?? error) };
|
||||
window.parent.postMessage(reply, NEW_ORIGIN);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function save(name, blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
const size = (bytes) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
async function show() {
|
||||
document.getElementById("page").hidden = false;
|
||||
const status = document.getElementById("status");
|
||||
const entries = [...(await listing()).values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (entries.length === 0) {
|
||||
status.textContent = "This browser keeps no designs for the old address. Designs you saved to a file on your computer are still there: open them at app.solidcraft.io with File > Open.";
|
||||
return;
|
||||
}
|
||||
status.textContent = entries.length === 1 ? "1 design found." : `${entries.length} designs found.`;
|
||||
document.getElementById("table").hidden = false;
|
||||
const rows = document.getElementById("rows");
|
||||
for (const entry of entries) {
|
||||
const shown = entry.autosave ? `${(entry.autosaveName || "Untitled").replace(/[\\/]/g, "-")} (unsaved work).sc` : entry.name;
|
||||
const row = rows.insertRow();
|
||||
row.insertCell().textContent = shown;
|
||||
const sizeCell = row.insertCell();
|
||||
sizeCell.className = "size";
|
||||
sizeCell.textContent = size(entry.blob.size);
|
||||
const button = document.createElement("button");
|
||||
button.textContent = "Save as a file";
|
||||
button.addEventListener("click", () => save(shown, entry.blob));
|
||||
row.insertCell().append(button);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.parent === window) {
|
||||
show().catch((error) => {
|
||||
document.getElementById("status").textContent = `This browser would not let the page read its storage: ${error?.message ?? error}. Use the old app below.`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Framed: only directly by a top-level page of NEW_ORIGIN (browsers without ancestorOrigins rely
|
||||
// on the checks in `serve`, which are the ones that keep the designs in).
|
||||
const ancestors = window.location.ancestorOrigins;
|
||||
const direct = window.parent === window.top && (!ancestors || (ancestors.length === 1 && ancestors[0] === NEW_ORIGIN));
|
||||
if (direct) serve();
|
||||
})();
|
||||
Reference in New Issue
Block a user