Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion runtime/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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), {
Expand Down
33 changes: 33 additions & 0 deletions runtime/src/reviewed-emoji-images.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 = /<meta http-equiv="Content-Security-Policy" content="[^"]*">/;
if (!csp.test(html)) throw new Error('Emoji image compatibility requires host CSP');
return html.replace(csp, value => `${value}<script data-hypergolic-emoji-images>(${installEmojiImages.toString()})();</script>`);
}
67 changes: 67 additions & 0 deletions runtime/tests/reviewed-emoji-images.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '<head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; script-src \'unsafe-inline\'; img-src blob:; connect-src blob:"></head><body data-fixture-ready></body>';
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';

async function open(page: Page) {
await page.setContent('<iframe sandbox="allow-scripts"></iframe>');
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('<body></body>', pin)).toThrow('host CSP');
const injected = injectEmojiImages(document, pin);
expect(injected).toContain(document.match(/<meta[^>]+>/)![0]);
expect(injected).toContain('data-hypergolic-emoji-images');
});
4 changes: 4 additions & 0 deletions runtime/tests/reviewed-presentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'});}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading