homegames-common 1.7.1 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/homegames-context.md +40 -34
- package/export-storage.js +3 -3
- package/package.json +1 -1
- package/player-toolbar.js +47 -20
- package/portable-client.js +13 -3
- package/portable-html.js +23 -14
- package/portable-project.js +22 -1
- package/portable-runtime.js +1 -0
- package/portable-saves.js +2 -1
- package/studio-debug.js +91 -0
- package/studio-handoff.js +79 -0
- package/test/player-toolbar.test.cjs +65 -0
- package/test/portable-downloads.test.cjs +48 -0
- package/test/portable-frame.test.cjs +17 -0
- package/test/standalone-edit.test.cjs +23 -0
- package/test/studio-debug.test.cjs +69 -0
- package/test/studio-handoff.test.cjs +94 -0
- package/test/studio-thumbnail.test.cjs +21 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Grounding reference for Homegames platform questions and SquishJS game authoring. **Part I** describes the product and implementation; **Part II** describes the game contract, including differences between portable projects and the retained hosted publishing path.
|
|
4
4
|
>
|
|
5
|
-
> **Implementation review: October
|
|
5
|
+
> **Implementation review: October 16, 2026.** This describes the current local working-tree code, including portable Studio, exports, browser saves and RTC. It does not establish what homegames.io has deployed. Existing downloaded HTML files retain the runtime bundled when they were exported.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -88,10 +88,11 @@ These are three different kinds of persistence:
|
|
|
88
88
|
|
|
89
89
|
| Data | Current local-first storage | Portable backup |
|
|
90
90
|
| --- | --- | --- |
|
|
91
|
-
|
|
|
92
|
-
|
|
|
91
|
+
| Saved source and authored media | Studio IndexedDB project library after explicit Save | Project `.zip`, playable `.html`, or full Studio backup |
|
|
92
|
+
| Source snapshots | Every explicit Save adds an immutable History entry | Full Studio backup includes history; individual game exports do not |
|
|
93
93
|
| Game progress | Installation-scoped IndexedDB saves, when the game calls `saveGame` | Download save / Load save JSON |
|
|
94
94
|
|
|
95
|
+
Unsaved source stays in memory until Save; a full Studio backup also captures that working copy.
|
|
95
96
|
No account or cloud sync is involved. Clearing/evicting browser storage can lose
|
|
96
97
|
projects and progress. Optional persistent-storage permission is best-effort;
|
|
97
98
|
keep file backups. Importing a ZIP makes a new installation, and each newly
|
|
@@ -112,30 +113,35 @@ The local-first implementation is integrated into the **existing Studio**, using
|
|
|
112
113
|
its editor, file tree, tabs, preview and asset tools. `studio-local.html` redirects
|
|
113
114
|
there; it is not a separate product.
|
|
114
115
|
|
|
115
|
-
- **
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
- **Preview
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
- **Export
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
116
|
+
- **Games:** opens a switcher over the main editor, with New game and Import game
|
|
117
|
+
(HTML or ZIP). Templates are static portable projects; no account is needed.
|
|
118
|
+
- **Editing and Save:** edits remain in memory until explicit Save / Ctrl+S / Cmd+S.
|
|
119
|
+
Each successful Save atomically keeps source/media and adds an immutable History
|
|
120
|
+
snapshot. The optional save message names it; blank messages use Save N. Incomplete
|
|
121
|
+
code can be saved. Play, asset insertion and exports do not save the working copy.
|
|
122
|
+
Generation checks prevent stale tabs overwriting newer edits; failed writes keep
|
|
123
|
+
the working copy and message available for retry.
|
|
124
|
+
- **History:** sits left of Files. Snapshots are read-only and can be played or exported.
|
|
125
|
+
Back to editing restores the in-memory working copy. Restore loads snapshot files
|
|
126
|
+
as unsaved edits; Save keeps them. Switching games or restoring over unsaved work
|
|
127
|
+
asks before discarding it. Source restoration does not roll back player progress.
|
|
128
|
+
- **Assets:** a reusable browser library. Upload, Draw, Record and Keyboard keep
|
|
129
|
+
originals there. Add to game copies selected files into the current project;
|
|
130
|
+
Save keeps that project change. Authored references use relative asset paths,
|
|
131
|
+
not hosted IDs. Session uploads from players are a separate mechanism (§9).
|
|
132
|
+
- **Preview:** Play runs current source in an isolated iframe. Options provides
|
|
133
|
+
inspection and a second local test player; Invite players connects real RTC guests.
|
|
134
|
+
Editing the source requires restarting Play to see the change.
|
|
135
|
+
- **Export & share:** ZIP contains editable source/media; HTML is a playable copy.
|
|
136
|
+
Neither contains full source history or existing progress. Inviting players keeps
|
|
137
|
+
the running browser tab as host; Share a copy transfers a separate editable pack.
|
|
138
|
+
- **Settings:** appearance, browser storage, and full-workspace backup/restore.
|
|
139
|
+
A .homegames-backup includes games, history, original assets, preview saves/exports,
|
|
140
|
+
preferences, and the current unfinished source without making an extra snapshot.
|
|
141
|
+
Restore replaces the workspace atomically and keeps an Undo last restore copy.
|
|
142
|
+
- **Learn:** interactive examples, a getting-started tutorial, searchable API topics,
|
|
143
|
+
and task-oriented snippets with labeled insertion points. Copy full reference
|
|
144
|
+
keeps this document available for an external coding assistant.
|
|
139
145
|
|
|
140
146
|
The isolated player cannot access the Studio's DOM or project storage. It receives
|
|
141
147
|
only its project bytes and a private, installation-bound progress channel. This
|
|
@@ -265,8 +271,8 @@ account backend and publishing adds API/MongoDB/Forgejo/RabbitMQ/validation serv
|
|
|
265
271
|
full **per-player view snapshots**, not deltas or the entire combined host tree.
|
|
266
272
|
Identical frames can be skipped and queued obsolete frames coalesced.
|
|
267
273
|
- Squish is the versioned binary node format. Default authoring target: `squish-142`
|
|
268
|
-
(`squishjs@1.4.2`). The browser bundle
|
|
269
|
-
|
|
274
|
+
(`squishjs@1.4.2`). The browser bundle and shared Node loader currently contain
|
|
275
|
+
**142, 143, 145 and 146**. The publish runner uses the
|
|
270
276
|
map included in its image. Do not infer browser support from Node availability;
|
|
271
277
|
portable validation rejects unavailable/mismatched versions rather than substituting.
|
|
272
278
|
References to 144-era APIs below describe behavior introduced then, not a bundled
|
|
@@ -939,7 +945,7 @@ this.setTimeout(() => this.base.removeChild(sound.id), 250);
|
|
|
939
945
|
|
|
940
946
|
The `assetInfo` key (`'potato'`, `'hiss'`) names a **logical entry in `metadata().assets`**, whose value loads bytes from a project path (portable) or hosted asset ID (legacy). Keep the key stable; it is not itself a database ID or a file path.
|
|
941
947
|
|
|
942
|
-
> **
|
|
948
|
+
> **Each audio node plays once.** The client tracks playback by node ID plus asset key. A fresh `GameNode.Asset` can replay the same clip; two separate nodes can play it at the same time. Leaving a finished node attached does not loop it. Remove old sound nodes to stop playback and avoid accumulating them; use a fresh node for the next play. Reusing the exact same node requires its sound to be absent from a rendered frame before it can restart. Start audio from a player action so browser autoplay rules allow it. Muted players do not hear audio.
|
|
943
949
|
|
|
944
950
|
### 7.3.1 Cropping an image / spritesheets (`squish-139`+)
|
|
945
951
|
|
|
@@ -1987,8 +1993,8 @@ Runtime: one host-authoritative game instance: browser LocalSession (local
|
|
|
1987
1993
|
Share/QR joins the running host; it sends views/media, not editable game source.
|
|
1988
1994
|
Manual offer/answer bypasses Homegames signaling but cannot guarantee all NAT paths.
|
|
1989
1995
|
Portable: ordinary .zip = homegame.json + source + media + generated inventory; .html = playable copy.
|
|
1990
|
-
default squish-142; browser supports 142/143/145; pin matching manifest/metadata/require.
|
|
1991
|
-
|
|
1996
|
+
default squish-142; browser supports 142/143/145/146; pin matching manifest/metadata/require.
|
|
1997
|
+
The publish runner must also contain the chosen version when using legacy publishing.
|
|
1992
1998
|
Saves: constructor({saveGame, saveData}); validate loaded JSON and rebuild nodes from state.
|
|
1993
1999
|
Portable: <=1 MiB UTF-8 JSON; Promise resolves after IndexedDB commit, no write debounce.
|
|
1994
2000
|
installation + save-schema:N slot; source snapshots don't roll back progress.
|
|
@@ -2025,8 +2031,8 @@ Nodes: GameNode.Shape({ shapeType, coordinates2d, fill, color, border, on
|
|
|
2025
2031
|
GameNode.Text({ textInfo:{ text,x,y,size,align,color,font }, playerIds }) // NO onClick
|
|
2026
2032
|
GameNode.Asset({ coordinates2d, assetInfo:{ key:{pos:{x,y},size:{x,y},startTime} }, playerIds }) // clickable
|
|
2027
2033
|
image drawn from assetInfo pos/size; clicks hit coordinates2d -> keep them aligned (§7.3)
|
|
2028
|
-
audio = zero-size Asset; add to tree = play, remove = stop;
|
|
2029
|
-
|
|
2034
|
+
audio = zero-size Asset; add to tree = play, remove = stop; tracked by node ID + asset key.
|
|
2035
|
+
Use a fresh node to replay a clip (§7.3). Remove old nodes; use bundled media.
|
|
2030
2036
|
Asset crop (squish-139+): assetInfo.key.{cropLeft,cropTop,cropRight,cropBottom} = % inset per edge -> spritesheets (§7.3.1)
|
|
2031
2037
|
Button: no button node + Text isn't clickable -> clickable Shape (onClick) with a Text node on top (§7.2.1)
|
|
2032
2038
|
|
package/export-storage.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Browser host storage. Embedded in standalone HTML as a self-contained adapter.
|
|
4
|
-
function createExportStorage(window, { installationId, gameId, packageRevision = 'local' }) {
|
|
4
|
+
function createExportStorage(window, { installationId, gameId, packageRevision = 'local', database: sharedDatabase, transaction }) {
|
|
5
5
|
if (typeof installationId !== 'string' || !installationId || typeof gameId !== 'string' || !gameId) throw new Error('Export storage requires a host-selected game identity');
|
|
6
6
|
const key = [installationId, gameId, packageRevision]; let database;
|
|
7
|
-
const open = () => database ||= new Promise((resolve, reject) => {
|
|
7
|
+
const open = () => sharedDatabase ? Promise.resolve(sharedDatabase) : database ||= new Promise((resolve, reject) => {
|
|
8
8
|
const request = window.indexedDB.open('homegames-user-exports', 1);
|
|
9
9
|
request.onupgradeneeded = () => request.result.createObjectStore('exports');
|
|
10
10
|
request.onerror = () => reject(request.error);
|
|
@@ -35,7 +35,7 @@ function createExportStorage(window, { installationId, gameId, packageRevision =
|
|
|
35
35
|
validate({ [name]: bytes }); bytes = bytes.slice();
|
|
36
36
|
const db = await open();
|
|
37
37
|
return new Promise((resolve, reject) => {
|
|
38
|
-
const tx = db.transaction('exports', 'readwrite'), store = tx.objectStore('exports'), request = store.get(key); let error;
|
|
38
|
+
const tx = transaction ? transaction(['exports']) : db.transaction('exports', 'readwrite'), store = tx.objectStore('exports'), request = store.get(key); let error;
|
|
39
39
|
request.onsuccess = () => { try { const files = { ...request.result, [name]: bytes }; validate(files); store.put(files, key); } catch (e) { error = e; tx.abort(); } };
|
|
40
40
|
tx.oncomplete = () => resolve();
|
|
41
41
|
tx.onabort = () => reject(error || tx.error || new Error('Unable to save export'));
|
package/package.json
CHANGED
package/player-toolbar.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Client UI only. Self-contained so the same toolbar can be embedded in a
|
|
4
4
|
// downloaded HTML without loading scripts or styles from a website.
|
|
5
5
|
function createPlayerToolbar(window, options = {}) {
|
|
6
|
-
const document = window.document, actions = new Map();
|
|
6
|
+
const document = window.document, actions = new Map(), running = new Set();
|
|
7
7
|
const element = (tag, className, text) => {
|
|
8
8
|
const node = document.createElement(tag); node.className = className;
|
|
9
9
|
if (text) node.textContent = text; return node;
|
|
@@ -15,17 +15,23 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
15
15
|
.hg-player-bar *,.hg-player-menu *{box-sizing:border-box}
|
|
16
16
|
.hg-player-bar{display:flex;flex:none;align-items:center;gap:6px;width:100%;height:calc(58px + env(safe-area-inset-top));padding:calc(6px + env(safe-area-inset-top)) max(10px,env(safe-area-inset-right)) 6px max(10px,env(safe-area-inset-left));border-bottom:1px solid #363b36}
|
|
17
17
|
.hg-player-heading{flex:1;min-width:0}.hg-player-title{margin:0;font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
18
|
+
.hg-player-identity{display:flex;align-items:baseline;gap:10px;min-width:0}.hg-player-author{font-size:12px;font-weight:400;color:#b7bfb5;margin-left:6px}
|
|
18
19
|
.hg-player-status{margin:0;color:#b7bfb5;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
19
20
|
.hg-player-bar button,.hg-player-bar a,.hg-player-menu button{font:inherit;color:inherit;border:1px solid #4a5049;border-radius:8px;background:transparent;cursor:pointer;text-decoration:none}
|
|
20
21
|
.hg-player-bar button,.hg-player-bar a{display:inline-flex;align-items:center;justify-content:center;flex:none;min-width:40px;height:44px;padding:0 10px;white-space:nowrap}
|
|
21
22
|
.hg-player-bar .hg-player-icon{font-size:21px;padding:0;width:40px}
|
|
23
|
+
.hg-player-home img{display:block;width:32px;height:32px;object-fit:contain}
|
|
24
|
+
.hg-player-bar .hg-player-brand{height:auto;min-height:24px;min-width:0;padding:0;border:0;border-radius:3px;color:#94d2e5;font-size:12px;text-decoration:underline;text-underline-offset:3px}
|
|
25
|
+
.hg-player-bar .hg-player-brand:hover{background:transparent;color:#b9e5f1}
|
|
22
26
|
.hg-player-bar .hg-player-share{background:#fff78e;color:#22261e;border-color:#fff78e;font-weight:600}
|
|
23
27
|
.hg-player-bar button:hover,.hg-player-bar a:hover,.hg-player-menu button:hover{background:#394036}
|
|
24
28
|
.hg-player-bar .hg-player-share:hover{background:#fffbb5}
|
|
29
|
+
.hg-player-bar .hg-player-install{background:#a8edc9;color:#17271e;border-color:#a8edc9;font-weight:600}.hg-player-bar .hg-player-install:hover{background:#c4f5dc}
|
|
25
30
|
.hg-player-bar :focus-visible,.hg-player-menu :focus-visible{outline:2px solid #fff78e;outline-offset:2px}
|
|
26
31
|
.hg-player-bar [hidden],.hg-player-menu [hidden]{display:none!important}
|
|
27
32
|
.hg-player-bar button:disabled,.hg-player-menu button:disabled{opacity:.45;cursor:default}
|
|
28
|
-
.hg-player-
|
|
33
|
+
.hg-player-edit-short{display:none}
|
|
34
|
+
@media(max-width:600px){.hg-player-edit-long{display:none}.hg-player-edit-short{display:inline}.hg-player-bar{gap:4px;flex-wrap:wrap;height:auto}.hg-player-home{order:-2}.hg-player-heading{order:-1;flex-basis:calc(100% - 48px)}.hg-player-share{margin-left:auto}.hg-player-bar button:not(.hg-player-icon){padding:0 8px}}
|
|
29
35
|
.hg-player-menu{position:fixed;inset:max(68px,env(safe-area-inset-top)) 12px auto auto;margin:0;width:min(320px,calc(100vw - 24px));max-height:calc(100dvh - 90px);overflow:auto;padding:16px;border:1px solid #4a5049;border-radius:12px;box-shadow:0 16px 50px #0008}
|
|
30
36
|
.hg-player-menu::backdrop{background:#080c0a99}.hg-player-menu h2{margin:0 0 12px;font-size:16px}
|
|
31
37
|
.hg-player-menu-actions{display:flex;flex-direction:column;gap:6px}.hg-player-menu button{width:100%;min-height:44px;padding:10px 12px;text-align:left}
|
|
@@ -36,23 +42,38 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
36
42
|
.hg-player-name button{margin-top:8px}.hg-player-name p{font-size:12px;color:#b7bfb5;margin:8px 0 0;overflow-wrap:anywhere}
|
|
37
43
|
`;
|
|
38
44
|
bar.append(style);
|
|
39
|
-
const home = element(typeof options.home === 'string' ? 'a' : 'button', 'hg-player-icon
|
|
45
|
+
const home = element(typeof options.home === 'string' ? 'a' : 'button', 'hg-player-icon hg-player-home');
|
|
40
46
|
home.setAttribute('aria-label', 'Home'); home.title = 'Home'; home.hidden = !options.home;
|
|
47
|
+
// Embedded copy of homegamesio/favicon-32x32.png; no network request in downloaded games.
|
|
48
|
+
const logo = element('img', ''); logo.alt = ''; logo.width = logo.height = 32;
|
|
49
|
+
logo.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAIbElEQVRYR7WXC1BU5xmGn7M3rgICYRHjBQSiBUIUTQQ0oEZTIRdTihcy04xO22mbSWKTTDQql2jNbdKk06S2TdqmaWrspMk0tHViiEpqEEyCGmhEq0gEXS4r7HJZll2WPX/nnLO7LEhsOp3+Mwxw9j//9/7v973v9620cOHCpKSkab9blJ2wSq/XGfh/LwFjsjx2usl6pLt7eLNUXHz7oTde/eadcbFhWmgBQgIJgUBC+h8BKacop00+p882wgPfPfSBVL7j257d5fkGbdv4EsL/qvbc/7ny++ssBTyyjKSTkKSpr1Gx57hHqiovFZU7V4EY8Z2rhdrwncMMDnlU7BPeVz+eDNdHne8KQoAsBCGhISSazdya7GTL1iWBC/rfrnq6HqmqfIOo3HUXyL2BFKCDbRXtHDt2gp6eHvUWCgVRRiODHo9GiSwIIAtiy/9MYVB5J2dxDsuT3Dy8twAM+gnk+QBsFJW77gbZOs6AJLH3BTvV1e9jsVjUgMqlf7W+kG3V9USajLy+ppDdJ0+xaFossyIjOdLdyS1JZowmwe6PTvpyJpGVmcnam0w8sud2MAXVuIDKZwIMKACuTgCwvaKdo7XHsXRatMIE9m8p5u0Pm1m5dC4mg57pYdEkRZuYPiDhjoYuvZ5+ay8PvFmDEFruMzMzKUo38dDeAiTjJAb2qgDWi8qd6xCiFwmvBkKCR7e3Unf8U40B5ZEkcfgHmxh1OegQLvqdHgpnmOmQBPMU9U4LZ8zl5d32L3j+T59qRStJ5Ofnk2t2snVvIUwGEKiB8m+B3AfCPQWAzgAzBx7cxO9rT3JHlpmspFTkkV4coZGkm4wMhoXy5fmLtPQN81zNiQDodevWMU9q58dPXw9ARSl4e8eVIMHWJ85T39AYYEA58b6FqbT09HJf3gJiB3X8yzRIavh02vtspMyMp/3MVdLzZvHIy4cCAMrKykh0tvDosyvAoAuIXRH5U083+FRQvkFTgRj2MSAovf8j2ju6AgDmL78DecyN5exZbi3ZQP3+18guXo+to5VLTadYtmkzXxyuJnPlvTTX/AVbj1VNgQLA7GzhsWdXIAx6TYo+HY7LsLxMCy73+KQouGd9Dd09fQEA237+E/qGvDSfOM3tKzKw2Vyk3LYMEZqArbmWcJMbXUQiMfE30FhTzYFX3lIVWVRURLrRwuPPrQySoWZnVeMM3A+yHYTN58aCH25t4dSppoAKVtxTTEFpCQdf+xlL79tE5k3zuNjayoz5GcToZa5YrZyr/RsRC4o4+FIllo6rKgNZWVmsSJZ44vlV1/hA5bgRlYKwBopQMZEdu7s5cuQoFkuXylnmslzyVq0m/Rtx1LxXy90bS+jquEzs/ExMDhtNdR9yutmKY2CYgrtW8VrFHhVARkYGBXP17PipBiDYQ30ANoqKnfciie5AcpQCeWzHJerq6rFYFBVI3PXQI+TnzuPsyVZuSE2lp7mO6NlpzLoplQt1h4nLXov70se445aQEnqZbd+rUoNlZWZRmKxju8LA1DJUnPCe8fz7klC2pZ6LFzvo6uxSfcFo1GFOiKCr28HcOTFcbLMTHx/OwIALvV5iekwo1t5hEm6IYNTt5WqvE51OR3Z2NoVzBI8pVhxi/CorngwASspqcDjGUD19ihWg0t8qfQam9nL17lqrSElOITO8iwefKmDEK2g+04t9SPF2He++06TIcFIv8J3c39iBo92O1yuPN2qdRN+QB48smBFtCjQvrXH7BeT/2wdEQHiogVr9bM5dTSI5bzV2lwe3vZeGd94cByBk64S+779F8OWVoxtOdJG3dMaUrEz10OsVvHjATbc8l9mLcjGGhuAntfbXLwa1Y8UJ/Wvy/OBj5czZPszmcOJ901PwXScH96fo8HE7ZxzL0CdnBAYTJTXKvFDzygu+ZrSrGGQbQ0OjnD9uBYNEzh1J14xkxxs6yc9N+tq3VzbuPxLDYEI+clikSqrX6cAQEYnD5eHYqy8pAJSJqAhEP22XBhj8bT8WSaZ499wJALyyzJkWGzdnxk0e3ib9r+Hr7RuhqcXBwaY4ZubfTWhkJMLj4kLDx8y8eTF2YeKff/yFH8BKhHDR0T5I2x/aabMNsOWlZRNmxI4rQ0RNCyHGX3xBnj6ZEoXJl2tuxDw3A5cxgguN9aQtX62qYqTfjtMQxpBrjJa39iFV7VIYyFPP8Hi8nG/t59x5GyX3pgWahpLPzxq7uXVx4qRYU82GcPQfl3mvczlExasF57S0kbHkNgwR0+hzuHF6xtTLqQB2bC8Rjz+8GJ1Oi6f8DDs8NJ62snhhAgajnk8/62bBgjgiwrWRSis+4avmoLFb0sbvY5/YsaQ8RPfwqDrKDVst6OUxwhLnqDYhZBnvqItzb/8G6dE1a8T3k5PVU9Vu7RuBlWPtzmGcHg8zY2PBaFDNwy9+oexTNikDq06nTs+qaY2OcmJ4BPuDFVgGXGpA2TNKV937xMxOQa/TodfriYoMp/nv7yDtLCoST6T56A64mS+OP4bJhBQZca01KCAUAJJOBS68MmLUTd2VK3yy+UmcsqZnr2cU0dbMjWnzYagfp1fGiJcTf/1zEIDrimvqXOuio8HpRFZG9aD1pWOIN1aXYTDPVp86Oi8h6fREJM6asO/sgV9OBHBtmKkDX98IBF4BVRHxuNJziJqTykD1G0TeWYo+LEJ9VTNpQcv+fUjb1671PJmWbvDPSl/3+6CS70C9BH1H8YPrGxjg4zFlBNCTO2Sn1RCCNSwcvceDwe0mZHSEalufR1qbl3do3y233BlrnNgq/5Pd/XfcjO/WvtkJbGNj/Ojzzz+QcnJyZiSEhLyeHRW1UidJX4kiuOteD5yfXlUVX7FRFsLTNDh41Op2b/43wV/aTZKTkWoAAAAASUVORK5CYII='; home.append(logo);
|
|
41
50
|
if (typeof options.home === 'string') home.href = options.home;
|
|
42
51
|
else { home.type = 'button'; home.onclick = options.home; }
|
|
43
52
|
const heading = element('div', 'hg-player-heading');
|
|
44
|
-
const title = element('h1', 'hg-player-title'), status = element('p', 'hg-player-status');
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
53
|
+
const identity = element('div', 'hg-player-identity'), title = element('h1', 'hg-player-title'), titleName = element('span', ''), author = element('span', 'hg-player-author'), status = element('p', 'hg-player-status');
|
|
54
|
+
title.append(titleName, author);
|
|
55
|
+
const website = element('a', 'hg-player-brand', 'homegames.io');
|
|
56
|
+
website.href = 'https://homegames.io/'; website.target = '_blank'; website.rel = 'noopener noreferrer'; website.title = 'Open homegames.io in a new tab';
|
|
57
|
+
identity.append(title, website);
|
|
58
|
+
status.setAttribute('role', 'status'); status.setAttribute('aria-atomic', 'true'); heading.append(identity, status);
|
|
48
59
|
const share = element('button', 'hg-player-share', 'Share'); share.type = 'button'; share.hidden = true;
|
|
49
60
|
const more = element('button', 'hg-player-icon', '⋯'); more.type = 'button'; more.title = 'More';
|
|
50
61
|
more.setAttribute('aria-label', 'More'); more.setAttribute('aria-haspopup', 'dialog');
|
|
51
|
-
const downloadButton = element('button', '', 'Download'); downloadButton.type = 'button'; downloadButton.hidden = true;
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
const downloadButton = element('button', '', 'Download file'); downloadButton.type = 'button'; downloadButton.hidden = true;
|
|
63
|
+
const installButton = element('button', 'hg-player-install', 'Download app'); installButton.type = 'button'; installButton.hidden = true;
|
|
64
|
+
const editButton = element('button', 'hg-player-edit'); editButton.type = 'button'; editButton.hidden = true;
|
|
65
|
+
editButton.title = 'Open your own editable copy in Studio'; editButton.setAttribute('aria-label', 'Edit this game');
|
|
66
|
+
editButton.append(element('span', 'hg-player-edit-long', 'Edit this game'), element('span', 'hg-player-edit-short', 'Edit'));
|
|
67
|
+
const primary = new Set([share, installButton, downloadButton, editButton]);
|
|
68
|
+
bar.append(home, heading, share, installButton, downloadButton, editButton, more);
|
|
54
69
|
const menu = element('dialog', 'hg-player-menu'); menu.setAttribute('aria-label', 'Game options');
|
|
55
70
|
const menuTitle = element('h2', '', 'Game options'), list = element('div', 'hg-player-menu-actions');
|
|
71
|
+
const placeDownload = () => {
|
|
72
|
+
// Installable players get one primary download action; their portable
|
|
73
|
+
// file remains available in More. File-only players keep it in the bar.
|
|
74
|
+
if (actions.has('install') && !installButton.hidden) { list.prepend(downloadButton); downloadButton.textContent = 'Download game file (HTML)'; }
|
|
75
|
+
else { bar.insertBefore(downloadButton, editButton); downloadButton.textContent = 'Download file'; }
|
|
76
|
+
};
|
|
56
77
|
const menuStatus = element('p', 'hg-player-menu-status');
|
|
57
78
|
const nameForm = element('form', 'hg-player-name'); nameForm.hidden = true;
|
|
58
79
|
const nameLabel = element('label', '', 'Your name'), nameInput = element('input', '');
|
|
@@ -79,7 +100,8 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
79
100
|
let disposed = false;
|
|
80
101
|
const api = {
|
|
81
102
|
bar, menu, statusElement: status,
|
|
82
|
-
setTitle(value) {
|
|
103
|
+
setTitle(value) { titleName.textContent = titleName.title = value || 'Homegames'; },
|
|
104
|
+
setAuthor(value) { author.textContent = typeof value === 'string' && value.trim() ? ' by ' + value.trim().slice(0, 200) : ''; author.title = author.textContent.trim(); author.hidden = !author.textContent; },
|
|
83
105
|
setStatus(value) { status.textContent = status.title = menuStatus.textContent = value || ''; },
|
|
84
106
|
setPlayerName(value, onChange) {
|
|
85
107
|
playerName = value; changeName = onChange; nameForm.hidden = !onChange;
|
|
@@ -87,18 +109,23 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
87
109
|
if (onChange) more.hidden = false;
|
|
88
110
|
},
|
|
89
111
|
setAction(key, value) {
|
|
90
|
-
if (!value) { const old = actions.get(key); if (old) { old.hidden = true; if (old
|
|
112
|
+
if (!value) { const old = actions.get(key); if (old) { old.hidden = true; if (!primary.has(old)) old.remove(); actions.delete(key); } if (key === 'install') placeDownload(); return; }
|
|
91
113
|
let button = actions.get(key);
|
|
92
|
-
if (!button) { button = key === 'share' ? share : key === '
|
|
93
|
-
:
|
|
94
|
-
button.type = 'button'; actions.set(key, button); if (button
|
|
114
|
+
if (!button) { button = key === 'share' ? share : key === 'edit-game' ? editButton
|
|
115
|
+
: key === 'install' ? installButton : key === 'download-html' ? downloadButton : element('button', '', value.label);
|
|
116
|
+
button.type = 'button'; actions.set(key, button); if (!primary.has(button)) list.append(button); }
|
|
95
117
|
if (value.id) button.id = value.id;
|
|
96
|
-
if (button !==
|
|
97
|
-
button.hidden = !!value.hidden
|
|
118
|
+
if (button !== editButton) button.textContent = value.label;
|
|
119
|
+
button.hidden = !!value.hidden; button.disabled = !!value.disabled || running.has(key);
|
|
120
|
+
if (key === 'install' || key === 'download-html') placeDownload();
|
|
98
121
|
button.onclick = () => {
|
|
122
|
+
if (running.has(key)) return;
|
|
99
123
|
if (menu.open) { menu.close(); resume(); }
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
running.add(key); button.disabled = true;
|
|
125
|
+
// Invoke synchronously: opening Studio needs the click gesture.
|
|
126
|
+
try { Promise.resolve(value.run()).catch(error => api.setStatus(error.message || String(error)))
|
|
127
|
+
.finally(() => { running.delete(key); button.disabled = !!value.disabled; }); }
|
|
128
|
+
catch (error) { running.delete(key); button.disabled = !!value.disabled; api.setStatus(error.message || String(error)); }
|
|
102
129
|
};
|
|
103
130
|
options.onActionReady?.(key);
|
|
104
131
|
},
|
|
@@ -107,7 +134,7 @@ function createPlayerToolbar(window, options = {}) {
|
|
|
107
134
|
dispose() { if (disposed) return; disposed = true; if (menu.open) menu.close(); menu.remove(); bar.remove(); },
|
|
108
135
|
};
|
|
109
136
|
bar.homegamesToolbar = api;
|
|
110
|
-
api.setTitle(options.title); api.setStatus(options.status);
|
|
137
|
+
api.setTitle(options.title); api.setAuthor(options.author); api.setStatus(options.status);
|
|
111
138
|
for (const [key, value] of Object.entries(options.actions || {})) api.setAction(key, value);
|
|
112
139
|
return api;
|
|
113
140
|
}
|
package/portable-client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
function attachClientHost(window, frame, recordAudio, exportStorage, createExportsViewer, recordVideo, createPlayerToolbar, options = {}) {
|
|
5
5
|
const document = window.document;
|
|
6
6
|
let port, sequence = 0, closed = false, toolbar = options.toolbar, ownToolbar = false, recording, viewer;
|
|
7
|
+
let gameTitle = options.title;
|
|
7
8
|
const pending = new Map();
|
|
8
9
|
function request(format, name) {
|
|
9
10
|
if (!port || closed) return Promise.reject(new Error('The player is not ready'));
|
|
@@ -16,9 +17,15 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
16
17
|
function command(action, value) { if (port && !closed) port.postMessage({ type: 'player-control', action, value }); }
|
|
17
18
|
async function download(format) {
|
|
18
19
|
try {
|
|
20
|
+
// The host can supply a recovered catalog name for older packs.
|
|
21
|
+
// Otherwise use the title announced by the current game. Keep this
|
|
22
|
+
// self-contained: this adapter is also embedded in downloaded HTML.
|
|
23
|
+
const name = typeof options.downloadName === 'function' ? options.downloadName() : options.downloadName;
|
|
24
|
+
let stem = String(name || gameTitle || 'game').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'game';
|
|
25
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(stem)) stem += '-game';
|
|
19
26
|
status('Preparing game download…');
|
|
20
27
|
const bytes = await request(format), url = window.URL.createObjectURL(new Blob([bytes], { type: format === 'html' ? 'text/html' : 'application/zip' }));
|
|
21
|
-
const link = document.createElement('a'); link.href = url; link.download = '
|
|
28
|
+
const link = document.createElement('a'); link.href = url; link.download = stem + '.' + format; link.click();
|
|
22
29
|
setTimeout(() => window.URL.revokeObjectURL(url), 30000); status('Game copy downloaded');
|
|
23
30
|
} catch (e) { status(e.message); }
|
|
24
31
|
}
|
|
@@ -28,7 +35,8 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
28
35
|
port.onmessage = event => {
|
|
29
36
|
const message = event.data;
|
|
30
37
|
if (!closed && message?.type === 'player-state') {
|
|
31
|
-
if (typeof message.title === 'string')
|
|
38
|
+
if (typeof message.title === 'string') { gameTitle = message.title.slice(0, 200); toolbar?.setTitle(gameTitle); }
|
|
39
|
+
if (typeof message.author === 'string') toolbar?.setAuthor?.(message.author.slice(0, 200));
|
|
32
40
|
if (typeof message.status === 'string') status(message.status.slice(0, 300));
|
|
33
41
|
if (message.ready) {
|
|
34
42
|
toolbar?.setPlayerName(options.profile?.get() || '', message.playerNames && options.profile ? value => {
|
|
@@ -40,6 +48,8 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
40
48
|
}
|
|
41
49
|
toolbar?.setAction('download-html', { label: 'Download game HTML', id: options.htmlId, run: () => download('html') });
|
|
42
50
|
toolbar?.setAction('download-zip', { label: 'Download game ZIP', id: options.zipId, run: () => download('zip') });
|
|
51
|
+
if (options.editGame) toolbar?.setAction('edit-game', { label: 'Edit this game', id: 'edit-game',
|
|
52
|
+
run: () => options.editGame(() => request('zip')) });
|
|
43
53
|
}
|
|
44
54
|
return;
|
|
45
55
|
}
|
|
@@ -77,7 +87,7 @@ function attachClientHost(window, frame, recordAudio, exportStorage, createExpor
|
|
|
77
87
|
};
|
|
78
88
|
frame.contentWindow.postMessage({ type: 'homegames-client-port-v1', playerName: options.profile?.get() }, '*', [channel.port2]);
|
|
79
89
|
if (!toolbar && createPlayerToolbar) {
|
|
80
|
-
toolbar = createPlayerToolbar(window, { title: options.title, home: options.home, status: 'Starting game…',
|
|
90
|
+
toolbar = createPlayerToolbar(window, { title: options.title, author: options.author, home: options.home, status: 'Starting game…',
|
|
81
91
|
onOpen: () => command('pause-input'), onClose: () => command('resume-input') });
|
|
82
92
|
ownToolbar = true; frame.before(toolbar.bar);
|
|
83
93
|
}
|
package/portable-html.js
CHANGED
|
@@ -10,6 +10,7 @@ const { recordVideo } = require('./video-recording');
|
|
|
10
10
|
const { createExportStorage } = require('./export-storage');
|
|
11
11
|
const { createPlayerToolbar } = require('./player-toolbar');
|
|
12
12
|
const { createPlayerIdentity } = require('./player-identity');
|
|
13
|
+
const { openGameInStudio } = require('./studio-handoff');
|
|
13
14
|
const inline = value => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
14
15
|
const escapeHtml = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
16
|
const filename = name => {
|
|
@@ -39,7 +40,7 @@ function base64(bytes) {
|
|
|
39
40
|
return btoa(binary);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
async function runtimeDocument(project, { clientBundleSource, installationId, sharing = {} } = {}) {
|
|
43
|
+
async function runtimeDocument(project, { clientBundleSource, installationId, sharing = {}, studioUrl, studioDebug = false } = {}) {
|
|
43
44
|
if (!clientBundleSource?.includes('PortableProject')) throw new Error('Rebuild the Homegames client before playing portable projects');
|
|
44
45
|
if (!/^[a-zA-Z0-9-]{16,80}$/.test(installationId || '')) throw new Error('Missing local installation ID');
|
|
45
46
|
const signal = new URL(sharing.signalingUrl || 'wss://api.homegames.io/rtc');
|
|
@@ -47,9 +48,11 @@ async function runtimeDocument(project, { clientBundleSource, installationId, sh
|
|
|
47
48
|
signal.protocol = signal.protocol === 'https:' ? 'wss:' : signal.protocol === 'http:' ? 'ws:' : signal.protocol;
|
|
48
49
|
const join = new URL(sharing.joinUrl || 'https://homegames.io/play.html');
|
|
49
50
|
if (!['http:', 'https:'].includes(join.protocol)) throw new Error('Invalid invitation URL');
|
|
51
|
+
const studio = new URL(studioUrl || '/studio.html', join);
|
|
52
|
+
if (!['http:', 'https:'].includes(studio.protocol) || studio.username || studio.password) throw new Error('Invalid Studio URL');
|
|
50
53
|
const receive = new URL(sharing.receiveUrl || '/game-app.html', join);
|
|
51
54
|
if (!['http:', 'https:'].includes(receive.protocol)) throw new Error('Invalid copy receiver URL');
|
|
52
|
-
const payload = { archive: base64(await exportArchive(project)), installationId,
|
|
55
|
+
const payload = { archive: base64(await exportArchive(project)), installationId, studioUrl: studio.href, studioDebug: studioDebug === true,
|
|
53
56
|
sharing: sharing === false ? false : { signalingUrl: signal.href, joinUrl: join.href, receiveUrl: receive.href } };
|
|
54
57
|
// No cookies, storage, parent DOM, navigation, popup or download permission.
|
|
55
58
|
// Network access is limited by CSP to the signaling endpoint; RTC itself is
|
|
@@ -61,7 +64,7 @@ html,body{margin:0;height:100%;background:#101820;color:#f7fafc;font:14px system
|
|
|
61
64
|
<script>${clientBundleSource.replace(/<\/script/gi, '<\\/script')}</script><script>
|
|
62
65
|
(async()=>{
|
|
63
66
|
const payload=${inline(payload)};
|
|
64
|
-
let client,clientSource,copyShare,pairing;
|
|
67
|
+
let client,clientSource,copyShare,pairing,debug;
|
|
65
68
|
const state=status=>clientSource?.setState({status});
|
|
66
69
|
const fail=error=>{document.getElementById('error').hidden=false;document.getElementById('error').textContent=error.message||String(error);state('Unable to play: '+(error.message||String(error)));};
|
|
67
70
|
try {
|
|
@@ -72,19 +75,20 @@ const shareCopy=async()=>{const copy=await client.requestGameCopy();copyShare?.c
|
|
|
72
75
|
clientSource=HomegamesClient.PortableProject.connectClientSource(window,async format=>{
|
|
73
76
|
const copy=await client.requestGameCopy();
|
|
74
77
|
if(format==='zip')return copy.bytes;
|
|
75
|
-
return new TextEncoder().encode(await HomegamesClient.PortableProject.buildIsolatedHtml(copy.project,{clientBundleSource:document.scripts[0].textContent,installationId:crypto.randomUUID(),sharing:payload.sharing}));
|
|
78
|
+
return new TextEncoder().encode(await HomegamesClient.PortableProject.buildIsolatedHtml(copy.project,{clientBundleSource:document.scripts[0].textContent,installationId:crypto.randomUUID(),sharing:payload.sharing,studioUrl:payload.studioUrl}));
|
|
76
79
|
},{list:()=>client.listExports(),read:name=>client.readExport(name)}, {
|
|
77
80
|
share:()=>client.showShare(),exports:()=>client.viewExports(),'copy-share':shareCopy,'manual-connect':()=>pairing?.show(),
|
|
78
|
-
'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>client.setInputEnabled(true),'player-name':name=>client.setPlayerName(name)
|
|
81
|
+
'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>{client.setInputEnabled(true);if(payload.studioDebug)client._localSession?._updateInputAttachment();},'player-name':name=>client.setPlayerName(name)
|
|
79
82
|
});
|
|
80
|
-
const ready=status=>clientSource.setState({title:project.metadata.name||'Homegame',status,ready:true,sharing:!!payload.sharing,playerNames:typeof client.setPlayerName==='function'});
|
|
83
|
+
const ready=status=>clientSource.setState({title:project.metadata.name||'Homegame',author:project.metadata.author||project.metadata.createdBy||'',status,ready:true,sharing:!!payload.sharing,playerNames:typeof client.setPlayerName==='function'});
|
|
81
84
|
client=new HomegamesClient({containerId:'game',chrome:false,localHome:false,share:payload.sharing,saveStore,platform:clientSource,playerName:await clientSource.getPlayerName?.(),
|
|
82
85
|
loadLocalGame:async()=>HomegamesClient.PortableProject.prepareTrustedLocalGame(project,{trusted:true,installationId:payload.installationId}),
|
|
83
|
-
onReady:()=>ready('Playing locally'),onSessionStatus:state,onError:fail});
|
|
86
|
+
onReady:()=>{ready('Playing locally');debug?.ready();},onSessionStatus:state,onError:fail});
|
|
84
87
|
pairing=payload.sharing?HomegamesClient.createManualPairing({container:document.body,getSession:()=>client._localSession,
|
|
85
88
|
onOpen:()=>client.setInputEnabled(false),onClose:()=>client.setInputEnabled(true),
|
|
86
|
-
join:async transport=>{client.stop();client=new HomegamesClient({containerId:'game',chrome:false,transport,platform:clientSource,playerName:await clientSource.getPlayerName?.(),onReady:()=>ready('Connected manually to host'),onClose:()=>state('Host disconnected'),onError:fail});client.start();}}):null;
|
|
87
|
-
|
|
89
|
+
join:async transport=>{client.stop();client=new HomegamesClient({containerId:'game',chrome:false,transport,platform:clientSource,playerName:await clientSource.getPlayerName?.(),onReady:()=>{ready('Connected manually to host');debug?.ready();},onClose:()=>state('Host disconnected'),onError:fail});client.start();}}):null;
|
|
90
|
+
if(payload.studioDebug)debug=HomegamesClient.PortableProject.connectStudioDebugSource(window,()=>client._localSession);
|
|
91
|
+
window.addEventListener('pagehide',()=>{debug?.close();copyShare?.close();pairing?.dispose();client.stop();clientSource.close();});client.startLocalGame({});
|
|
88
92
|
}catch(error){fail(error);}
|
|
89
93
|
})();
|
|
90
94
|
</script></body></html>`;
|
|
@@ -93,11 +97,12 @@ window.addEventListener('pagehide',()=>{copyShare?.close();pairing?.dispose();cl
|
|
|
93
97
|
function createFrame(document, source) {
|
|
94
98
|
const frame = document.createElement('iframe');
|
|
95
99
|
frame.title = 'Game preview';
|
|
96
|
-
// frame.setAttribute('sandbox', 'allow-scripts');
|
|
97
100
|
// Pointer Lock still requires a canvas gesture. Keep the opaque origin:
|
|
98
101
|
// enabling mouse capture does not grant parent/storage access.
|
|
99
102
|
frame.setAttribute('sandbox', 'allow-scripts allow-pointer-lock');
|
|
100
|
-
|
|
103
|
+
// Explicit delegation also covers browsers that restrict gamepads in
|
|
104
|
+
// cross-origin frames. '*' is needed for this frame's opaque origin.
|
|
105
|
+
frame.setAttribute('allow', "autoplay *; fullscreen *; gamepad *; camera 'none'; microphone 'none'; geolocation 'none'");
|
|
101
106
|
frame.referrerPolicy = 'no-referrer';
|
|
102
107
|
frame.srcdoc = source;
|
|
103
108
|
return frame;
|
|
@@ -105,7 +110,9 @@ function createFrame(document, source) {
|
|
|
105
110
|
|
|
106
111
|
async function buildIsolatedHtml(project, options) {
|
|
107
112
|
const document = await runtimeDocument(project, options);
|
|
108
|
-
const
|
|
113
|
+
const studioUrl = new URL(options.studioUrl || '/studio.html', options.sharing?.joinUrl || 'https://homegames.io/play.html').href;
|
|
114
|
+
const payload = inline({ formatVersion: 1, document, archive: base64(await exportArchive(project)), name: filename(project.metadata.name), title:project.metadata.name||'Homegame', author:project.metadata.author||project.metadata.createdBy||'',
|
|
115
|
+
studioUrl, homeUrl: new URL('/catalog.html', studioUrl).href,
|
|
109
116
|
saves:{packageRevision:project.revision,installationId:options.installationId,gameId:project.manifest.gameId,versionId:'save-schema:'+project.manifest.saveSchemaVersion,compatibility:project.metadata.saveCompatibility || []} });
|
|
110
117
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>${escapeHtml(project.metadata.name || 'Homegame')}</title>
|
|
111
118
|
<style>html,body{margin:0;height:100%;background:#101820;color:#f7fafc;font:14px system-ui}body{display:flex;flex-direction:column}iframe{flex:1;width:100%;border:0;min-height:0}[hidden]{display:none!important}</style></head><body>
|
|
@@ -114,12 +121,14 @@ async function buildIsolatedHtml(project, options) {
|
|
|
114
121
|
<script>const payload=JSON.parse(document.getElementById('homegames-project').textContent);
|
|
115
122
|
const originalHtml='<!doctype html>\\n'+document.documentElement.outerHTML;
|
|
116
123
|
let frame,saveHost,clientHost;
|
|
117
|
-
const toolbar=(${createPlayerToolbar.toString()})(window,{title:payload.title,status:'Starting game…',onOpen:()=>clientHost?.command('pause-input'),onClose:()=>clientHost?.command('resume-input')});document.body.prepend(toolbar.bar);
|
|
124
|
+
const toolbar=(${createPlayerToolbar.toString()})(window,{title:payload.title,author:payload.author,home:payload.homeUrl,status:'Starting game…',onOpen:()=>clientHost?.command('pause-input'),onClose:()=>clientHost?.command('resume-input')});document.body.prepend(toolbar.bar);
|
|
118
125
|
const showSaveStatus=message=>toolbar.setStatus(message);
|
|
119
|
-
|
|
126
|
+
const editGame=getArchive=>{showSaveStatus('Opening Studio…');return (${openGameInStudio.toString()})(window,getArchive,payload.studioUrl).then(()=>showSaveStatus('Editable copy saved in Studio'));};
|
|
127
|
+
async function mount(){clientHost?.close();if(saveHost)await saveHost.close();frame?.remove();frame=(${createFrame.toString()})(document,payload.document);saveHost=(${attachSaveHost.toString()})(window,frame,{...payload.saves,onStatus:value=>showSaveStatus(value.message)});clientHost=(${attachClientHost.toString()})(window,frame,(${recordAudio.toString()}),(${createExportStorage.toString()})(window,payload.saves),(${createExportsViewer.toString()}),(${recordVideo.toString()}),(${createPlayerToolbar.toString()}),{toolbar,editGame,htmlId:'portable-html-copy',zipId:'portable-copy',profile:(${createPlayerIdentity.toString()})().profile(window)});document.body.appendChild(frame);}
|
|
120
128
|
const fallback=(bytes,type,name)=>{const url=URL.createObjectURL(new Blob([bytes],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),30000);};
|
|
121
129
|
toolbar.setAction('download-html',{label:'Download game HTML',id:'portable-html-copy',run:()=>fallback(originalHtml,'text/html',payload.name.replace(/\\.zip$/,'.html'))});
|
|
122
130
|
toolbar.setAction('download-zip',{label:'Download game ZIP',id:'portable-copy',run:()=>fallback(Uint8Array.from(atob(payload.archive),c=>c.charCodeAt(0)),'application/zip',payload.name)});
|
|
131
|
+
toolbar.setAction('edit-game',{label:'Edit this game',id:'edit-game',run:()=>editGame(()=>Uint8Array.from(atob(payload.archive),c=>c.charCodeAt(0)))});
|
|
123
132
|
toolbar.setAction('save-download',{label:'Download save',id:'portable-save-download',run:()=>saveHost.downloadSave(payload.name.replace(/\\.zip$/,'-save.json'))});
|
|
124
133
|
toolbar.setAction('save-import',{label:'Load save',id:'portable-save-import',run:()=>document.getElementById('portable-save-file').click()});
|
|
125
134
|
mount();
|
package/portable-project.js
CHANGED
|
@@ -317,5 +317,26 @@ function checkSyntax(source) {
|
|
|
317
317
|
catch (error) { return { message: error.message, line: error.loc?.line || 1 }; }
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
|
|
320
|
+
// Library artwork is read from the entry's literal metadata, even when an
|
|
321
|
+
// unrelated project file is unfinished. Never run game code to find an image.
|
|
322
|
+
function thumbnailPath(files) {
|
|
323
|
+
try {
|
|
324
|
+
const manifest = json(bytes(files[MANIFEST]), MANIFEST), entry = normalizePath(manifest.entry);
|
|
325
|
+
const source = bytes(files[entry]);if(source.length > LIMITS.sourceBytes)return null;
|
|
326
|
+
const tree = acorn.parse(text(source), { ecmaVersion: 'latest', sourceType: 'script' });
|
|
327
|
+
const methods = [], bindings = new Map();
|
|
328
|
+
walk(tree, node => { if(node.type === 'MethodDefinition' && node.static && !node.computed && (node.key.name ?? node.key.value) === 'metadata')methods.push(node); });
|
|
329
|
+
const body = methods[0]?.value.body.body;
|
|
330
|
+
if(methods.length !== 1 || body.length !== 1 || body[0].type !== 'ReturnStatement' || body[0].argument?.type !== 'ObjectExpression')return null;
|
|
331
|
+
for(const statement of tree.body)if(statement.type === 'VariableDeclaration' && statement.kind === 'const') {
|
|
332
|
+
for(const declaration of statement.declarations)if(declaration.id.type === 'Identifier')bindings.set(declaration.id.name, declaration.init);
|
|
333
|
+
}
|
|
334
|
+
const property = body[0].argument.properties.find(prop => prop.type === 'Property' && !prop.computed && !prop.method && prop.kind === 'init' && (prop.key.name ?? prop.key.value) === 'thumbnail');
|
|
335
|
+
if(!property)return null;
|
|
336
|
+
const path = normalizePath(literal(property.value, 'thumbnail', bindings));
|
|
337
|
+
return own(files, path) ? path : null;
|
|
338
|
+
} catch (_) { return null; }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = { checkSyntax, thumbnailPath, MANIFEST, INVENTORY, LIMITS, normalizePath, resolveModule, canonical,
|
|
321
342
|
createProject, exportArchive, importArchive, assetBundle };
|
package/portable-runtime.js
CHANGED
|
@@ -55,6 +55,7 @@ function prepareTrustedLocalGame(project, squishMap, { trusted = false, installa
|
|
|
55
55
|
if (typeof installationId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(installationId)) throw new Error('A trusted local installation ID is required for project saves');
|
|
56
56
|
return {
|
|
57
57
|
name: project.metadata.name || 'Homegame',
|
|
58
|
+
author: project.metadata.author || project.metadata.createdBy,
|
|
58
59
|
gameClass: loadTrustedGame(project, squishMap, { trusted }),
|
|
59
60
|
// No authored media means no packet. RTC correctly rejects zero-byte
|
|
60
61
|
// packets; uploads can still register assets later in the session.
|
package/portable-saves.js
CHANGED
|
@@ -19,6 +19,7 @@ function attachSaveHost(window, frame, settings) {
|
|
|
19
19
|
};
|
|
20
20
|
const envelope = (data, schema = versionId) => ({ gameKey, gameId, versionId: schema, savedAt: Date.now(), data: clean(data) });
|
|
21
21
|
function database() {
|
|
22
|
+
if (settings.database) return Promise.resolve(settings.database);
|
|
22
23
|
if (!dbPromise) dbPromise = new Promise((resolve, reject) => {
|
|
23
24
|
let blocked = false;
|
|
24
25
|
const request = window.indexedDB.open('homegames-portable-saves', 1);
|
|
@@ -67,7 +68,7 @@ function attachSaveHost(window, frame, settings) {
|
|
|
67
68
|
if (storageFailure) throw storageFailure;
|
|
68
69
|
const db=await database(), next=latest;
|
|
69
70
|
await new Promise((resolve,reject) => {
|
|
70
|
-
const tx=db.transaction('saves','readwrite'),store=tx.objectStore('saves');let error;
|
|
71
|
+
const tx=settings.transaction ? settings.transaction(['saves']) : db.transaction('saves','readwrite'),store=tx.objectStore('saves');let error;
|
|
71
72
|
const request=store.get([installationId,versionId]);
|
|
72
73
|
request.onsuccess=()=>{
|
|
73
74
|
try {
|
package/studio-debug.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Studio-only debugging across the opaque player boundary. The game never gets
|
|
3
|
+
// access to the editor, its storage, or executable callbacks in the parent.
|
|
4
|
+
function inspectorTree(value) {
|
|
5
|
+
let count = 0;
|
|
6
|
+
const number = value => Number.isFinite(value) ? value : 0;
|
|
7
|
+
const point = value => ({x:number(value?.x), y:number(value?.y)});
|
|
8
|
+
function walk(node, depth) {
|
|
9
|
+
if (!node || typeof node !== 'object' || depth > 48 || ++count > 4000) return null;
|
|
10
|
+
const result = {id:typeof node.id === 'number' ? node.id : String(node.id).slice(0,100), children:[]};
|
|
11
|
+
if (Array.isArray(node.coordinates2d)) result.coordinates2d = node.coordinates2d.slice(0,512).filter(p=>Array.isArray(p)&&p.length>=2).map(p=>[number(p[0]),number(p[1])]);
|
|
12
|
+
if (Array.isArray(node.fill)) result.fill = node.fill.slice(0,4).map(number);
|
|
13
|
+
if (node.text) result.text = {...point(node.text),text:String(node.text.text||'').slice(0,2000),size:number(node.text.size),align:String(node.text.align||'').slice(0,20)};
|
|
14
|
+
if (node.asset) result.asset = {key:String(node.asset.key||'').slice(0,200),pos:point(node.asset.pos),size:point(node.asset.size)};
|
|
15
|
+
if (Array.isArray(node.playerIds)) result.playerIds = node.playerIds.slice(0,64).filter(Number.isFinite);
|
|
16
|
+
if (node.clickable) result.clickable = true;
|
|
17
|
+
if (node.input) result.input = String(node.input).slice(0,100);
|
|
18
|
+
if (Array.isArray(node.children)) for (const child of node.children) { if(count>=4000)break;const next=walk(child,depth+1);if(next)result.children.push(next); }
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
return Array.isArray(value) ? value.slice(0,128).map(node=>walk(node,0)).filter(Boolean) : [];
|
|
22
|
+
}
|
|
23
|
+
function attachStudioDebugHost(window, frame, {onReady=()=>{},onError=()=>{},onLog=()=>{}} = {}) {
|
|
24
|
+
let port, closed=false, sequence=0, playerId=1, snapshot, input, pick;
|
|
25
|
+
const pending=new Map();
|
|
26
|
+
function request(action,value) {
|
|
27
|
+
if(!port||closed)return Promise.reject(new Error('Preview debugger is not ready'));
|
|
28
|
+
return new Promise((resolve,reject)=>{
|
|
29
|
+
const id=++sequence,timer=setTimeout(()=>{pending.delete(id);reject(new Error('Preview debugger timed out'));},5000);
|
|
30
|
+
pending.set(id,{resolve,reject,timer});port.postMessage({id,action,value});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const command=(action,value)=>{request(action,value).catch(error=>{if(!closed)onError(error);});};
|
|
34
|
+
function connect(event) {
|
|
35
|
+
if(closed||port||event.source!==frame.contentWindow||event.origin!=='null'||event.data?.type!=='homegames-studio-debug-v1')return;
|
|
36
|
+
const channel=new window.MessageChannel();port=channel.port1;
|
|
37
|
+
port.onmessage=({data})=>{
|
|
38
|
+
if(closed||!data)return;
|
|
39
|
+
if(data.event==='ready'){playerId=Number.isFinite(data.playerId)?data.playerId:1;onReady(data.available===true);}
|
|
40
|
+
else if(data.event==='snapshot'&&snapshot&&Number.isFinite(data.time)&&Number.isSafeInteger(data.frame))snapshot({time:data.time,buf:data.frame,tree:inspectorTree(data.tree)});
|
|
41
|
+
else if(data.event==='input'&&input&&Number.isFinite(data.value?.time))input({time:data.value.time,type:String(data.value.type||'').slice(0,40),key:String(data.value.key||'').slice(0,80)});
|
|
42
|
+
else if(data.event==='pick'&&pick){const x=data.x===null?null:Number(data.x),y=data.y===null?null:Number(data.y);if((x===null||Number.isFinite(x))&&(y===null||Number.isFinite(y))){if(data.kind==='select'){const callback=pick.onPick;pick=null;callback?.(x,y);}else pick.onMove?.(x,y);}}
|
|
43
|
+
else if(data.event==='log'&&['log','warn','error'].includes(data.level))onLog(data.level,String(data.message).slice(0,4000));
|
|
44
|
+
else if(data.event==='result'){const entry=pending.get(data.id);if(!entry)return;pending.delete(data.id);clearTimeout(entry.timer);data.error?entry.reject(new Error(String(data.error).slice(0,500))):entry.resolve(data.value);}
|
|
45
|
+
};
|
|
46
|
+
frame.contentWindow.postMessage({type:'homegames-studio-debug-port-v1'},'*',[channel.port2]);
|
|
47
|
+
}
|
|
48
|
+
window.addEventListener('message',connect);
|
|
49
|
+
return {
|
|
50
|
+
embedded:true,get playerId(){return playerId;},
|
|
51
|
+
enableInspector(onSnapshot,onInput){snapshot=onSnapshot;input=onInput;command('inspect',true);},
|
|
52
|
+
disableInspector(){snapshot=input=pick=null;command('inspect',false);},
|
|
53
|
+
showFrame:id=>command('frame',id),resumeLive:()=>command('live'),setHighlight:points=>command('highlight',points),
|
|
54
|
+
enablePick(callbacks){pick=callbacks;command('pick',true);},disablePick(){pick=null;command('pick',false);},
|
|
55
|
+
addPlayer:()=>request('add-player'),removePlayer:id=>command('remove-player',id),freeze:()=>command('freeze'),
|
|
56
|
+
stop(){if(closed)return;closed=true;window.removeEventListener('message',connect);port?.close();snapshot=input=pick=null;for(const entry of pending.values()){clearTimeout(entry.timer);entry.reject(new Error('Preview closed'));}pending.clear();}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function connectStudioDebugSource(window, getSession) {
|
|
60
|
+
const document=window.document,frames=new Map();
|
|
61
|
+
let port,closed=false,available=false,frameId=0,frameBytes=0,secondId=null,secondView,firstLabel;
|
|
62
|
+
const send=data=>{if(!closed&&port)port.postMessage(data);};
|
|
63
|
+
const resize=()=>window.dispatchEvent(new window.Event('resize'));
|
|
64
|
+
function removePlayer(session) {if(secondId!==null)session?.removePlayer(secondId);secondId=null;secondView?.remove();secondView=null;firstLabel?.remove();firstLabel=null;resize();}
|
|
65
|
+
const label=(container,text)=>{const el=document.createElement('span');el.textContent=text;el.style.cssText='position:absolute;left:8px;top:6px;z-index:2;background:#101820cc;color:#fff;padding:3px 6px;border-radius:4px;font:12px system-ui;pointer-events:none';container.style.position='relative';container.append(el);return el;};
|
|
66
|
+
const commands={
|
|
67
|
+
inspect(session,enabled){frames.clear();frameBytes=0;if(!enabled){session.disableInspector();return;}
|
|
68
|
+
session.enableInspector(snap=>{if(!snap.buf?.byteLength||snap.buf.byteLength>8*1024*1024)return;const id=++frameId;frames.set(id,snap.buf.slice());frameBytes+=snap.buf.byteLength;while(frames.size>600||frameBytes>64*1024*1024){const oldest=frames.keys().next().value;frameBytes-=frames.get(oldest).byteLength;frames.delete(oldest);}send({event:'snapshot',frame:id,time:snap.time,tree:inspectorTree(snap.tree)});},value=>send({event:'input',value}));},
|
|
69
|
+
frame(session,id){const buf=frames.get(id);if(!buf)throw new Error('This recorded frame is no longer available');session.showFrame(buf);},
|
|
70
|
+
live:session=>session.resumeLive(),highlight:(session,value)=>session.setHighlight(value),
|
|
71
|
+
pick(session,enabled){if(!enabled){session.disablePick();return;}session.enablePick({onMove:(x,y)=>send({event:'pick',kind:'move',x,y}),onPick:(x,y)=>send({event:'pick',kind:'select',x,y})});},
|
|
72
|
+
'add-player'(session){if(secondId!==null)return secondId;if(session.frozen)throw new Error('Restart the game before adding a player');
|
|
73
|
+
const game=document.getElementById('game');secondView=document.createElement('div');secondView.id='studio-debug-player-2';secondView.style.cssText='flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;border-top:1px solid #45546a';game.after(secondView);resize();
|
|
74
|
+
secondId=session.addPlayer(secondView.id);if(secondId===null){removePlayer(session);throw new Error('Could not add a player');}
|
|
75
|
+
firstLabel=label(game,'Player '+session.playerId);label(secondView,'Player '+secondId);resize();return secondId;},
|
|
76
|
+
'remove-player':session=>removePlayer(session),freeze(session){removePlayer(session);session.freeze();}
|
|
77
|
+
};
|
|
78
|
+
function connect(event){
|
|
79
|
+
if(closed||port||event.source!==window.parent||event.data?.type!=='homegames-studio-debug-port-v1'||!event.ports[0])return;
|
|
80
|
+
window.removeEventListener('message',connect);port=event.ports[0];
|
|
81
|
+
port.onmessage=({data})=>{if(closed||!data||!Number.isSafeInteger(data.id))return;
|
|
82
|
+
try {const session=getSession();if(!session)throw new Error('Debug tools require a local game');if(!Object.hasOwn(commands,data.action))throw new Error('Unknown debug action');const value=commands[data.action](session,data.value);send({event:'result',id:data.id,value});}
|
|
83
|
+
catch(error){send({event:'result',id:data.id,error:error.message});}
|
|
84
|
+
};send({event:'ready',available,playerId:getSession()?.playerId});
|
|
85
|
+
}
|
|
86
|
+
window.addEventListener('message',connect);window.parent.postMessage({type:'homegames-studio-debug-v1'},'*');
|
|
87
|
+
const originals={},wrappers={};let logWindow=0,logs=0;
|
|
88
|
+
for(const level of ['log','warn','error']){originals[level]=window.console[level];wrappers[level]=(...args)=>{originals[level].apply(window.console,args);const now=Date.now();if(now-logWindow>1000){logs=0;logWindow=now;}if(++logs>25)return;const message=args.map(value=>{try{return typeof value==='string'?value:JSON.stringify(value);}catch(_){return String(value);}}).join(' ').slice(0,4000);send({event:'log',level,message});};window.console[level]=wrappers[level];}
|
|
89
|
+
return {ready(){available=!!getSession();send({event:'ready',available,playerId:getSession()?.playerId});},close(){if(closed)return;closed=true;window.removeEventListener('message',connect);getSession()?.disableInspector();removePlayer(getSession());frames.clear();port?.close();for(const level of Object.keys(originals))if(window.console[level]===wrappers[level])window.console[level]=originals[level];}};
|
|
90
|
+
}
|
|
91
|
+
module.exports={inspectorTree,attachStudioDebugHost,connectStudioDebugSource};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Self-contained: the sender is also embedded in standalone HTML. Project
|
|
4
|
+
// bytes travel between these two windows, never in a URL or server request.
|
|
5
|
+
function openGameInStudio(window, getArchive, studioUrl = 'https://homegames.io/studio.html') {
|
|
6
|
+
const url = new URL(studioUrl);
|
|
7
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('Invalid Studio address');
|
|
8
|
+
const token = window.crypto.randomUUID(), origin = url.origin;
|
|
9
|
+
// Browsers serialize file message origins as either "null" or "file://".
|
|
10
|
+
url.hash = new URLSearchParams({ edit: token, from: window.location.protocol === 'file:' ? 'file://' : window.location.origin }).toString();
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let target, archive, sending = false, finished = false;
|
|
13
|
+
const finish = error => {
|
|
14
|
+
if (finished) return; finished = true;
|
|
15
|
+
clearTimeout(timer); clearInterval(closed);
|
|
16
|
+
window.removeEventListener('message', receive); window.removeEventListener('pagehide', leaving);
|
|
17
|
+
error ? reject(error) : resolve();
|
|
18
|
+
};
|
|
19
|
+
const leaving = () => finish(new Error('Keep the game open until Studio receives it.'));
|
|
20
|
+
const receive = event => {
|
|
21
|
+
if (finished || !target || event.source !== target || event.origin !== origin || event.data?.token !== token) return;
|
|
22
|
+
if (event.data.type === 'homegames-studio-saved') return finish();
|
|
23
|
+
if (event.data.type === 'homegames-studio-error') return finish(new Error(String(event.data.error).slice(0, 300)));
|
|
24
|
+
if (event.data.type !== 'homegames-studio-ready' || sending) return;
|
|
25
|
+
sending = true;
|
|
26
|
+
archive ||= Promise.resolve().then(getArchive).then(bytes => {
|
|
27
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > 65 * 1024 * 1024) throw new Error('Invalid game archive');
|
|
28
|
+
return bytes;
|
|
29
|
+
});
|
|
30
|
+
archive.then(bytes => {
|
|
31
|
+
if (!finished) target.postMessage({ type: 'homegames-studio-game', token, bytes }, origin);
|
|
32
|
+
sending = false;
|
|
33
|
+
}).catch(finish);
|
|
34
|
+
};
|
|
35
|
+
const timer = setTimeout(() => finish(new Error('Studio did not receive the game. Open Studio online once, or import the game HTML or ZIP there.')), 90000);
|
|
36
|
+
const closed = setInterval(() => { if (target?.closed) finish(new Error('Studio was closed before saving the copy.')); }, 500);
|
|
37
|
+
window.addEventListener('message', receive); window.addEventListener('pagehide', leaving);
|
|
38
|
+
try {
|
|
39
|
+
target = window.open(url.href, '_blank');
|
|
40
|
+
if (!target) finish(new Error('Allow this game to open a Studio tab, or import its HTML or ZIP in Studio.'));
|
|
41
|
+
} catch (error) { finish(error); }
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function receiveGameInStudio(window, saveArchive) {
|
|
46
|
+
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
47
|
+
const token = params.get('edit'), origin = params.get('from'), source = window.opener;
|
|
48
|
+
const localFile = origin === 'file://';
|
|
49
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token || '') || !source) return Promise.reject(new Error('Reopen Edit this game from the player, or import its HTML or ZIP.'));
|
|
50
|
+
try {
|
|
51
|
+
if (origin !== 'null' && !localFile && (!['http:', 'https:'].includes(new URL(origin).protocol) || new URL(origin).origin !== origin)) throw new Error();
|
|
52
|
+
} catch (_) { return Promise.reject(new Error('Invalid game sender')); }
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let receiving = false, finished = false;
|
|
55
|
+
const send = data => { try { source.postMessage({ ...data, token }, origin === 'null' || localFile ? '*' : origin); } catch (_) { /* Sender may have closed. */ } };
|
|
56
|
+
const finish = (error, value) => {
|
|
57
|
+
if (finished) return; finished = true;
|
|
58
|
+
clearTimeout(timer); clearInterval(ping); window.removeEventListener('message', receive); window.removeEventListener('pagehide', leaving);
|
|
59
|
+
if (error) { send({ type: 'homegames-studio-error', error: error.message }); reject(error); }
|
|
60
|
+
else { send({ type: 'homegames-studio-saved' }); resolve(value); }
|
|
61
|
+
};
|
|
62
|
+
const leaving = () => finish(new Error('Studio closed before receiving the game.'));
|
|
63
|
+
const receive = event => {
|
|
64
|
+
if (finished || receiving || event.source !== source || (localFile ? !['null', 'file://'].includes(event.origin) : event.origin !== origin)
|
|
65
|
+
|| event.data?.token !== token || event.data.type !== 'homegames-studio-game') return;
|
|
66
|
+
const bytes = event.data.bytes;
|
|
67
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > 65 * 1024 * 1024) return finish(new Error('Invalid game archive'));
|
|
68
|
+
receiving = true; clearInterval(ping); clearTimeout(timer);
|
|
69
|
+
// The caller validates and persists the archive as data before ACK.
|
|
70
|
+
Promise.resolve().then(() => saveArchive(bytes)).then(value => finish(null, value), finish);
|
|
71
|
+
};
|
|
72
|
+
const ready = () => send({ type: 'homegames-studio-ready' });
|
|
73
|
+
const timer = setTimeout(() => finish(new Error('The game did not arrive. Keep its tab open and try Edit this game again.')), 90000);
|
|
74
|
+
const ping = setInterval(ready, 500);
|
|
75
|
+
window.addEventListener('message', receive); window.addEventListener('pagehide', leaving); ready();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { openGameInStudio, receiveGameInStudio };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test'), assert = require('node:assert/strict');
|
|
3
|
+
const { createPlayerToolbar } = require('../player-toolbar');
|
|
4
|
+
function windowWithDom() {
|
|
5
|
+
class Element {
|
|
6
|
+
constructor(tag) { this.tagName = tag; this.children = []; this.attributes = {}; }
|
|
7
|
+
setAttribute(name, value) { this.attributes[name] = value; }
|
|
8
|
+
append(...children) { children.forEach(child => { child.remove(); this.children.push(child); child.parent = this; }); }
|
|
9
|
+
prepend(child) { child.remove(); this.children.unshift(child); child.parent = this; }
|
|
10
|
+
insertBefore(child, sibling) { child.remove(); this.children.splice(this.children.indexOf(sibling), 0, child); child.parent = this; }
|
|
11
|
+
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
|
|
12
|
+
addEventListener() {}
|
|
13
|
+
click() { if (!this.disabled) this.onclick?.(); }
|
|
14
|
+
}
|
|
15
|
+
return { document: { body: new Element('body'), createElement: tag => new Element(tag) } };
|
|
16
|
+
}
|
|
17
|
+
test('website and embedded toolbar keep common primary actions and preserve popup gestures', async () => {
|
|
18
|
+
const toolbarFactory = (0, eval)('(' + createPlayerToolbar.toString() + ')');
|
|
19
|
+
for (const simple of [false, true]) {
|
|
20
|
+
const toolbar = toolbarFactory(windowWithDom(), { home: '/catalog.html', title: 'Test game', author: 'A developer', simple });
|
|
21
|
+
const home = toolbar.bar.children.find(child => child.attributes['aria-label'] === 'Home');
|
|
22
|
+
assert.equal(home.href, '/catalog.html');
|
|
23
|
+
assert.match(home.children[0].src, /^data:image\/png;base64,/);
|
|
24
|
+
const heading = toolbar.bar.children.find(child => child.className === 'hg-player-heading');
|
|
25
|
+
const [title, website] = heading.children[0].children;
|
|
26
|
+
assert.equal(title.children[0].textContent, 'Test game');
|
|
27
|
+
assert.equal(title.children[1].textContent, ' by A developer');
|
|
28
|
+
assert.equal(website.textContent, 'homegames.io');
|
|
29
|
+
assert.equal(website.href, 'https://homegames.io/');
|
|
30
|
+
assert.equal(website.target, '_blank');
|
|
31
|
+
assert.equal(website.rel, 'noopener noreferrer');
|
|
32
|
+
toolbar.setTitle('Renamed game');
|
|
33
|
+
assert.equal(title.children[1].textContent, ' by A developer', 'Title updates retain the author and website link');
|
|
34
|
+
toolbar.setAuthor(''); assert.equal(title.children[1].hidden, true);
|
|
35
|
+
let clicked = 0, resolve;
|
|
36
|
+
toolbar.setAction('download-html', { label: 'Download game HTML', run() {} });
|
|
37
|
+
toolbar.setAction('exports', { label: 'View exports', run() {} });
|
|
38
|
+
const edit = { label: 'Edit this game', run() { clicked++; return new Promise(done => { resolve = done; }); } };
|
|
39
|
+
toolbar.setAction('edit-game', edit);
|
|
40
|
+
const button = toolbar.bar.children.find(child => child.attributes['aria-label'] === 'Edit this game');
|
|
41
|
+
assert.ok(button && !button.hidden);
|
|
42
|
+
assert.ok(toolbar.bar.children.some(child => child.textContent === 'Download file' && !child.hidden));
|
|
43
|
+
let installs = 0;
|
|
44
|
+
toolbar.setAction('install', { label: 'Download app', run() { installs++; } });
|
|
45
|
+
assert.ok(toolbar.bar.children.some(child => child.textContent === 'Download app' && !child.hidden));
|
|
46
|
+
assert(!toolbar.bar.children.some(child => child.textContent === 'Download file'));
|
|
47
|
+
const menuActions = toolbar.menu.children.find(child => child.className === 'hg-player-menu-actions');
|
|
48
|
+
assert(menuActions.children.some(child => child.textContent === 'Download game file (HTML)' && !child.hidden));
|
|
49
|
+
toolbar.runAction('install'); assert.equal(installs, 1, 'Native install prompts retain the click gesture');
|
|
50
|
+
toolbar.setAction('download-html', { label: 'Download game HTML', run() {} });
|
|
51
|
+
assert(menuActions.children.some(child => child.textContent === 'Download game file (HTML)' && !child.hidden), 'Player-ready updates retain the portable download');
|
|
52
|
+
toolbar.setAction('install', null);
|
|
53
|
+
assert.ok(toolbar.bar.children.some(child => child.textContent === 'Download file' && !child.hidden));
|
|
54
|
+
assert.equal(toolbar.bar.children.some(child => child.textContent === 'View exports'), false);
|
|
55
|
+
assert.equal(toolbar.runAction('edit-game'), true);
|
|
56
|
+
assert.equal(clicked, 1, 'The popup callback runs within the click, before yielding');
|
|
57
|
+
assert.equal(button.disabled, true);
|
|
58
|
+
toolbar.setAction('edit-game', edit); // A player-state update must not permit duplicate clicks.
|
|
59
|
+
assert.equal(toolbar.runAction('edit-game'), false);
|
|
60
|
+
resolve(); await new Promise(done => setImmediate(done));
|
|
61
|
+
assert.equal(button.disabled, false);
|
|
62
|
+
toolbar.setAction('edit-game', null); assert.equal(button.hidden, true);
|
|
63
|
+
toolbar.dispose();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { attachClientHost } = require('../portable-client');
|
|
5
|
+
|
|
6
|
+
test('player downloads use the game title, honor a recovered name, and keep safe filenames', async t => {
|
|
7
|
+
const listeners = new Map(), actions = new Map(), downloads = [];
|
|
8
|
+
let source, nextTitle, resolveState, title, editedArchive;
|
|
9
|
+
const toolbar = { setTitle() {}, setPlayerName() {}, setAction(key, action) { actions.set(key, action); },
|
|
10
|
+
setStatus() { resolveState?.(); resolveState = null; } };
|
|
11
|
+
const frame = { style: {}, parentElement: { style: {} }, contentWindow: {
|
|
12
|
+
postMessage(message, origin, ports) {
|
|
13
|
+
source = ports[0];
|
|
14
|
+
source.onmessage = event => source.postMessage({ type: 'game-download', id: event.data.id, bytes: new Uint8Array([80, 75]) });
|
|
15
|
+
}
|
|
16
|
+
} };
|
|
17
|
+
const window = { MessageChannel, URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} },
|
|
18
|
+
document: { createElement: () => ({ click() { downloads.push(this.download); } }) },
|
|
19
|
+
addEventListener: (type, listener) => listeners.set(type, listener), removeEventListener: type => listeners.delete(type) };
|
|
20
|
+
// The production HTML embeds this function's source, so also exercise it
|
|
21
|
+
// without access to the module's lexical scope.
|
|
22
|
+
const attach = (0, eval)('(' + attachClientHost.toString() + ')');
|
|
23
|
+
const host = attach(window, frame, null, null, null, null, null, { toolbar, downloadName: () => title,
|
|
24
|
+
editGame: async getArchive => { editedArchive = await getArchive(); } });
|
|
25
|
+
t.after(() => { host.close(); source?.close(); });
|
|
26
|
+
listeners.get('message')({ source: frame.contentWindow, origin: 'null', data: { type: 'homegames-client-connect-v1' } });
|
|
27
|
+
const state = async value => {
|
|
28
|
+
nextTitle = new Promise(resolve => { resolveState = resolve; });
|
|
29
|
+
source.postMessage({ type: 'player-state', title: value, status: 'Playing', ready: true });
|
|
30
|
+
await nextTitle;
|
|
31
|
+
};
|
|
32
|
+
// Complete cleanup timers without waiting thirty seconds for each URL.
|
|
33
|
+
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
34
|
+
await state('Weed Smoke Willie');
|
|
35
|
+
await actions.get('edit-game').run();
|
|
36
|
+
assert.deepEqual(editedArchive, new Uint8Array([80, 75]), 'Edit requests the live runtime ZIP through the same bridge as downloads');
|
|
37
|
+
await actions.get('download-html').run();
|
|
38
|
+
await actions.get('download-zip').run();
|
|
39
|
+
assert.deepEqual(downloads.splice(0), ['weed-smoke-willie.html', 'weed-smoke-willie.zip']);
|
|
40
|
+
await state('Homegame'); title = 'Pocket Chime';
|
|
41
|
+
await host.download('html');
|
|
42
|
+
assert.equal(downloads.pop(), 'pocket-chime.html', 'A recovered catalog name wins over an older embedded title');
|
|
43
|
+
title = null;
|
|
44
|
+
for (const [name, expected] of [['../CON', 'con-game.html'], ['!!!', 'game.html'], ['X'.repeat(100), 'x'.repeat(80) + '.html']]) {
|
|
45
|
+
await state(name); await host.download('html'); assert.equal(downloads.pop(), expected);
|
|
46
|
+
}
|
|
47
|
+
t.mock.timers.tick(30000);
|
|
48
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { createFrame } = require('../portable-html');
|
|
5
|
+
|
|
6
|
+
test('catalog, Studio, and serialized offline frames explicitly allow gamepads while retaining opaque isolation', () => {
|
|
7
|
+
// Exported HTML embeds the function, so it must also work out of module scope.
|
|
8
|
+
for (const create of [createFrame, (0, eval)('(' + createFrame.toString() + ')')]) {
|
|
9
|
+
const frame = { attributes: {}, setAttribute(name, value) { this.attributes[name] = value; } };
|
|
10
|
+
const result = create({ createElement(tag) { assert.equal(tag, 'iframe'); return frame; } }, '<p>Game</p>');
|
|
11
|
+
assert.equal(result.srcdoc, '<p>Game</p>');
|
|
12
|
+
assert.match(result.attributes.allow, /(?:^|;\s*)gamepad \*(?:;|$)/);
|
|
13
|
+
assert.equal(result.attributes.sandbox, 'allow-scripts allow-pointer-lock');
|
|
14
|
+
assert.match(result.attributes.allow, /camera 'none'/);
|
|
15
|
+
assert.match(result.attributes.allow, /microphone 'none'/);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test'), assert = require('node:assert/strict'), path = require('node:path');
|
|
3
|
+
const { readDirectory, options } = require('../portable-project-node');
|
|
4
|
+
const { createProject, importArchive } = require('../portable-project');
|
|
5
|
+
const { buildIsolatedHtml, readHtmlArchive } = require('../portable-html');
|
|
6
|
+
|
|
7
|
+
test('standalone HTML uses the shared edit control and preserves the full project as data', async () => {
|
|
8
|
+
const original = await readDirectory(path.resolve(__dirname, '../../homegames-core/test/fixtures/portable-sound'));
|
|
9
|
+
const files = Object.fromEntries(original.reader.listFiles().map(name => [name, original.reader.readFile(name)]));
|
|
10
|
+
files['user-exports/drawing.bin'] = new Uint8Array([0, 255, 10, 13, 128]);
|
|
11
|
+
const project = await createProject(files, options);
|
|
12
|
+
const html = await buildIsolatedHtml(project, { clientBundleSource: '/* PortableProject */ globalThis.wrapperExecuted=true;',
|
|
13
|
+
installationId: '12345678-1234-1234-1234-123456789012', sharing: { joinUrl: 'http://localhost:81/play.html' } });
|
|
14
|
+
const payload = JSON.parse(html.match(/<script id="homegames-project" type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
|
15
|
+
assert.equal(payload.studioUrl, 'http://localhost:81/studio.html');
|
|
16
|
+
assert.equal(payload.homeUrl, 'http://localhost:81/catalog.html');
|
|
17
|
+
assert.match(html, /Edit this game/); assert.match(html, /homegames-studio-game/);
|
|
18
|
+
assert.match(payload.document, /studioUrl:payload.studioUrl/);
|
|
19
|
+
const copy = await importArchive(readHtmlArchive(html), options);
|
|
20
|
+
for (const name of project.reader.listFiles()) assert.deepEqual(copy.reader.readFile(name), project.reader.readFile(name), name);
|
|
21
|
+
assert.equal(globalThis.wrapperExecuted, undefined);
|
|
22
|
+
await assert.rejects(buildIsolatedHtml(project, { clientBundleSource: 'PortableProject', installationId: '1234567890123456', studioUrl: 'javascript:alert(1)' }), /Studio URL/);
|
|
23
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test=require('node:test'),assert=require('node:assert/strict');
|
|
3
|
+
const {inspectorTree,attachStudioDebugHost,connectStudioDebugSource}=require('../studio-debug');
|
|
4
|
+
|
|
5
|
+
test('inspector trees are bounded data, even with deep, cyclic or malformed nodes',()=>{
|
|
6
|
+
const node={id:1,children:[],coordinates2d:[[1,2],[Infinity,3]],text:{text:'x'.repeat(3000),size:5}};node.children.push(node);
|
|
7
|
+
const tree=inspectorTree([node]);let depth=0,current=tree[0];while(current){depth++;current=current.children[0];}
|
|
8
|
+
assert.equal(depth,49);assert.equal(tree[0].text.text.length,2000);assert.deepEqual(tree[0].coordinates2d,[[1,2],[0,3]]);
|
|
9
|
+
assert.deepEqual(inspectorTree(null),[]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('isolated Studio debug bridge restores inspection, picking, player views and cleanup',async t=>{
|
|
13
|
+
class Element {
|
|
14
|
+
constructor(tag){this.tagName=tag;this.children=[];this.style={};}
|
|
15
|
+
append(child){this.children.push(child);child.parent=this;}
|
|
16
|
+
after(child){this.parent.append(child);}
|
|
17
|
+
remove(){if(this.parent)this.parent.children=this.parent.children.filter(child=>child!==this);}
|
|
18
|
+
}
|
|
19
|
+
const body=new Element('body'),game=new Element('div');game.id='game';body.append(game);
|
|
20
|
+
const doc={body,createElement:tag=>new Element(tag),getElementById:id=>body.children.find(el=>el.id===id)};
|
|
21
|
+
const hostListeners=new Map(),sourceListeners=new Map(),errors=[],calls=[],logs=[];
|
|
22
|
+
const session={playerId:7,enableInspector(s,i){this.snapshot=s;this.input=i;},disableInspector(){this.snapshot=null;},
|
|
23
|
+
showFrame:buf=>calls.push(['frame',[...buf]]),resumeLive:()=>calls.push(['live']),setHighlight:value=>calls.push(['highlight',value]),
|
|
24
|
+
enablePick(callbacks){this.pick=callbacks;},disablePick(){this.pick=null;},
|
|
25
|
+
addPlayer:id=>{calls.push(['add',id]);return 8;},removePlayer:id=>calls.push(['remove',id]),freeze(){this.frozen=true;calls.push(['freeze']);}};
|
|
26
|
+
let readyResolve,snapshotResolve,pickResolve;
|
|
27
|
+
const ready=new Promise(resolve=>readyResolve=resolve);
|
|
28
|
+
const parent={postMessage:data=>hostListeners.get('message')?.({source:child,origin:'null',data})};
|
|
29
|
+
const child={document:doc,parent,Event:class{constructor(type){this.type=type;}},dispatchEvent(){},console:{log(){},warn(){},error(){}},
|
|
30
|
+
addEventListener:(type,fn)=>sourceListeners.set(type,fn),removeEventListener:type=>sourceListeners.delete(type),
|
|
31
|
+
postMessage:(data,origin,ports)=>sourceListeners.get('message')?.({source:parent,data,ports})};
|
|
32
|
+
const hostWindow={MessageChannel,addEventListener:(type,fn)=>hostListeners.set(type,fn),removeEventListener:type=>hostListeners.delete(type)};
|
|
33
|
+
const frame={contentWindow:child};
|
|
34
|
+
const host=attachStudioDebugHost(hostWindow,frame,{onReady:value=>{if(value)readyResolve(value);},onError:e=>errors.push(e),onLog:(level,message)=>logs.push([level,message])});
|
|
35
|
+
// A different frame cannot connect a debugging channel.
|
|
36
|
+
hostListeners.get('message')({source:{},origin:'null',data:{type:'homegames-studio-debug-v1'}});
|
|
37
|
+
const source=connectStudioDebugSource(child,()=>session);source.ready();
|
|
38
|
+
t.after(()=>{host.stop();source.close();});
|
|
39
|
+
assert.equal(await ready,true);assert.equal(host.playerId,7);
|
|
40
|
+
const snapshot=new Promise(resolve=>snapshotResolve=resolve);
|
|
41
|
+
host.enableInspector(value=>snapshotResolve(value));
|
|
42
|
+
child.console.warn('Preview warning', {count:2});
|
|
43
|
+
assert.equal(await host.addPlayer(),8); // Ordered acknowledgement also flushes inspect.
|
|
44
|
+
assert.ok(doc.getElementById('studio-debug-player-2'));
|
|
45
|
+
assert.deepEqual(logs,[['warn','Preview warning {"count":2}']]);
|
|
46
|
+
session.snapshot({time:100,buf:new Uint8Array([1,2,3]),tree:[{id:2,clickable:true,children:[]}]});
|
|
47
|
+
const recorded=await snapshot;assert.equal(typeof recorded.buf,'number');assert.equal(recorded.tree[0].id,2);
|
|
48
|
+
host.showFrame(recorded.buf);host.setHighlight([[0,0],[10,0],[10,10]]);
|
|
49
|
+
const picked=new Promise(resolve=>pickResolve=resolve);
|
|
50
|
+
host.enablePick({onPick:(x,y)=>pickResolve([x,y])});await host.addPlayer();
|
|
51
|
+
session.pick.onPick(20,30);assert.deepEqual(await picked,[20,30]);
|
|
52
|
+
host.freeze();await assert.rejects(host.addPlayer(),/Restart/);
|
|
53
|
+
assert.ok(calls.some(call=>call[0]==='frame'&&call[1].join(',')==='1,2,3'));
|
|
54
|
+
assert.ok(calls.some(call=>call[0]==='remove'&&call[1]===8));
|
|
55
|
+
assert.ok(calls.some(call=>call[0]==='freeze'));
|
|
56
|
+
host.stop();await assert.rejects(host.addPlayer(),/not ready/);
|
|
57
|
+
assert.equal(hostListeners.has('message'),false);assert.deepEqual(errors,[]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('Studio debugging is opt-in and exported game documents do not enable it',async()=>{
|
|
61
|
+
const path=require('node:path'),{readDirectory}=require('../portable-project-node');
|
|
62
|
+
const {runtimeDocument,buildIsolatedHtml}=require('../portable-html');
|
|
63
|
+
const project=await readDirectory(path.resolve(__dirname,'../../homegames-core/test/fixtures/portable-sound'));
|
|
64
|
+
const options={clientBundleSource:'/* PortableProject */',installationId:'12345678-1234-1234-1234-123456789012'};
|
|
65
|
+
const studio=await runtimeDocument(project,{...options,studioDebug:true});assert.match(studio,/"studioDebug":true/);
|
|
66
|
+
const standalone=await buildIsolatedHtml(project,options);
|
|
67
|
+
const payload=JSON.parse(standalone.match(/<script id="homegames-project" type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
|
68
|
+
assert.match(payload.document,/"studioDebug":false/);
|
|
69
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test'), assert = require('node:assert/strict');
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
const { openGameInStudio, receiveGameInStudio } = require('../studio-handoff');
|
|
5
|
+
const send = (0, eval)('(' + openGameInStudio.toString() + ')'); // Also runs embedded in HTML.
|
|
6
|
+
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
7
|
+
function pair(origin = 'null', protocol = 'https:') {
|
|
8
|
+
function endpoint(location) {
|
|
9
|
+
const events = new Map();
|
|
10
|
+
return { location, crypto: { randomUUID },
|
|
11
|
+
addEventListener(type, fn) { events.set(type, fn); },
|
|
12
|
+
removeEventListener(type) { events.delete(type); },
|
|
13
|
+
emit(type, event) { events.get(type)?.(event); },
|
|
14
|
+
get listenerCount() { return events.size; } };
|
|
15
|
+
}
|
|
16
|
+
const game = endpoint({ origin, protocol }), studio = endpoint({ origin: 'https://studio.example' });
|
|
17
|
+
const traffic = [];
|
|
18
|
+
const gameRef = { postMessage(data, target) {
|
|
19
|
+
traffic.push({ direction: 'game', data, target });
|
|
20
|
+
assert.ok(target === '*' || target === origin);
|
|
21
|
+
queueMicrotask(() => game.emit('message', { data, origin: studio.location.origin, source: studioRef }));
|
|
22
|
+
} };
|
|
23
|
+
const studioRef = { closed: false, postMessage(data, target) {
|
|
24
|
+
traffic.push({ direction: 'studio', data, target });
|
|
25
|
+
assert.equal(target, studio.location.origin);
|
|
26
|
+
queueMicrotask(() => studio.emit('message', { data, origin, source: gameRef }));
|
|
27
|
+
} };
|
|
28
|
+
game.open = url => { studio.location.hash = new URL(url).hash; return studioRef; };
|
|
29
|
+
studio.opener = gameRef;
|
|
30
|
+
return { game, studio, gameRef, studioRef, traffic };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const [protocol, origin] of [['file:', 'null'], ['file:', 'file://'], ['https:', 'https://game.example']]) test(`complete archive travels directly from ${origin} and is acknowledged after persistence`, async () => {
|
|
34
|
+
const ctx = pair(origin, protocol), bytes = new Uint8Array([80, 75, 0, 255, 7]);
|
|
35
|
+
let getCount = 0, saved = false, release, imports = 0;
|
|
36
|
+
const sent = send(ctx.game, () => { getCount++; return bytes; }, 'https://studio.example/studio.html').then(() => { saved = true; });
|
|
37
|
+
const received = receiveGameInStudio(ctx.studio, async value => {
|
|
38
|
+
imports++; assert.deepEqual(value, bytes);
|
|
39
|
+
await new Promise(resolve => { release = resolve; }); return 'project';
|
|
40
|
+
});
|
|
41
|
+
await tick();
|
|
42
|
+
assert.equal(saved, false);
|
|
43
|
+
const packet = ctx.traffic.find(item => item.direction === 'studio').data;
|
|
44
|
+
ctx.studioRef.postMessage(packet, ctx.studio.location.origin); // Duplicate delivery must not create a second project.
|
|
45
|
+
await tick(); assert.equal(imports, 1); release();
|
|
46
|
+
assert.equal(await received, 'project'); await sent;
|
|
47
|
+
assert.equal(getCount, 1); assert.equal(saved, true);
|
|
48
|
+
assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
|
|
49
|
+
assert.ok(!ctx.studio.location.hash.includes('bytes'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('unrelated windows, origins, and tokens cannot inject an archive or acknowledge a transfer', async () => {
|
|
53
|
+
const ctx = pair(), bytes = new Uint8Array([1]); let imports = 0;
|
|
54
|
+
const sent = send(ctx.game, () => bytes, 'https://studio.example/studio.html');
|
|
55
|
+
const token = new URLSearchParams(ctx.studio.location.hash.slice(1)).get('edit');
|
|
56
|
+
const valid = { source: ctx.gameRef, origin: 'null', data: { type: 'homegames-studio-game', token, bytes } };
|
|
57
|
+
const received = receiveGameInStudio(ctx.studio, () => { imports++; });
|
|
58
|
+
for (const patch of [{ source: {} }, { origin: 'https://evil.example' }, { data: { ...valid.data, token: randomUUID() } }]) {
|
|
59
|
+
ctx.studio.emit('message', { ...valid, ...patch });
|
|
60
|
+
}
|
|
61
|
+
assert.equal(imports, 0);
|
|
62
|
+
await received; await sent; assert.equal(imports, 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('failed persistence reaches the sender and releases both sides', async () => {
|
|
66
|
+
const ctx = pair();
|
|
67
|
+
const sent = assert.rejects(send(ctx.game, () => new Uint8Array([1]), 'https://studio.example/studio.html'), /storage full/);
|
|
68
|
+
const received = assert.rejects(receiveGameInStudio(ctx.studio, () => { throw new Error('storage full'); }), /storage full/);
|
|
69
|
+
await Promise.all([sent, received]);
|
|
70
|
+
assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('popup blocking and archive failures keep a usable recovery error', async () => {
|
|
74
|
+
const blocked = pair(); blocked.game.open = () => null;
|
|
75
|
+
await assert.rejects(send(blocked.game, () => {}), /import its HTML or ZIP/);
|
|
76
|
+
assert.equal(blocked.game.listenerCount, 0);
|
|
77
|
+
const ctx = pair();
|
|
78
|
+
const sent = assert.rejects(send(ctx.game, () => { throw new Error('Copy failed'); }, 'https://studio.example/studio.html'), /Copy failed/);
|
|
79
|
+
const received = receiveGameInStudio(ctx.studio, () => {});
|
|
80
|
+
const caught = assert.rejects(received, /closed/);
|
|
81
|
+
await sent; ctx.studio.emit('pagehide'); await caught;
|
|
82
|
+
assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('timeout and closed tabs clean up pending transfers', async t => {
|
|
86
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
|
87
|
+
const ctx = pair();
|
|
88
|
+
const sent = assert.rejects(send(ctx.game, () => {}, 'https://studio.example/studio.html'), /did not receive/);
|
|
89
|
+
t.mock.timers.tick(90000); await sent; assert.equal(ctx.game.listenerCount, 0);
|
|
90
|
+
const closed = pair();
|
|
91
|
+
const pending = assert.rejects(send(closed.game, () => {}, 'https://studio.example/studio.html'), /closed/);
|
|
92
|
+
closed.studioRef.closed = true; t.mock.timers.tick(500); await pending;
|
|
93
|
+
assert.equal(closed.game.listenerCount, 0);
|
|
94
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const test = require('node:test'), assert = require('node:assert/strict');
|
|
3
|
+
const { thumbnailPath } = require('../portable-project');
|
|
4
|
+
const project = source => ({'homegame.json':JSON.stringify({entry:'index.js'}),'index.js':source,
|
|
5
|
+
'assets/cover.png':new Uint8Array([137,80,78,71]),'unfinished.js':'class {'});
|
|
6
|
+
|
|
7
|
+
test('Studio reads declared local artwork without evaluating game code or requiring other files to compile', () => {
|
|
8
|
+
const files = project("globalThis.thumbnailCodeRan = true; const COVER = './assets/cover.png'; class Game { static metadata() { return {thumbnail:COVER}; } }");
|
|
9
|
+
assert.equal(thumbnailPath(files), 'assets/cover.png');
|
|
10
|
+
assert.equal(globalThis.thumbnailCodeRan, undefined);
|
|
11
|
+
assert.equal(thumbnailPath({...files,'index.js':new TextEncoder().encode(files['index.js'])}), 'assets/cover.png');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('missing, unsafe, computed and unfinished thumbnail metadata falls back without executing it', () => {
|
|
15
|
+
for(const value of ["'https://example.com/cover.png'", "'../cover.png'", "'assets/missing.png'", '(globalThis.thumbnailCodeRan = true)']) {
|
|
16
|
+
assert.equal(thumbnailPath(project(`class Game { static metadata() { return {thumbnail:${value}}; } }`)), null);
|
|
17
|
+
}
|
|
18
|
+
assert.equal(thumbnailPath(project('class {')), null);
|
|
19
|
+
assert.equal(thumbnailPath({}), null);
|
|
20
|
+
assert.equal(globalThis.thumbnailCodeRan, undefined);
|
|
21
|
+
});
|