@homie-rocks/studio 0.51.7 → 0.51.8
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/CHANGELOG.md +22 -0
- package/bin/homie-studio.mjs +9 -4
- package/docs/toolkit-place-patterns.md +62 -0
- package/guides/app/SKILL.md +8 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-ready.mjs +31 -0
- package/lib/check.mjs +9 -6
- package/lib/connector-token.mjs +30 -0
- package/lib/template-history.json +42 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-shell.mjs +36 -15
- package/worker/build-id.mjs +1 -1
- package/worker/connector-token.mjs +11 -0
- package/worker/index.mjs +5 -2
- package/worker/join-area.mjs +35 -0
- package/worker/mcp.mjs +14 -6
- package/worker/pages.mjs +6 -3
- package/worker/records-page.mjs +3 -2
- package/worker/records-prefs.mjs +24 -0
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,28 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.51.8] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
**Plugin 0.51.8**
|
|
23
|
+
|
|
24
|
+
Run your studio across more screens with reliable shared records, clearer checks and a simpler owner connection.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Reliable screens and saved settings:** App screens share simultaneous record reads across tabs, queue bursts and back off after connection failures. Writes stay separate and refresh cached reads; reconnect notices use plain language. Records-only apps remember device preferences and saved references after reload while keeping their iframe sandbox.
|
|
29
|
+
- **Room for your wall display:** TV join cards reserve a measured safe area for your app, stay within a bounded size and wrap long addresses. Hiding the card returns that space to your content.
|
|
30
|
+
- **Checks that match what people can use:** Covered controls fail checks immediately with the blocking element identified, including overlays outside the app frame. You can also check text navigation with graphics disabled using `check --webgl-failure`; fallback notices stay in the page flow so controls remain reachable.
|
|
31
|
+
- **Clean toolkit type checks:** Collision, terrain and tile-coordinate code now passes strict `build --types` checks when imported into your app or game.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Connect your owner's AI with your existing login:** `connector` uses your Wrangler sign-in to save a private MCP configuration limited to the chosen tools, studio origin and expiry. You can revoke access; the server stores only token hashes and command output contains no bearer secret.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Safer bookings and usable text controls:** Authoring guides show server-owned capacity with atomic versioned writes, tested with six simultaneous requests and one successful reservation. They explain how to initialise text controls before graphics and keep the app usable when 3D is unavailable.
|
|
40
|
+
- **Clear deploy guidance:** Wrangler remains the path for uploading your site's static assets. The guide explains why Cloudflare's official MCP request interface cannot perform the required upload authorization.
|
|
41
|
+
|
|
20
42
|
## [0.51.7] - 2026-10-14
|
|
21
43
|
|
|
22
44
|
**Plugin 0.51.7**
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
* AI guides, chat review and game decisions think with Clef on this computer
|
|
38
38
|
* when Ollama has clef-flash (free; dev never downloads it), else scripted;
|
|
39
39
|
* --remote-ai: the real Workers AI, billed)
|
|
40
|
-
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
40
|
+
* homie-studio check <id> [--url <site>] [--shots <dir>] [--webgl-failure]
|
|
41
|
+
* homie-studio connector --tools <name,...> --out <private-config.json> [--days 30]
|
|
42
|
+
* homie-studio connector --revoke <id>
|
|
41
43
|
* homie-studio shoot <id> [--url <site> | --preview] [--frames 60] [--fps 30] [--device computer|phone]
|
|
42
44
|
* [--out <dir>] [--hold <key code>] [--no-smoke] [--timeout 180]
|
|
43
45
|
* pictures of the game on a clock this command steps (one frame = 1/fps s
|
|
@@ -380,14 +382,14 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
380
382
|
const argv = process.argv.slice(2);
|
|
381
383
|
const flags = new Map();
|
|
382
384
|
// build --quick keeps compilation and type checks, skips rules generated play/smoke checks.
|
|
383
|
-
const BOOL_FLAGS = ['quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
385
|
+
const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
384
386
|
const positional = [];
|
|
385
387
|
for (let i = 0; i < argv.length; i++) {
|
|
386
388
|
const a = argv[i];
|
|
387
389
|
if (a.startsWith('--')) {
|
|
388
390
|
const [k, v] = a.slice(2).split(/=(.*)/s, 2);
|
|
389
391
|
if (v !== undefined) flags.set(k, v);
|
|
390
|
-
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && (!BOOL_FLAGS.includes(k) || k === 'profile' && argv[0] === 'shop')) flags.set(k, argv[++i]);
|
|
392
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && (!BOOL_FLAGS.includes(k) || k === 'revoke' && argv[0] === 'connector' || k === 'profile' && argv[0] === 'shop')) flags.set(k, argv[++i]);
|
|
391
393
|
else flags.set(k, true);
|
|
392
394
|
} else positional.push(a);
|
|
393
395
|
}
|
|
@@ -407,6 +409,7 @@ function print(result) {
|
|
|
407
409
|
if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
|
|
408
410
|
if (/^collision /.test(String(result.command ?? '')) && result.lines) { process.stdout.write(`${result.lines.join('\n')}\n`); return; }
|
|
409
411
|
switch (result.command) {
|
|
412
|
+
case 'connector': lines.push(JSON.stringify(result,null,2)); break;
|
|
410
413
|
case 'new':
|
|
411
414
|
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
|
|
412
415
|
break;
|
|
@@ -831,6 +834,7 @@ const OUTWARD_FLAGS = {
|
|
|
831
834
|
'setup attach': ['homie', 'client'],
|
|
832
835
|
handoff: ['homie', 'client'],
|
|
833
836
|
'players owner': ['url', 'revoke'],
|
|
837
|
+
'connector': ['url','tools','out','days','revoke'],
|
|
834
838
|
'stats key': ['url', 'hours'], 'stats link': ['url'], 'stats revoke': ['url'], 'stats share': [],
|
|
835
839
|
'office link': ['url', 'to'], 'office key': ['url', 'hours'], 'office announce': ['url', 'game', 'room', 'seconds'],
|
|
836
840
|
'office invite': ['url', 'label', 'uses', 'count', 'days', 'server'], 'office launch': ['url', 'max'],
|
|
@@ -914,6 +918,7 @@ async function main() {
|
|
|
914
918
|
}
|
|
915
919
|
if (cmd === 'events') return (await import('../lib/events-cli.mjs')).eventsCommand(root, sub, positional, flags, { log });
|
|
916
920
|
if (cmd === 'function' || cmd === 'functions') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
|
|
921
|
+
if (cmd === 'connector') return (await import('../lib/connector-token.mjs')).connectorToken(root,{url:flags.get('url'),tools:typeof flags.get('tools')==='string'?flags.get('tools').split(','):[],out:flags.get('out'),days:flags.has('days')?Number(flags.get('days')):30,revoke:flags.get('revoke')});
|
|
917
922
|
if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
|
|
918
923
|
// A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
|
|
919
924
|
if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
|
|
@@ -995,7 +1000,7 @@ async function main() {
|
|
|
995
1000
|
const results = [];
|
|
996
1001
|
for (const game of games) {
|
|
997
1002
|
const shots = flags.get('shots') ? resolve(flags.get('shots'), ...(games.length > 1 ? [game] : [])) : null;
|
|
998
|
-
const r = await tracked(root, 'checks', (report) => check({ root, url: site.url, game, shots, log, report, isolate:!flags.has('no-isolate') }), 'check');
|
|
1003
|
+
const r = await tracked(root, 'checks', (report) => check({ webglFailure:flags.has('webgl-failure'), root, url: site.url, game, shots, log, report, isolate:!flags.has('no-isolate') }), 'check');
|
|
999
1004
|
results.push({ ...r, game, site: site.url, local: site.local });
|
|
1000
1005
|
const receiptDir = join(root, '.studio', 'checks');
|
|
1001
1006
|
mkdirSync(receiptDir, { recursive: true });
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Building places: layout, authority and recovery
|
|
2
|
+
|
|
3
|
+
## TV join safe area
|
|
4
|
+
|
|
5
|
+
On `/<id>/tv`, the shell sets `body.homie-join-visible` while the join card is present. It publishes CSS pixel values on the document root in both the shell and the sandboxed app: `--homie-join-x`, `--homie-join-y`, `--homie-join-width`, `--homie-join-height`, and `--homie-join-inset-top/right/bottom/left`. The insets include the card's distance from its configured screen edge. Inactive edges are zero; no card means all values are zero and the body class is absent. Measurements update on resize and iframe navigation. The card is at most 480 × 280 CSS pixels; its address wraps within that box.
|
|
6
|
+
|
|
7
|
+
Reserve an entire strip on either occupied axis, with a gap:
|
|
8
|
+
|
|
9
|
+
```css
|
|
10
|
+
.status { position: fixed; right: 20px;
|
|
11
|
+
bottom: calc(var(--homie-join-inset-bottom, 0px) + 20px); }
|
|
12
|
+
.tee-sheet { position: fixed; left: 20px; bottom: 20px;
|
|
13
|
+
right: calc(var(--homie-join-inset-right, 0px) + 20px); }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For top/left join corners, use the corresponding top/left insets. Never position critical controls underneath the card merely because it ignores pointer events. Regression coverage measures actual rectangles at 1920×1080, 1280×720 and 3840×2160.
|
|
17
|
+
|
|
18
|
+
## Authoritative capacity
|
|
19
|
+
|
|
20
|
+
Bookings and inventory belong to server-side `defineTool` mutations, with strict schemas (`additionalProperties: false`) and atomic versioned writes. A client-writable generic ledger is not an inventory authority: six browsers can all read one remaining place and overwrite one another. Clients may display availability and request a reservation; they cannot supply remaining capacity, identity or a new inventory total.
|
|
21
|
+
|
|
22
|
+
The executable [reservation example](../guides/app/examples/reserve.mjs) accepts only a slot ID. An owner-only action seeds `ctx.database.put('tee-0900', {remaining: 1, bookings: []}, {version: 0})`. `reserve` reads that row, derives the booking identity from `ctx.caller`, and writes with `{version: row.version}`. One SQLite statement checks the version and updates the entire row; a conflict is a failed reservation, never a success. Six simultaneous requests produce one winner and five conflicts. Do not expose this namespace through a generic client record mutation. Read availability through a separate read-only action. For retryable purchases add a server-owned idempotency key and settlement handling before charging.
|
|
23
|
+
|
|
24
|
+
## Text first, graphics optional
|
|
25
|
+
|
|
26
|
+
Bind navigation and text controls before creating a renderer. A failed WebGL context must not prevent those handlers from being installed:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
renderTextControls();
|
|
30
|
+
bindNavigation();
|
|
31
|
+
try {
|
|
32
|
+
const renderer = new THREE.WebGLRenderer({canvas});
|
|
33
|
+
startScene(renderer);
|
|
34
|
+
} catch {
|
|
35
|
+
canvas.hidden = true;
|
|
36
|
+
fallback.hidden = false; // a normal-flow status inside the scene section
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Keep the fallback inside the scene section, outside navigation; never use a fullscreen or fixed overlay. The shell's automatic fallback is a normal-flow status beside the canvas and does not intercept input. Author a Codex need that clicks navigation and asserts the resulting text; then run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl`. This disables WebGL context creation before scripts run in every app frame and executes the same UI needs. A passing rendering check alone does not establish usable text controls.
|
|
41
|
+
|
|
42
|
+
## Action diagnostics and types
|
|
43
|
+
|
|
44
|
+
`check` tests whether the action target is covered before dispatch. An obstruction reports the surface, target selector and covering element immediately; remove the overlay or repair its layout before retrying. Navigation/detachment handling does not replay an ambiguous dispatched action.
|
|
45
|
+
|
|
46
|
+
`build --types` checks imported toolkit sources too. Collision/terrain closure narrowing and tile-coordinate narrowing are fixed in the toolkit; fresh studios need no local suppressions or edits to `node_modules`.
|
|
47
|
+
|
|
48
|
+
## Connect another studio without another approval page
|
|
49
|
+
|
|
50
|
+
Use the new studio's directory and the owner's existing Wrangler login (the same Cloudflare account must have D1 access). One command writes a connector config, never its bearer to stdout:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
homie-studio connector --tools studio_stats --out /private/path/studio-connector.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Load that file directly into the AI client's connector configuration using its local configuration/import mechanism. Do not paste, print or include the file in chat or commit it. The file is created exclusively with mode 0600; existing files are never overwritten. Only the file path, token ID, scopes and expiry are reported. The config targets this studio's `/mcp/auth` endpoint. No browser approval is needed when Wrangler is already authenticated; this command does not deploy anything.
|
|
57
|
+
|
|
58
|
+
Tokens are bound to this origin, limited to the exact named tools, expire after 30 days by default (`--days 1` through `--days 90`), and cannot read MCP resources or subscriptions. D1 stores only the token hash and its scope metadata. Revoke with `homie-studio connector --revoke <id>` from the same studio. Every dispatch rechecks expiry and revocation. A login to a different studio alone does not grant authority over this database.
|
|
59
|
+
|
|
60
|
+
## Deployment
|
|
61
|
+
|
|
62
|
+
Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
|
package/guides/app/SKILL.md
CHANGED
|
@@ -354,3 +354,11 @@ and pass a role when deliberately testing the customer experience.
|
|
|
354
354
|
`check --shots` may capture outside the studio, but persistent needs evidence copies
|
|
355
355
|
those pictures into `.studio/checks` and stores studio-relative paths. Keep that
|
|
356
356
|
folder with the Codex evidence. Deploy can render it without outside-path warnings.
|
|
357
|
+
|
|
358
|
+
### Places: capacity, TV space and failed graphics
|
|
359
|
+
|
|
360
|
+
Authoritative bookings/inventory are server-side `defineTool` actions with strict schemas and atomic versioned writes, never a client-writable generic ledger. Use [the capacity example](examples/reserve.mjs): seed one place through an owner action; six simultaneous reservation requests must yield exactly one winner. Derive identity from the caller; expose neither inventory totals nor the authoritative namespace as client mutations.
|
|
361
|
+
|
|
362
|
+
Initialise text controls and navigation before 3D rendering. Catch renderer creation failures inside the scene section; keep fallback text in normal flow, never over navigation. Add a navigation need and run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl` to prove the controls work without WebGL.
|
|
363
|
+
|
|
364
|
+
On TV, reserve `--homie-join-inset-bottom/right` (or the configured corner's top/left) plus a gap; `body.homie-join-visible` and `--homie-join-x/y/width/height` describe the shell card in the app frame too. See `node_modules/@homie-rocks/studio/docs/toolkit-place-patterns.md` for layout and text-first examples, connector setup and diagnostics.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {defineTool} from '@homie-rocks/studio/tools';
|
|
2
|
+
export default defineTool({
|
|
3
|
+
name:'reserve',description:'Reserve one place in a server-owned slot',audience:'signed-in',namespace:'capacity',
|
|
4
|
+
inputSchema:{type:'object',properties:{slot:{type:'string',pattern:'^tee-[0-9]{4}$'}},required:['slot'],additionalProperties:false},
|
|
5
|
+
async handler({slot},ctx){
|
|
6
|
+
const row=await ctx.database.getVersioned(slot);
|
|
7
|
+
if(!row)return {ok:false,reason:'Unknown slot'};
|
|
8
|
+
const value=row.value;
|
|
9
|
+
if(value.remaining<1)return {ok:false,reason:'Full'};
|
|
10
|
+
const result=await ctx.database.put(slot,{remaining:value.remaining-1,bookings:[...value.bookings,ctx.caller.id]},{version:row.version});
|
|
11
|
+
return result.ok?{ok:true}:{ok:false,reason:'Taken; refresh availability'};
|
|
12
|
+
}
|
|
13
|
+
});
|
|
@@ -28,3 +28,5 @@ isolated records. Assert actual sound/haptic/notification triggers where request
|
|
|
28
28
|
Check waits through reload readiness and reports dialogs and named timeout conditions.
|
|
29
29
|
Do not weaken a need to obtain a green result; inspect captures and complete the
|
|
30
30
|
owner's workflow. Do not deploy merely to prove a local handler.
|
|
31
|
+
|
|
32
|
+
For authoritative bookings/inventory use strict `defineTool` actions and `database.put(key, next, {version: row.version})`; a conflict is a failed reservation. Never make the inventory a client-writable generic ledger. The [capacity example](../app/examples/reserve.mjs) proves one winner from six simultaneous requests for one place; initialise capacity through an owner-only action.
|
|
@@ -409,3 +409,7 @@ and slug. It fills missing generated files and retries installation, preserving
|
|
|
409
409
|
already there. A different studio or unrelated nonempty folder is still refused.
|
|
410
410
|
New studios do not request a directory listing by default; set `homie.directory: true`
|
|
411
411
|
or a directory URL only when the person chooses listing.
|
|
412
|
+
|
|
413
|
+
Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
|
|
414
|
+
|
|
415
|
+
An owner's existing Wrangler login can connect another studio without a new browser approval: in that studio run `homie-studio connector --tools <exact-tool-names> --out <private-config.json>`, then load the file directly into the AI client's connector configuration. Never print the bearer or paste the file in chat. Revoke with `homie-studio connector --revoke <id>`. See the installed `docs/toolkit-place-patterns.md` for scope and expiry.
|
package/lib/app-ready.mjs
CHANGED
|
@@ -56,6 +56,28 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
|
|
|
56
56
|
for (;;) {
|
|
57
57
|
try {
|
|
58
58
|
await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
|
|
59
|
+
const handle=await surface.frame.$?.(selector);
|
|
60
|
+
if(handle)try {
|
|
61
|
+
await handle.scrollIntoView();
|
|
62
|
+
const point=await handle.clickablePoint();
|
|
63
|
+
const ancestors=[];
|
|
64
|
+
for(let frame=surface.frame;frame.parentFrame();frame=frame.parentFrame())ancestors.unshift(await frame.frameElement());
|
|
65
|
+
const describeHit=(el,p)=>{
|
|
66
|
+
const hit=document.elementFromPoint(p.x,p.y);
|
|
67
|
+
return hit&&!el.contains(hit)?hit.tagName.toLowerCase()+(hit.id?'#'+hit.id:'')+[...hit.classList].map(c=>'.'+c).join(''):null;
|
|
68
|
+
};
|
|
69
|
+
let local={...point};
|
|
70
|
+
try {
|
|
71
|
+
for(const ancestor of ancestors){
|
|
72
|
+
const covered=await ancestor.evaluate(describeHit,local);
|
|
73
|
+
if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
|
|
74
|
+
const offset=await ancestor.evaluate(el=>{const r=el.getBoundingClientRect();return {x:r.x+el.clientLeft,y:r.y+el.clientTop};});
|
|
75
|
+
local={x:local.x-offset.x,y:local.y-offset.y};
|
|
76
|
+
}
|
|
77
|
+
const covered=await handle.evaluate(describeHit,local);
|
|
78
|
+
if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
|
|
79
|
+
}finally{await Promise.all(ancestors.map(h=>h.dispose()));}
|
|
80
|
+
}finally{await handle.dispose();}
|
|
59
81
|
return await action(surface.frame);
|
|
60
82
|
} catch(error) {
|
|
61
83
|
if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
|
|
@@ -69,3 +91,12 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
|
|
|
69
91
|
}
|
|
70
92
|
}
|
|
71
93
|
}
|
|
94
|
+
|
|
95
|
+
/** Install before navigation: every app iframe observes a real context creation failure. */
|
|
96
|
+
export function forceWebglFailure() {
|
|
97
|
+
for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
|
|
98
|
+
if(!proto)continue;
|
|
99
|
+
const original=proto.getContext;
|
|
100
|
+
proto.getContext=function(kind,...args){return /^(webgl2?|experimental-webgl)$/.test(kind)?null:Reflect.apply(original,this,[kind,...args]);};
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/check.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {needsHeadless} from './needs-headless.mjs';
|
|
2
2
|
import {randomBytes} from 'node:crypto';
|
|
3
|
-
import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
|
+
import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
4
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
5
5
|
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
6
6
|
/**
|
|
@@ -132,7 +132,7 @@ export function judgeRounds(browsers, missed = new Map()) {
|
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export async function check({ isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
135
|
+
export async function check({ webglFailure = false, isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
136
136
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
137
137
|
const base = String(url).replace(/\/+$/, '');
|
|
138
138
|
const play = `${base}/${game}/play`;
|
|
@@ -152,14 +152,15 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
152
152
|
if(appMeta && !appMeta.check?.action) {
|
|
153
153
|
try {
|
|
154
154
|
const plan=readAppNeeds(root,game);
|
|
155
|
-
if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
156
|
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
157
|
}
|
|
158
158
|
const chrome = findChrome();
|
|
159
159
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
160
160
|
let puppeteer;
|
|
161
161
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
162
|
-
if (appMeta) return checkApp({ isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
162
|
+
if (appMeta) return checkApp({ webglFailure, isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
163
|
+
if(webglFailure)return {ok:false,command:'check',why:'--webgl-failure requires an app with UI checks'};
|
|
163
164
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
164
165
|
const profiles = [];
|
|
165
166
|
const browsers = [];
|
|
@@ -354,7 +355,7 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
/** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
|
|
357
|
-
export async function checkApp({ isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
358
|
+
export async function checkApp({ webglFailure = false, isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
358
359
|
let plan;
|
|
359
360
|
try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
360
361
|
const secret=localCredential(root,url);
|
|
@@ -364,6 +365,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
364
365
|
const proof = meta.check??{};
|
|
365
366
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
366
367
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
368
|
+
if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
|
|
367
369
|
const captureWarnings=[];
|
|
368
370
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
369
371
|
let browser;
|
|
@@ -382,6 +384,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
382
384
|
const page=await context.newPage();
|
|
383
385
|
answerDialogs(page,name,dialogs,report);
|
|
384
386
|
await page.evaluateOnNewDocument(observeEffects);
|
|
387
|
+
if(webglFailure)await page.evaluateOnNewDocument(forceWebglFailure);
|
|
385
388
|
const [width,height]=dimensions[name];
|
|
386
389
|
await page.setViewport({width,height,isMobile:name!=='wall',hasTouch:name!=='wall'});
|
|
387
390
|
const params=new URLSearchParams(proof.params??{});params.set('room',room);params.set('role',role);params.set('surface',name==='staff'?'tablet':name==='phone-two'?'phone':name);
|
|
@@ -423,7 +426,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
423
426
|
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
424
427
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
425
428
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
426
|
-
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
429
|
+
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
427
430
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
|
|
428
431
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
429
432
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {randomBytes,createHash} from 'node:crypto';
|
|
2
|
+
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join,resolve} from 'node:path';
|
|
5
|
+
import {runner,configNames} from './cloudflare.mjs';
|
|
6
|
+
import {siteUrl} from './studio.mjs';
|
|
7
|
+
/** D1 administration uses the owner's existing Wrangler login. No bearer enters stdout or argv. */
|
|
8
|
+
export async function connectorToken(root,{url,tools,out,revoke,days=30,execute}={}) {
|
|
9
|
+
const run=execute??(async file=>{const names=configNames(root);if(!names.d1)throw new Error('Studio database is not configured');const result=runner(root)(['d1','execute',names.d1,'--remote','--file',file]);if(result.code!==0)throw new Error('Connector token database operation failed; check the existing Wrangler login and studio database');});
|
|
10
|
+
const scratch=mkdtempSync(join(tmpdir(),'homie-connector-'));
|
|
11
|
+
const sql=async text=>{const file=join(scratch,'token.sql');writeFileSync(file,text,{mode:0o600});await run(file);};
|
|
12
|
+
const quote=value=>"'"+value.replaceAll("'","''")+"'";
|
|
13
|
+
try {
|
|
14
|
+
if(revoke){if(!/^[a-f0-9]{64}$/.test(revoke))throw new Error('Invalid connector id');await sql(`DELETE FROM mcp_kv WHERE key='connector:${revoke}';`);return {ok:true,command:'connector',revoked:revoke};}
|
|
15
|
+
if(!Array.isArray(tools)||!tools.length||tools.length>100||tools.some(t=>! /^[a-zA-Z0-9_-]{1,128}$/.test(t)))throw new Error('Provide explicit tool names with --tools (no wildcard)');
|
|
16
|
+
if(!out)throw new Error('Provide --out for the private connector configuration');
|
|
17
|
+
if(!Number.isInteger(days)||days<1||days>90)throw new Error('Expiry must be 1–90 days');
|
|
18
|
+
const origin=new URL(url??siteUrl(root)).origin;if(!origin.startsWith('https://'))throw new Error('Connector requires HTTPS');
|
|
19
|
+
const token='homie_owner_'+randomBytes(32).toString('hex');const id=createHash('sha256').update(token).digest('hex');
|
|
20
|
+
const expires=Math.floor(Date.now()/1000)+days*86400;
|
|
21
|
+
const value=JSON.stringify({origin,tools:[...new Set(tools)],expires});
|
|
22
|
+
// Reserve the destination before minting; never overwrite a credential or follow a symlink.
|
|
23
|
+
const path=resolve(root,out);writeFileSync(path,'',{flag:'wx',mode:0o600});
|
|
24
|
+
try{
|
|
25
|
+
await sql(`INSERT INTO mcp_kv(key,value,expires) VALUES('connector:${id}',${quote(value)},${expires});`);
|
|
26
|
+
writeFileSync(path,JSON.stringify({mcpServers:{studio:{url:origin+'/mcp/auth',headers:{Authorization:'Bearer '+token}}}},null,2)+'\n',{mode:0o600});
|
|
27
|
+
}catch(error){rmSync(path,{force:true});throw error;}
|
|
28
|
+
return {ok:true,command:'connector',id,file:path,expires,tools:[...new Set(tools)]};
|
|
29
|
+
}finally{rmSync(scratch,{recursive:true,force:true});}
|
|
30
|
+
}
|
|
@@ -3837,6 +3837,48 @@
|
|
|
3837
3837
|
}
|
|
3838
3838
|
}
|
|
3839
3839
|
}
|
|
3840
|
+
},
|
|
3841
|
+
"0.51.8": {
|
|
3842
|
+
"files": {
|
|
3843
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3844
|
+
"README.md": "684b3cca8001673c",
|
|
3845
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3846
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3847
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3848
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3849
|
+
"posts/README.md": "37841aae476bd540",
|
|
3850
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3851
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3852
|
+
},
|
|
3853
|
+
"sections": {
|
|
3854
|
+
"AGENTS.md": {
|
|
3855
|
+
"(intro)": "394cbbe934e80722",
|
|
3856
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3857
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3858
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3859
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3860
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3861
|
+
"## Making games": "bb715272fd1a615b",
|
|
3862
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3863
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3864
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3865
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3866
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3867
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3868
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3869
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3870
|
+
"## Beta": "9d02638207a26e73"
|
|
3871
|
+
}
|
|
3872
|
+
},
|
|
3873
|
+
"named": {
|
|
3874
|
+
"Homie Arcade": {
|
|
3875
|
+
"sections": {
|
|
3876
|
+
"AGENTS.md": {
|
|
3877
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3840
3882
|
}
|
|
3841
3883
|
}
|
|
3842
3884
|
}
|
package/netplay/netplay.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type Movement = 'owner' | 'host';
|
|
|
116
116
|
|
|
117
117
|
/** What the shell injects as `window.HOMIE_NET` before the game boots. */
|
|
118
118
|
export interface NetConfig {
|
|
119
|
+
/** The served experience kind, independent of standalone packaging. */
|
|
120
|
+
kind?: 'app';
|
|
119
121
|
v: number;
|
|
120
122
|
/** The relay socket: dev `ws://127.0.0.1:<port>/__net?room=main`, site `wss://<host>/api/table/<CODE>/ws?as=net&s=<nonce>`. */
|
|
121
123
|
url: string;
|
|
@@ -1939,6 +1941,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1939
1941
|
}
|
|
1940
1942
|
/** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
|
|
1941
1943
|
function aloneText(): string {
|
|
1944
|
+
if (cfg?.kind === 'app') return 'Reconnecting. Your saved updates will appear shortly.';
|
|
1942
1945
|
if (!canOffline) return 'This game needs a connection.';
|
|
1943
1946
|
if (rulesGame && !rulesHost) return 'Loading offline play…';
|
|
1944
1947
|
if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.8",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/collision.ts
CHANGED
|
@@ -123,6 +123,7 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
123
123
|
charge(24);
|
|
124
124
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
125
125
|
let best: Hit3 | null = null;
|
|
126
|
+
const currentBest = (): Hit3 | null => best;
|
|
126
127
|
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
|
|
127
128
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
128
129
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
@@ -141,10 +142,11 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
141
142
|
charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
|
|
142
143
|
}
|
|
143
144
|
for (const tile of terrain) {
|
|
144
|
-
|
|
145
|
+
const nearest: Hit3 | null = currentBest();
|
|
146
|
+
if(exactTerrain && !hits && nearest?.t===0)break;
|
|
145
147
|
// Once a nearer surface is known, later solids only need to beat that time.
|
|
146
148
|
// This also bounds work on densely tessellated terrain behind the first hit.
|
|
147
|
-
const limit = exactTerrain && !hits &&
|
|
149
|
+
const limit = exactTerrain && !hits && nearest ? nearest.t : 1;
|
|
148
150
|
const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
|
|
149
151
|
const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
|
|
150
152
|
if (hit) take({...hit, t: hit.t * limit});
|
package/rules/terrain.ts
CHANGED
|
@@ -136,12 +136,13 @@ function roundedCast(source:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
|
136
136
|
|
|
137
137
|
const speed2=dot(d,d);if(speed2<1e-20)return null;
|
|
138
138
|
let best:Hit3|null=null;
|
|
139
|
+
const currentBest=():Hit3|null=>best;
|
|
139
140
|
const take=(t:number,normal:Vec3)=>{if(t< -1e-9||t>1||best&&t>=best.t)return;const len=Math.sqrt(dot(normal,normal));if(len<1e-12||dot(normal,d)>=-1e-12)return;best={t:Math.max(0,t),nx:normal.x/len,ny:normal.y/len,nz:normal.z/len};};
|
|
140
141
|
const sphere=(v:Vec3)=>{const o=sub(p,v),b=dot(o,d),c=dot(o,o)-r*r,h=b*b-speed2*c;if(h<0)return;const t=(-b-Math.sqrt(h))/speed2;take(t,sub(add(p,d,t),v));};
|
|
141
142
|
// Reuse the plane times from clipping. Parallel and receding faces cannot
|
|
142
143
|
// be entry contacts; do not project or retest their already-known times.
|
|
143
144
|
for(const {plane,t} of approaching){
|
|
144
|
-
charge(8);if(
|
|
145
|
+
charge(8);const nearest=currentBest();if(nearest&&t>=nearest.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
|
|
145
146
|
for(const q of prism.planes){charge(4);if(dot(q.n,hit)>q.c+1e-10){inside=false;break;}}
|
|
146
147
|
if(inside)take(t,plane.n);
|
|
147
148
|
}
|
package/rules/tiles.ts
CHANGED
|
@@ -45,10 +45,14 @@ export function compileTiles(raw: any, wrap?: MapWrap): {
|
|
|
45
45
|
return { tiles, boxes };
|
|
46
46
|
}
|
|
47
47
|
export function tileApi(get: () => GameMap) {
|
|
48
|
-
const cell = (x: unknown, y: unknown) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const cell = (x: unknown, y: unknown) => {
|
|
49
|
+
const t = get().tiles;
|
|
50
|
+
if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y)) return -1;
|
|
51
|
+
const w = get().wrap;
|
|
52
|
+
const cx = w?.x ? ((x % t.width) + t.width) % t.width : x;
|
|
53
|
+
const cy = w?.y ? ((y % t.height) + t.height) % t.height : y;
|
|
54
|
+
return cx < 0 || cy < 0 || cx >= t.width || cy >= t.height ? -1 : cy * t.width + cx;
|
|
55
|
+
};
|
|
52
56
|
return {
|
|
53
57
|
tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
|
|
54
58
|
distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
|
|
2
|
+
if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
|
|
3
|
+
try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
|
|
4
|
+
const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
|
|
5
|
+
p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
|
|
6
|
+
const canvas=document.querySelector('canvas');
|
|
7
|
+
canvas.insertAdjacentElement('afterend',p);
|
|
8
|
+
}
|
package/worker/app-shell.mjs
CHANGED
|
@@ -4,26 +4,47 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
4
4
|
if (!boot || boot.kind !== 'app') return;
|
|
5
5
|
var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
6
|
var params = new URLSearchParams(location.search);
|
|
7
|
-
|
|
7
|
+
// Share short-lived reads across same-origin screens with the exact same role/access.
|
|
8
|
+
// No records are persisted. Writes always reach the Worker and invalidate all views.
|
|
9
|
+
var recent = new Map(), failures = new Map(), pending = new Map();
|
|
10
|
+
var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
|
|
11
|
+
function accept(m) { if (m?.invalidate) { recent.clear(); failures.clear(); } else if (m?.path && m?.entry) recent.set(m.path, m.entry); }
|
|
12
|
+
if (channel) channel.onmessage = function(e) { accept(e.data); };
|
|
13
|
+
function share(m) { accept(m); if (channel) channel.postMessage(m); }
|
|
14
|
+
function locked(run) { return typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('homie-app-records-v1', run) : run(); }
|
|
15
|
+
async function request(path, m) {
|
|
16
|
+
var read = m.method === 'GET';
|
|
17
|
+
if (read && pending.has(path)) return pending.get(path);
|
|
18
|
+
var job = locked(async function() {
|
|
19
|
+
var cached = recent.get(path);
|
|
20
|
+
if (read && cached && cached.until > Date.now()) return cached.result;
|
|
21
|
+
var result;
|
|
22
|
+
try {
|
|
23
|
+
var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: read ? undefined : JSON.stringify({ data: m.data, version: m.version }), signal: AbortSignal.timeout(8000) });
|
|
24
|
+
result = await res.json();
|
|
25
|
+
if (res.status === 429 || res.status >= 500) throw new Error('reconnect');
|
|
26
|
+
failures.delete(path);
|
|
27
|
+
if (read && result.ok) share({path: path, entry: {result: result, until: Date.now() + 1500}});
|
|
28
|
+
if (!read && result.ok) share({invalidate: true});
|
|
29
|
+
} catch (_) {
|
|
30
|
+
result = {ok: false, error: 'Reconnecting. Your saved updates will appear shortly.'};
|
|
31
|
+
if (read) { var count = (failures.get(path) || 0) + 1; failures.set(path, count); share({path: path, entry: {result: result, until: Date.now() + Math.min(30000, 1000 * Math.pow(2, count - 1))}}); }
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
});
|
|
35
|
+
if (read) { pending.set(path, job); job.finally(function() { pending.delete(path); }); }
|
|
36
|
+
return job;
|
|
37
|
+
}
|
|
8
38
|
addEventListener('message', async function (e) {
|
|
9
39
|
var m = e.data;
|
|
10
40
|
if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
|
|
11
41
|
if (!frame || e.source !== frame.contentWindow || !m || m.type !== 'homie-app' || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
12
42
|
if (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record))) return;
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
var q = new URLSearchParams();
|
|
19
|
-
['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
20
|
-
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
21
|
-
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
22
|
-
var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: m.method === 'GET' ? undefined : JSON.stringify({ data: m.data, version: m.version }) });
|
|
23
|
-
result = await res.json();
|
|
24
|
-
} catch (err) { result = { ok: false, error: 'records unavailable; reconnect and retry' }; }
|
|
25
|
-
finally { inflight--; }
|
|
26
|
-
}
|
|
43
|
+
var q = new URLSearchParams();
|
|
44
|
+
['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
45
|
+
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
46
|
+
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
47
|
+
var result = await request(path, m);
|
|
27
48
|
e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
|
|
28
49
|
});
|
|
29
50
|
})();`;
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.8","sha256":"ac2d23a60cc25247d09af506854dec3345b9219d57dbde5f3b1abe5640abde84"});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {oauthStorage} from './mcp-store.mjs';
|
|
2
|
+
/** A hashed bearer, bound to this origin and exact tool names; re-read on every dispatch. */
|
|
3
|
+
export async function tokenCaller(request,env) {
|
|
4
|
+
const token=/^Bearer (homie_owner_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
|
|
5
|
+
const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));
|
|
6
|
+
const id=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('');
|
|
7
|
+
const row=await oauthStorage(env.DB).get('connector:'+id,'json');
|
|
8
|
+
if(!row||row.origin!==new URL(request.url).origin||row.expires<=Date.now()/1000)return null;
|
|
9
|
+
return {id:'connector:'+id,owner:true,client:'owner-connector',connection:'connector:'+id,tools:row.tools};
|
|
10
|
+
}
|
|
11
|
+
export function toolInScope(caller,name){return !caller.tools||caller.tools.includes(name);}
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {appGraphicsFallback} from './app-graphics.mjs';
|
|
2
|
+
import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
|
|
1
3
|
import { directoryUrl } from './directory-choice.mjs';
|
|
2
4
|
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
3
5
|
import {recordsPage} from './records-page.mjs';
|
|
@@ -506,6 +508,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
506
508
|
const params = paramsFrom(url.searchParams, meta);
|
|
507
509
|
if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
|
|
508
510
|
const cfg = {
|
|
511
|
+
...(meta.kind === 'app' ? {kind: 'app'} : {}),
|
|
509
512
|
v: 1,
|
|
510
513
|
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
|
|
511
514
|
room,
|
|
@@ -534,8 +537,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
534
537
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
535
538
|
};
|
|
536
539
|
let html = await res.text();
|
|
537
|
-
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',
|
|
538
|
-
const head = (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
540
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
|
|
541
|
+
const head = `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
539
542
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
540
543
|
return new Response(html, {
|
|
541
544
|
headers: {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The shell owns these measurements; sandboxed views receive only geometry. */
|
|
2
|
+
export function joinAreaShell() {
|
|
3
|
+
const frame=document.querySelector('iframe.game');
|
|
4
|
+
const update=()=>{
|
|
5
|
+
const card=document.querySelector('[data-join]');
|
|
6
|
+
const r=card?.getBoundingClientRect();
|
|
7
|
+
const visible=!!(r?.width&&r.height&&getComputedStyle(card).visibility!=='hidden'&&getComputedStyle(card).display!=='none');
|
|
8
|
+
const corner=card?.className??'';
|
|
9
|
+
const box=visible?{x:r.x,y:r.y,width:r.width,height:r.height,top:corner.includes('top')?r.bottom:0,bottom:corner.includes('top')?0:innerHeight-r.top,left:corner.includes('left')?r.right:0,right:corner.includes('left')?0:innerWidth-r.left}:{};
|
|
10
|
+
document.body.classList.toggle('homie-join-visible',visible);
|
|
11
|
+
for(const k of ['top','bottom','left','right'])document.documentElement.style.setProperty('--homie-join-inset-'+k,(box[k]??0)+'px');
|
|
12
|
+
for(const k of ['x','y','width','height'])document.documentElement.style.setProperty('--homie-join-'+k,(box[k]??0)+'px');
|
|
13
|
+
frame?.contentWindow?.postMessage({type:'homie-join-area',visible,box},'*');
|
|
14
|
+
};
|
|
15
|
+
new ResizeObserver(update).observe(document.body);
|
|
16
|
+
const card=document.querySelector('[data-join]');if(card)new ResizeObserver(update).observe(card);
|
|
17
|
+
new MutationObserver(update).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','class']});
|
|
18
|
+
frame?.addEventListener('load',update);addEventListener('resize',update);
|
|
19
|
+
addEventListener('message',e=>{if(e.source===frame?.contentWindow&&e.data?.type==='homie-join-ready')update();});
|
|
20
|
+
update();
|
|
21
|
+
}
|
|
22
|
+
export function joinAreaFrame() {
|
|
23
|
+
addEventListener('message',e=>{
|
|
24
|
+
if(e.source!==parent||e.data?.type!=='homie-join-area')return;
|
|
25
|
+
const {box,visible}=e.data;
|
|
26
|
+
document.body?.classList.toggle('homie-join-visible',!!visible);
|
|
27
|
+
for(const k of ['top','bottom','left','right','x','y','width','height']) {
|
|
28
|
+
const v=box?.[k];if(v!==undefined&&(!Number.isFinite(v)||v<0))return;
|
|
29
|
+
document.documentElement.style.setProperty('--homie-join-'+(['top','bottom','left','right'].includes(k)?'inset-':'')+k,(v??0)+'px');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
addEventListener('DOMContentLoaded',()=>parent.postMessage({type:'homie-join-ready'},'*'));
|
|
33
|
+
}
|
|
34
|
+
export const JOIN_AREA_SHELL_JS=`(${joinAreaShell.toString()})();`;
|
|
35
|
+
export const JOIN_AREA_FRAME_JS=`(${joinAreaFrame.toString()})();`;
|
package/worker/mcp.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {tokenCaller,toolInScope} from './connector-token.mjs';
|
|
1
2
|
import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
|
|
2
3
|
import {eventsRequest} from './mcp-events.mjs';
|
|
3
4
|
function freshErrorMessage(error,env,caller) {
|
|
@@ -38,10 +39,17 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
38
39
|
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
39
40
|
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
40
41
|
const localIdentity=await localCaller(request,env);
|
|
42
|
+
const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
|
|
43
|
+
const bearerIdentity=bearer?await tokenCaller(request,env):null;
|
|
44
|
+
if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
|
|
41
45
|
const api=async(request,env,ctx)=>{
|
|
42
|
-
const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
|
|
46
|
+
const identity=async()=>{if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
|
|
43
47
|
let caller;
|
|
44
48
|
try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
49
|
+
if(caller.tools){
|
|
50
|
+
const body=request.method==='POST'?await request.clone().json().catch(()=>null):null;
|
|
51
|
+
if(request.method!=='POST'||!['initialize','notifications/initialized','ping','tools/list','tools/call'].includes(body?.method))return json({error:'connector is scoped to tools'},403);
|
|
52
|
+
}
|
|
45
53
|
const cat=await catalogueOf();
|
|
46
54
|
const tools=[...builtins(env,cat,origin),...definitions];
|
|
47
55
|
await pushToolList(env,cat,tools,origin);
|
|
@@ -110,11 +118,11 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
120
|
for(const tool of tools){
|
|
113
|
-
if (!await allowed(tool.audience,caller,env)) continue;
|
|
121
|
+
if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
|
|
114
122
|
if (tool.kind==='prompt') {
|
|
115
|
-
if (!await allowed(tool.audience,caller,env)) continue;
|
|
123
|
+
if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
|
|
116
124
|
s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
|
|
117
|
-
if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
125
|
+
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
118
126
|
const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
119
127
|
return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
|
|
120
128
|
}));continue;
|
|
@@ -125,7 +133,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
125
133
|
try{
|
|
126
134
|
// Recheck inside dispatch, even when a connection listed tools earlier.
|
|
127
135
|
const fresh=await identity();
|
|
128
|
-
if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
136
|
+
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
129
137
|
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
130
138
|
const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
|
|
131
139
|
if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
|
|
@@ -163,7 +171,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
163
171
|
};
|
|
164
172
|
return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
|
|
165
173
|
};
|
|
166
|
-
if(localIdentity)return api(request,scoped,ctx);
|
|
174
|
+
if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
|
|
167
175
|
const provider=new OAuthProvider({
|
|
168
176
|
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
169
177
|
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
package/worker/pages.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {JOIN_AREA_SHELL_JS} from './join-area.mjs';
|
|
1
2
|
import { appWords, openPath } from './app-format.mjs';
|
|
2
3
|
import { APP_SHELL_JS } from './app-shell.mjs';
|
|
3
4
|
/**
|
|
@@ -142,11 +143,13 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
142
143
|
.card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
|
|
143
144
|
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
|
|
144
145
|
.card ol { margin: 0; padding-left: 18px; }
|
|
145
|
-
|
|
146
|
+
:root{--homie-join-inset-top:0px;--homie-join-inset-right:0px;--homie-join-inset-bottom:0px;--homie-join-inset-left:0px;--homie-join-x:0px;--homie-join-y:0px;--homie-join-width:0px;--homie-join-height:0px}
|
|
147
|
+
.join { box-sizing:border-box;width:480px;max-width:calc(100vw - 32px);max-height:280px;overflow:hidden; position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
|
|
148
|
+
.join[hidden]{display:none}
|
|
146
149
|
.join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
|
|
147
150
|
.join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
|
|
148
151
|
.join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
|
|
149
|
-
.join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
152
|
+
.join > div:last-child{min-width:0;overflow-wrap:anywhere}.join .qr { flex-shrink:0; width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
150
153
|
.join .qr svg { width: 100%; height: 100%; display: block; }
|
|
151
154
|
.join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
|
|
152
155
|
.join span { opacity: .85; word-break: break-all; }
|
|
@@ -248,7 +251,7 @@ ${arrive.html}
|
|
|
248
251
|
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
249
252
|
<div class="card" data-results hidden></div>
|
|
250
253
|
<div class="card" data-screen hidden></div>
|
|
251
|
-
${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
254
|
+
${joinCard}${joinCard ? `<script>${JOIN_AREA_SHELL_JS}</script>` : ''}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
252
255
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
253
256
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
254
257
|
<script>${CHAT_JS}</script>
|
package/worker/records-page.mjs
CHANGED
|
@@ -2,12 +2,13 @@ import {playerProperties} from './embed.mjs';
|
|
|
2
2
|
import {appRole} from './app-format.mjs';
|
|
3
3
|
import {esc} from './site.mjs';
|
|
4
4
|
import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
5
|
+
import {RECORDS_PREFS_JS} from './records-prefs.mjs';
|
|
5
6
|
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
6
7
|
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
7
8
|
export function recordsPage(meta,url,cat={},shop=null) {
|
|
8
|
-
const params=new URLSearchParams(url.search);params.delete('access');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
|
|
9
|
+
const params=new URLSearchParams(url.search);params.delete('access');params.set('pf','1');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
|
|
9
10
|
const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
|
|
10
11
|
const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
11
12
|
const source=`/${meta.id}/__game/?${params}`;
|
|
12
|
-
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
13
|
+
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${RECORDS_PREFS_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
13
14
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The records-only shell keeps net.prefs outside its opaque app frame. */
|
|
2
|
+
export const RECORDS_PREFS_JS=String.raw`(()=>{
|
|
3
|
+
const boot=window.__HOMIE_APP,frame=document.querySelector('iframe.game');
|
|
4
|
+
const storageKey='homie-prefs.'+boot.game;let memory=null;
|
|
5
|
+
function read(){if(memory)return memory;try{const v=JSON.parse(localStorage.getItem(storageKey)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{};}catch{return memory={};}}
|
|
6
|
+
addEventListener('message',e=>{
|
|
7
|
+
const m=e.data;if(e.source!==frame.contentWindow||m?.t!=='homie-net'||m.what!=='prefs')return;
|
|
8
|
+
const result={t:'homie-prefs',n:m.n,ok:false};
|
|
9
|
+
try{
|
|
10
|
+
const all=read(),key=m.k;
|
|
11
|
+
if(m.op==='all'){result.ok=true;result.all=all;}
|
|
12
|
+
else if(typeof key!=='string'||!key.length||key.length>64||key==='__proto__')result.why='key';
|
|
13
|
+
else if(m.op==='set'||m.op==='del'){
|
|
14
|
+
const next=Object.fromEntries(Object.entries(all).filter(([k])=>k!==key));
|
|
15
|
+
if(m.op==='set'&&m.v!=null)next[key]=m.v;
|
|
16
|
+
const text=JSON.stringify(next);
|
|
17
|
+
if(text.length>16384||Object.keys(next).length>32)result.why='too-large';
|
|
18
|
+
else if(typeof m.v==='number'&&!Number.isFinite(m.v))result.why='value';
|
|
19
|
+
else{result.ok=true;result.kept='kept';try{if(memory)throw Error('memory');localStorage.setItem(storageKey,text);}catch{memory=next;result.kept='memory';}}
|
|
20
|
+
}else result.why='op';
|
|
21
|
+
}catch{result.why='error';}
|
|
22
|
+
e.source.postMessage(result,'*');
|
|
23
|
+
});
|
|
24
|
+
})();`;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.51.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.8';
|