homegames-common 1.7.0 → 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 +44 -34
- package/export-storage.js +3 -3
- package/game-session.js +15 -16
- package/package.json +1 -1
- package/player-identity.js +31 -0
- package/player-toolbar.js +72 -22
- package/portable-client.js +26 -8
- package/portable-html.js +25 -15
- 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
package/portable-html.js
CHANGED
|
@@ -9,6 +9,8 @@ const { createExportsViewer } = require('./exports-viewer');
|
|
|
9
9
|
const { recordVideo } = require('./video-recording');
|
|
10
10
|
const { createExportStorage } = require('./export-storage');
|
|
11
11
|
const { createPlayerToolbar } = require('./player-toolbar');
|
|
12
|
+
const { createPlayerIdentity } = require('./player-identity');
|
|
13
|
+
const { openGameInStudio } = require('./studio-handoff');
|
|
12
14
|
const inline = value => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
13
15
|
const escapeHtml = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
14
16
|
const filename = name => {
|
|
@@ -38,7 +40,7 @@ function base64(bytes) {
|
|
|
38
40
|
return btoa(binary);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
async function runtimeDocument(project, { clientBundleSource, installationId, sharing = {} } = {}) {
|
|
43
|
+
async function runtimeDocument(project, { clientBundleSource, installationId, sharing = {}, studioUrl, studioDebug = false } = {}) {
|
|
42
44
|
if (!clientBundleSource?.includes('PortableProject')) throw new Error('Rebuild the Homegames client before playing portable projects');
|
|
43
45
|
if (!/^[a-zA-Z0-9-]{16,80}$/.test(installationId || '')) throw new Error('Missing local installation ID');
|
|
44
46
|
const signal = new URL(sharing.signalingUrl || 'wss://api.homegames.io/rtc');
|
|
@@ -46,9 +48,11 @@ async function runtimeDocument(project, { clientBundleSource, installationId, sh
|
|
|
46
48
|
signal.protocol = signal.protocol === 'https:' ? 'wss:' : signal.protocol === 'http:' ? 'ws:' : signal.protocol;
|
|
47
49
|
const join = new URL(sharing.joinUrl || 'https://homegames.io/play.html');
|
|
48
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');
|
|
49
53
|
const receive = new URL(sharing.receiveUrl || '/game-app.html', join);
|
|
50
54
|
if (!['http:', 'https:'].includes(receive.protocol)) throw new Error('Invalid copy receiver URL');
|
|
51
|
-
const payload = { archive: base64(await exportArchive(project)), installationId,
|
|
55
|
+
const payload = { archive: base64(await exportArchive(project)), installationId, studioUrl: studio.href, studioDebug: studioDebug === true,
|
|
52
56
|
sharing: sharing === false ? false : { signalingUrl: signal.href, joinUrl: join.href, receiveUrl: receive.href } };
|
|
53
57
|
// No cookies, storage, parent DOM, navigation, popup or download permission.
|
|
54
58
|
// Network access is limited by CSP to the signaling endpoint; RTC itself is
|
|
@@ -60,7 +64,7 @@ html,body{margin:0;height:100%;background:#101820;color:#f7fafc;font:14px system
|
|
|
60
64
|
<script>${clientBundleSource.replace(/<\/script/gi, '<\\/script')}</script><script>
|
|
61
65
|
(async()=>{
|
|
62
66
|
const payload=${inline(payload)};
|
|
63
|
-
let client,clientSource,copyShare,pairing;
|
|
67
|
+
let client,clientSource,copyShare,pairing,debug;
|
|
64
68
|
const state=status=>clientSource?.setState({status});
|
|
65
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)));};
|
|
66
70
|
try {
|
|
@@ -71,19 +75,20 @@ const shareCopy=async()=>{const copy=await client.requestGameCopy();copyShare?.c
|
|
|
71
75
|
clientSource=HomegamesClient.PortableProject.connectClientSource(window,async format=>{
|
|
72
76
|
const copy=await client.requestGameCopy();
|
|
73
77
|
if(format==='zip')return copy.bytes;
|
|
74
|
-
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}));
|
|
75
79
|
},{list:()=>client.listExports(),read:name=>client.readExport(name)}, {
|
|
76
80
|
share:()=>client.showShare(),exports:()=>client.viewExports(),'copy-share':shareCopy,'manual-connect':()=>pairing?.show(),
|
|
77
|
-
'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>client.setInputEnabled(true)
|
|
81
|
+
'pause-input':()=>client.setInputEnabled(false),'resume-input':()=>{client.setInputEnabled(true);if(payload.studioDebug)client._localSession?._updateInputAttachment();},'player-name':name=>client.setPlayerName(name)
|
|
78
82
|
});
|
|
79
|
-
const ready=status=>clientSource.setState({title:project.metadata.name||'Homegame',status,ready:true,sharing:!!payload.sharing});
|
|
80
|
-
client=new HomegamesClient({containerId:'game',chrome:false,localHome:false,share:payload.sharing,saveStore,platform:clientSource,
|
|
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'});
|
|
84
|
+
client=new HomegamesClient({containerId:'game',chrome:false,localHome:false,share:payload.sharing,saveStore,platform:clientSource,playerName:await clientSource.getPlayerName?.(),
|
|
81
85
|
loadLocalGame:async()=>HomegamesClient.PortableProject.prepareTrustedLocalGame(project,{trusted:true,installationId:payload.installationId}),
|
|
82
|
-
onReady:()=>ready('Playing locally'),onSessionStatus:state,onError:fail});
|
|
86
|
+
onReady:()=>{ready('Playing locally');debug?.ready();},onSessionStatus:state,onError:fail});
|
|
83
87
|
pairing=payload.sharing?HomegamesClient.createManualPairing({container:document.body,getSession:()=>client._localSession,
|
|
84
88
|
onOpen:()=>client.setInputEnabled(false),onClose:()=>client.setInputEnabled(true),
|
|
85
|
-
join:transport=>{client.stop();client=new HomegamesClient({containerId:'game',chrome:false,transport,platform:clientSource,onReady:()=>ready('Connected manually to host'),onClose:()=>state('Host disconnected'),onError:fail});client.start();}}):null;
|
|
86
|
-
|
|
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({});
|
|
87
92
|
}catch(error){fail(error);}
|
|
88
93
|
})();
|
|
89
94
|
</script></body></html>`;
|
|
@@ -92,11 +97,12 @@ window.addEventListener('pagehide',()=>{copyShare?.close();pairing?.dispose();cl
|
|
|
92
97
|
function createFrame(document, source) {
|
|
93
98
|
const frame = document.createElement('iframe');
|
|
94
99
|
frame.title = 'Game preview';
|
|
95
|
-
// frame.setAttribute('sandbox', 'allow-scripts');
|
|
96
100
|
// Pointer Lock still requires a canvas gesture. Keep the opaque origin:
|
|
97
101
|
// enabling mouse capture does not grant parent/storage access.
|
|
98
102
|
frame.setAttribute('sandbox', 'allow-scripts allow-pointer-lock');
|
|
99
|
-
|
|
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'");
|
|
100
106
|
frame.referrerPolicy = 'no-referrer';
|
|
101
107
|
frame.srcdoc = source;
|
|
102
108
|
return frame;
|
|
@@ -104,7 +110,9 @@ function createFrame(document, source) {
|
|
|
104
110
|
|
|
105
111
|
async function buildIsolatedHtml(project, options) {
|
|
106
112
|
const document = await runtimeDocument(project, options);
|
|
107
|
-
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,
|
|
108
116
|
saves:{packageRevision:project.revision,installationId:options.installationId,gameId:project.manifest.gameId,versionId:'save-schema:'+project.manifest.saveSchemaVersion,compatibility:project.metadata.saveCompatibility || []} });
|
|
109
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>
|
|
110
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>
|
|
@@ -113,12 +121,14 @@ async function buildIsolatedHtml(project, options) {
|
|
|
113
121
|
<script>const payload=JSON.parse(document.getElementById('homegames-project').textContent);
|
|
114
122
|
const originalHtml='<!doctype html>\\n'+document.documentElement.outerHTML;
|
|
115
123
|
let frame,saveHost,clientHost;
|
|
116
|
-
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);
|
|
117
125
|
const showSaveStatus=message=>toolbar.setStatus(message);
|
|
118
|
-
|
|
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);}
|
|
119
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);};
|
|
120
129
|
toolbar.setAction('download-html',{label:'Download game HTML',id:'portable-html-copy',run:()=>fallback(originalHtml,'text/html',payload.name.replace(/\\.zip$/,'.html'))});
|
|
121
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)))});
|
|
122
132
|
toolbar.setAction('save-download',{label:'Download save',id:'portable-save-download',run:()=>saveHost.downloadSave(payload.name.replace(/\\.zip$/,'-save.json'))});
|
|
123
133
|
toolbar.setAction('save-import',{label:'Load save',id:'portable-save-import',run:()=>document.getElementById('portable-save-file').click()});
|
|
124
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
|
+
});
|