diff --git a/docs/architecture.md b/docs/architecture.md index 8d84e7b..a499f0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,6 +15,8 @@ integrity and publisher identity; it does not grant native authority. The reviewed Mapplets release `3a5eeb98b3251a48a3ff1e32cb92ce76b7488b721dd492ae1659e90eeffe76f5` accepts a single finger drag when its rectangle tool is armed. The host translates this gesture into the guest’s existing local rectangle events under the trusted input gate. A second finger, cancellation, or loss of input ends the unfinished drag. Ordinary map gestures still use MapLibre’s handlers. Opening the editor requires a separate activation of **Edit** and the usual Intent review. +The reviewed Emoji Browser release `24b16ab3eb550d50eff01a3c42c6e56c3a81da4cf6914b11dc766d8575767381` refreshes its image cache during retry, revoking Blob URLs while other images still use them. Its guest-only compatibility layer defers those revocations until the last image reference is removed or replaced. Unused URLs revoke immediately; unloading the document releases remaining URLs. The original artifact hashes and CSP remain unchanged, and other apps or versions receive no override. + Android WebView and iOS WKWebView host the packaged runtime. Native code stages verified artifact bytes through a one-use registry. The runtime mediates guest messages through the selected capability profile. There is no general-purpose diff --git a/runtime/src/main.ts b/runtime/src/main.ts index 928989f..2263fd0 100644 --- a/runtime/src/main.ts +++ b/runtime/src/main.ts @@ -2,6 +2,7 @@ import {createLegacyImageMount} from './legacy-image-mount'; import {injectLegacyImages} from './legacy-image-guest'; import {injectReviewedPresentation} from './reviewed-presentation'; import {injectCreatorUpload} from './reviewed-creator-upload'; +import {injectEmojiImages} from './reviewed-emoji-images'; import {supportsMappletsTouchSelection} from './reviewed-mapplets-touch'; import {validEnvelope, rejectEnvelope} from './guest-envelope'; import {commonFailure} from '../../src/common/common-contract'; @@ -349,7 +350,7 @@ function renderGuest(artifact: RuntimeMetadata, rawHtml: string): void { frame.srcdoc = prefix + injectLegacyReadOnly(injectLegacyImages(injectAndroidGuestHelpers(injectResourceMediaGate(layout)), legacyNonce), legacyNonce); return; } - const presentation = injectReviewedPresentation(withCsp, artifact); + const presentation = injectReviewedPresentation(injectEmojiImages(withCsp, artifact), artifact); const html = discoveryToken ? injectDiscoveryOpen(presentation, discoveryToken, artifact.domains.includes('relay')) : presentation; const guest = injectAndroidGuestHelpers(injectResourceMediaGate(html, supportsMappletsTouchSelection(artifact))); const injected = injectNappletNamespacePrelude(injectCreatorUpload(injectSupportReport(guest), artifact), { diff --git a/runtime/src/reviewed-emoji-images.ts b/runtime/src/reviewed-emoji-images.ts new file mode 100644 index 0000000..8eb481b --- /dev/null +++ b/runtime/src/reviewed-emoji-images.ts @@ -0,0 +1,33 @@ +type EmojiMetadata = Readonly<{published: boolean; appId: string; aggregateHash: string}>; +const emojiVersion = '24b16ab3eb550d50eff01a3c42c6e56c3a81da4cf6914b11dc766d8575767381'; + +/** The pinned guest refreshes its cache before pending resolver images finish loading. */ +function installEmojiImages(): void { + const revoke = URL.revokeObjectURL.bind(URL); + const deferred = new Set(); + const referenced = () => new Set(Array.from(document.images, image => image.getAttribute('src'))); + const sweep = () => { + const sources = referenced(); + for (const url of deferred) { + if (sources.has(url)) continue; + deferred.delete(url); + revoke(url); + } + if (deferred.size === 0) observer.disconnect(); + }; + const observer = new MutationObserver(sweep); + URL.revokeObjectURL = url => { + if (!referenced().has(url)) {revoke(url);return;} + // Keep the URL through resolver-to-card handoff. Release after its last image + // is removed or changes source; document unload releases all remaining URLs. + deferred.add(url); + observer.observe(document, {subtree:true, childList:true, attributes:true, attributeFilter:['src']}); + }; +} + +export function injectEmojiImages(html: string, metadata: EmojiMetadata): string { + if (!metadata.published || metadata.appId !== 'emoji-browser' || metadata.aggregateHash !== emojiVersion) return html; + const csp = //; + if (!csp.test(html)) throw new Error('Emoji image compatibility requires host CSP'); + return html.replace(csp, value => `${value}`); +} diff --git a/runtime/tests/reviewed-emoji-images.spec.ts b/runtime/tests/reviewed-emoji-images.spec.ts new file mode 100644 index 0000000..cb0471c --- /dev/null +++ b/runtime/tests/reviewed-emoji-images.spec.ts @@ -0,0 +1,67 @@ +import {test, expect, type Page} from '@playwright/test'; +import {injectEmojiImages} from '../src/reviewed-emoji-images'; + +const pin = {published:true, appId:'emoji-browser', aggregateHash:'24b16ab3eb550d50eff01a3c42c6e56c3a81da4cf6914b11dc766d8575767381'}; +const document = ''; +const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; + +async function open(page: Page) { + await page.setContent(''); + await page.locator('iframe').evaluate((frame: HTMLIFrameElement, html) => {frame.srcdoc = html;}, injectEmojiImages(document, pin)); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('body[data-fixture-ready]')).toBeAttached(); + return frame; +} + +test('retry revocation keeps all pending images usable until their last DOM reference disappears', async ({page}) => { + const frame = await open(page); + const urls = await frame.locator('body').evaluate((body, png) => { + const bytes = Uint8Array.from(atob(png), c => c.charCodeAt(0)); + return Array.from({length:6}, () => { + const url = URL.createObjectURL(new Blob([bytes], {type:'image/png'})); + const img = document.createElement('img'); + img.src = url; + body.append(img); + // The publisher refreshes its cache while these images are still loading. + URL.revokeObjectURL(url); + return url; + }); + }, png); + await expect.poll(() => frame.locator('img').evaluateAll(images => images.filter(image => image.complete && image.naturalWidth > 0).length)).toBe(6); + // A resolved hidden image becomes a visible card in the same render turn. + await frame.locator('body').evaluate(body => { + for (const image of body.querySelectorAll('img')) image.replaceWith(image.cloneNode()); + }); + await expect.poll(() => frame.locator('img').evaluateAll(images => images.filter(image => image.complete && image.naturalWidth > 0).length)).toBe(6); + const readable = () => frame.locator('body').evaluate(async (_body, urls) => Promise.all(urls.map(url => fetch(url).then(response => response.ok, () => false))), urls); + expect(await readable()).toEqual(Array(6).fill(true)); + await frame.locator('body').evaluate(body => body.replaceChildren()); + await expect.poll(readable).toEqual(Array(6).fill(false)); +}); + +test('changing an image source releases its deferred URL and unused URLs revoke immediately', async ({page}) => { + const frame = await open(page); + const urls = await frame.locator('body').evaluate((body, png) => { + const bytes = Uint8Array.from(atob(png), c => c.charCodeAt(0)); + const old = URL.createObjectURL(new Blob([bytes], {type:'image/png'})); + const next = URL.createObjectURL(new Blob([bytes], {type:'image/png'})); + const unused = URL.createObjectURL(new Blob([bytes], {type:'image/png'})); + const img = document.createElement('img'); + img.src = old; body.append(img); + URL.revokeObjectURL(old); URL.revokeObjectURL(old); URL.revokeObjectURL(unused); + img.src = next; + return {old, next, unused}; + }, png); + await expect.poll(() => frame.locator('body').evaluate(async (_body, urls) => Promise.all([urls.old, urls.next, urls.unused].map(url => fetch(url).then(response => response.ok, () => false))), urls)).toEqual([false, true, false]); + await expect.poll(() => frame.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true); +}); + +test('only the exact published Emoji Browser receives image lifetime compatibility', () => { + for (const metadata of [{...pin, published:false}, {...pin, appId:'other'}, {...pin, aggregateHash:'f'.repeat(64)}]) { + expect(injectEmojiImages(document, metadata)).toBe(document); + } + expect(() => injectEmojiImages('', pin)).toThrow('host CSP'); + const injected = injectEmojiImages(document, pin); + expect(injected).toContain(document.match(/]+>/)![0]); + expect(injected).toContain('data-hypergolic-emoji-images'); +}); diff --git a/runtime/tests/reviewed-presentation.spec.ts b/runtime/tests/reviewed-presentation.spec.ts index cad7bcf..d8f333a 100644 --- a/runtime/tests/reviewed-presentation.spec.ts +++ b/runtime/tests/reviewed-presentation.spec.ts @@ -102,6 +102,8 @@ async function open(page:Page,appId:string,variant='exact',uploadMode='complete' if(!state.releaseImages){held.set(request.id,envelope);return;} response.items=request.requests.map((item:any,index:number)=>({url:item.url,ok:true, transfer:{handle:`00000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`,length:atob(png).length,mime:'image/png'}})); + }else if(request.type==='resource.bytes'){ + response.transfer={handle:'00000000-0000-4000-8000-000000000007',length:atob(png).length,mime:'image/png'}; }else if(request.type==='resource.__chunk'){state.chunks++;response.base64=png;} else if(request.type==='resource.cancel'){ const prior=held.get(request.id);if(prior){held.delete(request.id);reply(prior,{type:'resource.bytesMany.error',id:request.id,error:'timeout'});} @@ -114,6 +116,7 @@ async function open(page:Page,appId:string,variant='exact',uploadMode='complete' await page.goto(`/assets/runtime/index.html?sessionId=${generation}&source=published`); const frame=page.frameLocator('#napplet'); await expect(frame.locator('#hypergolic-reviewed-presentation')).toHaveCount(variant==='exact'?1:0); + await expect(frame.locator('[data-hypergolic-emoji-images]')).toHaveCount(appId==='emoji-browser'&&variant==='exact'?1:0); await expect(frame.locator('[data-hypergolic-creator-upload]')).toHaveCount(appId==='emoji-pack-creator'&&variant==='exact'?1:0); await expect(frame.locator('body')).not.toHaveText(''); return frame; @@ -142,6 +145,7 @@ for(const width of [320,360])for(const colorScheme of ['light','dark'] as const) await page.evaluate(()=>(window as any).__presentation.releaseImages=true); await retry.click();await expect.poll(()=>page.evaluate(()=>(window as any).__presentation.chunks)).toBeGreaterThanOrEqual(6); await expect(frame.locator('.emoji-card')).toHaveCount(6); + await expect.poll(()=>frame.locator('.emoji-card img').evaluateAll(images=>images.filter(image=>image.complete&&image.naturalWidth>0).length)).toBe(6); await frame.locator('.emoji-card').first().click();await expect(frame.locator('.detail')).toContainText('sample_0'); await fits(frame.locator('.topbar button,.toolbar button,.toolbar input,.emoji-card,.detail button')); expect(await page.evaluate(()=>(window as any).__presentation.batches)).toBeGreaterThanOrEqual(2);