diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 7f22d4e..043c6c1 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -33,6 +33,7 @@ interface Hosts {
img: string
notify: string
rooms: string
+ cdn: string
}
/**
@@ -67,12 +68,12 @@ interface SelfAccount {
interface SubRoom {
SubRoomId: number
Name: string
- /** The Unity scene the client loads under the saved objects. */
- UnitySceneId: string
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
Accessibility: number
IsSandbox: boolean
MaxPlayers: number
+ /** The subroom's scene-data key, served back by `cdn` under `room/`. */
+ DataBlob?: string
/**
* A save posted without `AutoPublish` waits here. Cleared when that save is
* published, so a non-null value means "edited since players last saw a change".
@@ -83,7 +84,17 @@ interface SubRoom {
* one silently loads nothing, which is worth surfacing to an owner who can't tell
* that apart from a broken room.
*/
- CurrentSave: { SubRoomDataSaveId: number; CreatedAt: string; Description: string } | null
+ CurrentSave: {
+ SubRoomDataSaveId: number
+ CreatedAt: string
+ Description: string
+ /**
+ * The scene-data key for the published save — what the client downloads to load
+ * the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves
+ * this one first, ahead of the subroom's own.
+ */
+ DataBlob: string
+ } | null
}
/**
@@ -1010,7 +1021,7 @@ function RoomPage({
// they have no business asking.
That isn't one of your rooms.
) : (
-
+
)}
)
@@ -1029,7 +1040,15 @@ function platformList(room: OwnedRoom): string[] {
}
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */
-function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
+function RoomDetail({
+ room,
+ imgHost,
+ cdnHost,
+}: {
+ room: OwnedRoom
+ imgHost: string
+ cdnHost: string
+}) {
const created = new Date(room.CreatedAt)
const platforms = platformList(room)
const subRooms = room.SubRooms ?? []
@@ -1095,7 +1114,7 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
) : (
{subRooms.map((sub) => (
-
+
))}
)}
@@ -1105,23 +1124,31 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
}
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
-function SubRoomRow({ sub }: { sub: SubRoom }) {
+function SubRoomRow({
+ sub,
+ roomName,
+ cdnHost,
+}: {
+ sub: SubRoom
+ roomName: string
+ cdnHost: string
+}) {
const save = sub.CurrentSave ?? null
const saved = save ? new Date(save.CreatedAt) : null
// Cleared when that save is published (see publishSubRoomSave), so a value here always
// means work the owner saved but players still can't see.
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
+ const name = sub.Name || `Subroom ${sub.SubRoomId}`
return (
- {sub.Name || `Subroom ${sub.SubRoomId}`}
+ {name}
{sub.IsSandbox && Sandbox}
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
- {sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}
{save === null ? (
@@ -1139,10 +1166,111 @@ function SubRoomRow({ sub }: { sub: SubRoom }) {
· a newer save is staged, waiting to be published.
)}
+ {/* The published save's blob first: that's the copy of the room worth keeping,
+ and the one the client resolves ahead of the subroom's own key. */}
+ {save?.DataBlob && (
+
+ )}
+ {sub.DataBlob && (
+
+ )}
)
}
+/**
+ * A download filename built from player-supplied names, with everything that isn't a
+ * word character, dot or dash flattened to a dash — a subroom can be called anything,
+ * and that string is about to become a path on someone's disk.
+ */
+const safeFilename = (...parts: string[]): string =>
+ `${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin`
+
+/**
+ * One scene-data blob: the key, and a link that downloads it from `cdn`.
+ *
+ * `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any
+ * other link. The click is intercepted only to give the file a NAME: blobs are stored
+ * under a date-foldered UUID, so three downloads otherwise land as three
+ * indistinguishable extensionless files. The `download` attribute can't do that on its
+ * own — browsers ignore it cross-origin, and `cdn` is always a different origin from the
+ * website — hence fetching the bytes and saving them through an object URL.
+ */
+function BlobDownload({
+ label,
+ blobKey,
+ filename,
+ cdnHost,
+}: {
+ label: string
+ blobKey: string
+ filename: string
+ cdnHost: string
+}) {
+ // Room build data is served under `room/` — the same prefix the storage worker
+ // uploads it to, and the one the game downloads it from.
+ const url = `${cdnHost}/room/${blobKey}`
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState('')
+
+ const download = async () => {
+ setPending(true)
+ setError('')
+ try {
+ const res = await fetch(url)
+ // The blob key is stored on the subroom, so a miss here means the object is gone
+ // from the bucket — worth saying, rather than saving a file of the 404 body.
+ if (!res.ok) throw new Error(`the CDN answered ${res.status}`)
+ const href = URL.createObjectURL(await res.blob())
+ const link = document.createElement('a')
+ link.href = href
+ link.download = filename
+ link.click()
+ // The click is dispatched synchronously but the save reads the URL after this
+ // frame, so the revoke waits a tick rather than pulling it out from under.
+ setTimeout(() => URL.revokeObjectURL(href), 0)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e))
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+
+
{label}
+
{
+ e.preventDefault()
+ void download()
+ }}
+ >
+ {blobKey}
+
+ {/* Only rendered when it has something to say — an empty span would still take a
+ gap from the flex row, leaving the key trailed by a stray space. */}
+ {pending ? (
+
Downloading…
+ ) : error ? (
+
Couldn’t download — {error}.
+ ) : null}
+
+ )
+}
+
/** How a room or subroom's `Accessibility` reads on screen. */
const accessibilityLabel = (accessibility: number): string =>
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css
index 0d357ad..e149fb1 100644
--- a/apps/www/src/client/styles.css
+++ b/apps/www/src/client/styles.css
@@ -835,6 +835,44 @@ h2 {
color: var(--accent);
}
+/*
+ * A scene-data blob and the link that downloads it. The KEY is the link, rather than a
+ * "Download" button beside it: the key is the thing an owner came to find (it's what
+ * `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target
+ * keeps it readable and clickable without a second control competing for the row.
+ */
+.blob {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 10px;
+ margin-top: 8px;
+ font-size: 0.8rem;
+}
+
+.blob-label {
+ flex: none;
+ color: var(--muted);
+}
+
+/* Blob keys are `/` — long, and with only two places a line could break.
+ `anywhere` lets one wrap mid-key instead of pushing the card sideways. */
+.blob-key {
+ color: var(--accent);
+ text-decoration: none;
+ overflow-wrap: anywhere;
+ min-width: 0;
+}
+
+.blob-key:hover {
+ text-decoration: underline;
+}
+
+.blob-note {
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
/* ---- Forms -------------------------------------------------------------- */
label {
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index 2a4e7d7..47142d3 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -49,6 +49,7 @@ it('advertises signup and where the other workers live', async () => {
img: 'https://img.rec.example.com',
notify: 'https://notify.rec.example.com',
rooms: 'https://rooms.rec.example.com',
+ cdn: 'https://cdn.rec.example.com',
},
})
})
diff --git a/apps/www/src/upstream.ts b/apps/www/src/upstream.ts
index 361f321..72c12f2 100644
--- a/apps/www/src/upstream.ts
+++ b/apps/www/src/upstream.ts
@@ -16,6 +16,7 @@ export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
+export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}`
/**
* POST a form body to the `auth` worker, carrying the browser's real IP across.
diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts
index ad0dcad..d72558e 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -12,6 +12,7 @@ import {
accountsBase,
apiBase,
authBase,
+ cdnBase,
imgBase,
notifyBase,
postAuthForm,
@@ -69,6 +70,7 @@ const app = new Hono()
img: imgBase(c.env),
notify: notifyBase(c.env),
rooms: roomsBase(c.env),
+ cdn: cdnBase(c.env),
},
})
})