@homie-rocks/studio 0.51.1 → 0.51.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/worker/pages.mjs CHANGED
@@ -200,6 +200,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
200
200
  .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
201
201
  [hidden] { display: none !important; }
202
202
  ${arrive.css}
203
+ ${g.kind === 'app' ? 'html,body,iframe.game{touch-action:auto;user-select:auto;-webkit-user-select:auto;-webkit-touch-callout:default}' : ''}
203
204
  ${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
204
205
  ${shopControl ? `.shop-control{color:#eef1f8;font:600 13px/1.2 system-ui;position:fixed;top:env(safe-area-inset-top);left:max(8px,env(safe-area-inset-left));height:52px;display:flex;align-items:center;z-index:6}.shop-control button{min-height:44px;padding:0 18px}html body .room.room{flex-direction:column;align-items:flex-end;top:max(4px,env(safe-area-inset-top));right:max(8px,env(safe-area-inset-right));left:auto;bottom:auto;transform:none}html body iframe.game.game{top:calc(env(safe-area-inset-top) + 52px);height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 52px)}` : ''}
205
206
  ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
@@ -241,7 +242,7 @@ ${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock
241
242
  </div>
242
243
  <div class="toast" data-toast role="status" hidden></div>`;
243
244
  return layoutless(`${g.name} · ${appWords(g).open}`, `
244
- <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
245
+ <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe>
245
246
  ${arrive.html}
246
247
  <div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
247
248
  <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
@@ -1178,7 +1179,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
1178
1179
  <span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
1179
1180
  <a class="playb" data-playb href="${esc(openPath(g))}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>${esc(appWords(g).open)}</span></a>
1180
1181
  </header>
1181
- <main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
1182
+ <main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe></main>
1182
1183
  <footer class="dock">
1183
1184
  <div class="caption" data-caption role="status" aria-live="polite"><b data-cap-head>Joining the room…</b><span data-cap-sub></span></div>
1184
1185
  <nav class="strip" data-strip role="toolbar" aria-label="Whose view to watch"></nav>
@@ -9,5 +9,5 @@ export function recordsPage(meta,url,cat={},shop=null) {
9
9
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
10
10
  const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
11
  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="autoplay *; fullscreen *"></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'}});
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
13
  }
@@ -0,0 +1,8 @@
1
+ /** Same-site destinations, including app routes and query strings. */
2
+ export function signinTarget(value, fallback = '/_studio/stats') {
3
+ if (typeof value !== 'string' || !value.startsWith('/') || /^\/\//.test(value) || /[\\\u0000-\u0020]/.test(value)) return fallback;
4
+ try {
5
+ const target = new URL(value, 'https://studio.invalid');
6
+ return target.origin === 'https://studio.invalid' ? target.pathname + target.search + target.hash : fallback;
7
+ } catch { return fallback; }
8
+ }
package/worker/site.mjs CHANGED
@@ -107,6 +107,8 @@ img,video{display:block;max-width:100%}
107
107
  .hero-media{position:absolute;inset:0;z-index:-2;overflow:hidden;background:
108
108
  radial-gradient(60% 50% at 70% 30%,color-mix(in srgb,var(--glow) 26%,transparent),transparent 70%),
109
109
  radial-gradient(55% 45% at 25% 70%,color-mix(in srgb,var(--hot) 24%,transparent),transparent 70%),var(--bg)}
110
+ .hero-media iframe{pointer-events:none}
111
+ .hero-media iframe[data-interactive]{pointer-events:auto}
110
112
  .hero-media img,.hero-media video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:var(--focus,50% 50%)}
111
113
  /* The studio's own colour over the picture, so a bright cover still carries the words (game.json landing.hero.tint, 0 to 80). */
112
114
  .hero-media::before{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;background:color-mix(in srgb,var(--bg) var(--tint,16%),transparent)}
@@ -1,3 +1,4 @@
1
+ import { signinTarget } from './signin-target.mjs';
1
2
  /**
2
3
  * The studio owner's private stats page (/_studio/stats) and its one-time sign-in (/_studio/signin).
3
4
  *
@@ -14,8 +15,8 @@ import { CODEX_SCRIPT } from './codex-script.mjs';
14
15
  * Where a sign-in may send the owner next: the stats, one game's codex, the back office, an ask waiting for the
15
16
  * owner's tap, or one of the studio's games (a private game's own secret road for the owner's phone).
16
17
  */
17
- const NEXT = /^\/(?:_studio\/(?:stats|office|codex\/[a-z0-9][a-z0-9-]{0,39}\/|confirm\/ask_[a-f0-9]{16})|[a-z0-9][a-z0-9-]{0,39}\/(?:play)?)$/;
18
- const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
18
+ const nextOf = (url) => signinTarget(url.searchParams.get('to'));
19
+ const SIGNIN_SCRIPT = "document.querySelector('form').requestSubmit();";
19
20
  const signinWord = (to) => (to.startsWith('/_studio/codex/') ? 'Open the codex' : to === '/_studio/office' ? 'Open the office' : to.startsWith('/_studio/confirm/') ? 'Sign in and see what your AI asks' : to.startsWith('/_studio/stats') ? 'Open the stats' : 'Sign in and play');
20
21
  let scriptHash = null;
21
22
  async function codexScriptHash() {
@@ -135,9 +136,11 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
135
136
  const key = url.searchParams.get('k') ?? '';
136
137
  if (request.method === 'GET') {
137
138
  // A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
139
+ const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(SIGNIN_SCRIPT)));
140
+ const hash = btoa(String.fromCharCode(...bytes));
138
141
  return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
139
142
  <p class="dim">Sign this browser in as the studio's owner: the private stats and office, and the owner's tools in your own games. The link works once.</p>
140
- <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${signinWord(nextOf(url))}</button></form>`);
143
+ <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${signinWord(nextOf(url))}</button></form><script>${SIGNIN_SCRIPT}</script>`, 200, { 'content-security-policy': PRIVATE_HEADERS['content-security-policy'].replace("default-src 'none';", `default-src 'none'; script-src 'sha256-${hash}';`) });
141
144
  }
142
145
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
143
146
  const session = await spendSignin(env, key);
@@ -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.1';
2
+ export const STUDIO_VERSION_TAG = '0.51.2';