The server address and the player token were set from alerts that opened on shaking the phone, which nobody finds. Settings now has a This device section in the app: the server, the player token and its address, and a Make button that creates an API token and hands it straight to the app's keychain. The app sends what it has at ready (appSettings) and takes setServer, setToken and clearToken; a new server rebuilds the web view as the alert did. The first-launch prompt and the Cannot reach alert stay native, since there is no page then. The iPhone is portrait only; the iPad keeps all four orientations, which Split View and Slide Over need (#434 checked the iPad's layout). Build 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
13 KiB
TypeScript
235 lines
13 KiB
TypeScript
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
|
//
|
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
|
// road, with nothing to look at.
|
|
//
|
|
// node tests/native-bridge.ts
|
|
import { makeContext, vm, scriptName } from './dom-stub.ts';
|
|
import { buildPage } from '../web/build.mts';
|
|
|
|
const { html, js: script } = buildPage('index.html');
|
|
|
|
let failed = 0;
|
|
const ok = (cond: unknown, what: string) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
|
|
|
/* ---- a browser: nothing installs ---- */
|
|
{
|
|
const { ctx } = makeContext(html, script, { media: true });
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: scriptName(script, 'browser'), timeout: 5000 });
|
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
|
const audio = ctx.document.querySelector('#audio');
|
|
audio.src = '/media/1';
|
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
|
}
|
|
|
|
/* ---- inside the shell ---- */
|
|
/// What the page sent the shell: a kind, `t`, and its fields.
|
|
type Posted = { t: string, [field: string]: unknown };
|
|
const posted: Posted[] = [];
|
|
const { ctx, audio: media, body } = makeContext(html, script, { media: true });
|
|
if (!media) throw new Error('makeContext gave no media element for { media: true }');
|
|
const audio = media;
|
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: (m: Posted) => posted.push(m) } } };
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: scriptName(script, 'shell'), timeout: 5000 });
|
|
|
|
const last = (t: string) => [...posted].reverse().find(m => m.t === t);
|
|
const since = () => posted.splice(0, posted.length);
|
|
|
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
|
ok(last('ready'), 'the host was never told the bridge is in');
|
|
since();
|
|
|
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
|
|
|
body.classList.toggle('has-video', false);
|
|
audio.calls.length = 0;
|
|
audio.src = '/media/42';
|
|
|
|
const load = last('load');
|
|
ok(load, 'setting the src told the host nothing');
|
|
if (load) {
|
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
|
}
|
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
|
|
|
// player.ts sets currentTime=0 straight after the src.
|
|
since();
|
|
audio.currentTime = 0;
|
|
ok(last('seek')?.to === 0, 'a seek did not reach the host');
|
|
|
|
since();
|
|
audio.play();
|
|
ok(last('play'), 'play did not reach the host');
|
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
|
|
|
// The host answers, and the page must move as it would have on its own.
|
|
let played = 0, timed = 0;
|
|
audio.addEventListener('play', () => played++);
|
|
audio.addEventListener('timeupdate', () => timed++);
|
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
|
ok(played === 1, 'the page never saw the host start playing');
|
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
|
|
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
|
|
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
|
|
|
// The 15-second key: a read and a write through the shim.
|
|
since();
|
|
audio.currentTime -= 15;
|
|
ok(last('seek')?.to === 15, 'back 15 seconds did not land at 15');
|
|
|
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
|
since();
|
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
|
ok(/\/position$/.test(String(last('position')?.url ?? '')), 'the position write did not become a request to the host');
|
|
|
|
// Closing the player has to stop the host, not just blank the element.
|
|
since();
|
|
audio.removeAttribute('src');
|
|
ok(last('stop'), 'closing the player left the host playing');
|
|
|
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
|
// buys nothing.
|
|
since();
|
|
audio.calls.length = 0;
|
|
body.classList.toggle('has-video', true);
|
|
audio.src = '/media/99';
|
|
ok(!last('load'), 'a video was handed to the host');
|
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
|
|
|
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
|
|
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
|
|
ok(String(vm.runInContext('keepBtn(X)', Object.assign(ctx, { X: enc42 }))).includes('data-a="offline"'),
|
|
'no Keep button in the app, which has no service worker to keep a file');
|
|
since();
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
|
|
ok(vm.runInContext('keeping.has("/media/42")', ctx), 'the button did not show it was on its way');
|
|
|
|
// The app's answer, which comes whether or not anything is playing.
|
|
audio.removeAttribute('src');
|
|
ctx.window.ipxNative.on({ t: 'kept', urls: ['/media/42'], keeping: [] });
|
|
ok(vm.runInContext('isKept(X)', ctx), "the app's list did not reach the page");
|
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'still shown as on its way once kept');
|
|
ok(vm.runInContext('mediaSrc(X)', ctx) === '/media/42?offline', 'a kept file is not asked for as kept');
|
|
|
|
since();
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ok(last('unkeep')?.url === '/media/42', 'pressing it again did not ask the app to let it go');
|
|
ctx.window.ipxNative.on({ t: 'kept', urls: [], keeping: [] });
|
|
ok(!vm.runInContext('isKept(X)', ctx), 'still kept after the app let it go');
|
|
|
|
// One that could not be kept stops showing as on its way.
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ctx.window.ipxNative.on({ t: 'keepFailed', url: '/media/42', message: 'The server said 404.' });
|
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'a failed keep still shows as on its way');
|
|
|
|
since();
|
|
await vm.runInContext('clearOffline()', ctx);
|
|
ok(last('clearKept'), 'signing out left the phone holding what was kept');
|
|
|
|
// The sleep timer and AirPlay (#429): the app's to do, once it says it can.
|
|
// The stub hands out a new element on each lookup, so the select's painting is left out.
|
|
vm.runInContext('paintSleep=()=>{}', ctx);
|
|
since();
|
|
vm.runInContext('sleepIn(900)', ctx);
|
|
ok(!last('sleep'), 'the sleep timer went to an app that never said it has one');
|
|
ok(vm.runInContext('sleepTimer', ctx), 'with an app that has no timer, the page did not keep the time');
|
|
vm.runInContext('sleepIn(0)', ctx);
|
|
|
|
ctx.window.ipxNative.on({ t: 'host', can: ['sleep', 'route'] });
|
|
ok(vm.runInContext('hostCan.has("route")', ctx), "the app's picker was not taken up");
|
|
since();
|
|
vm.runInContext('sleepIn(900)', ctx);
|
|
ok(last('sleep')?.secs === 900, 'the sleep timer did not reach the app');
|
|
ok(!vm.runInContext('sleepTimer', ctx), 'the page kept a timer of its own beside the app\'s');
|
|
ctx.window.ipxNative.on({ t: 'slept' });
|
|
ok(vm.runInContext('sleepAt', ctx) === 0, 'the timer still shows after the app paused for it');
|
|
ctx.window.ipxNative.on({ t: 'sleep', left: 600 });
|
|
ok(vm.runInContext('sleepAt', ctx) > Date.now() + 590_000, 'a reloaded page was not told how long is left');
|
|
since();
|
|
vm.runInContext('sleepIn(0)', ctx);
|
|
ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app');
|
|
|
|
// Haptics (#430): the page asks, the phone does.
|
|
since();
|
|
vm.runInContext('toast("Unable to play", true)', ctx);
|
|
ok(last('haptic')?.kind === 'warn', 'an error was not felt');
|
|
|
|
// The icon's count (#421): the page's own, sent when it changes.
|
|
since();
|
|
vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx);
|
|
ok(posted.filter(m => m.t === 'unread').map(m => m.n).join() === '7,3', 'the app was not told the unread count once per change');
|
|
|
|
// Push (#433): the app's token is registered with ipx by the page, and dropped on signing out.
|
|
const fetched: string[] = [];
|
|
ctx.fetch = (url: unknown, init?: { method?: string, body?: string }) => {
|
|
fetched.push(`${init?.method || 'GET'} ${url} ${init?.body || ''}`);
|
|
return Promise.resolve({ ok: true, status: 204, headers: { get: () => null }, text: () => Promise.resolve(''), json: () => Promise.resolve(null) });
|
|
};
|
|
ctx.window.ipxNative.on({ t: 'push', token: 'ab'.repeat(32), sandbox: false });
|
|
await new Promise(r => setTimeout(r, 0));
|
|
ok(fetched.some(f => f.startsWith('POST /api/push/devices') && f.includes('"sandbox":false')), 'the phone was not registered with ipx');
|
|
await vm.runInContext('forgetPush()', ctx);
|
|
ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone');
|
|
|
|
// The app's own settings (#445), in Settings: only once the app has sent them.
|
|
ok(vm.runInContext('deviceSettingsHTML()', ctx) === '', 'Settings offered app settings the app never sent');
|
|
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example"><b>', api: 'https://api.example', tokenSet: false });
|
|
const device = String(vm.runInContext('deviceSettingsHTML()', ctx));
|
|
ok(device.includes('https://api.example') && device.includes('Not set'), "Settings does not show the app's server and token");
|
|
ok(!device.includes('"><b>'), 'the server address went into Settings unescaped');
|
|
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example', api: 'https://api.example', tokenSet: true });
|
|
ok(String(vm.runInContext('deviceSettingsHTML()', ctx)).includes('placeholder="Set"'), 'a saved token still shows as not set');
|
|
|
|
// CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one.
|
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
|
body.classList.toggle('has-video', false);
|
|
audio.src = '/media/42';
|
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
|
since();
|
|
ctx.window.ipxNative.on({ t: 'replaced' });
|
|
ok(vm.runInContext('player.guid', ctx) === null, "the bar still shows the episode the car replaced");
|
|
ok(audio.paused === true, 'the page still thinks its episode plays');
|
|
audio.removeAttribute('src');
|
|
ok(!last('stop'), "closing the page's player stopped what the car chose");
|
|
ok(!posted.some(m => m.t === 'position'), 'the old episode saved its place by the new one\'s clock');
|
|
|
|
// The car's pick, or one already playing as the page loads (#442): the bar takes it up, and its
|
|
// buttons drive the phone's player, where it used to close and leave nothing to pause.
|
|
since();
|
|
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
|
|
playing: true, cur: 120, dur: 1800 });
|
|
ok(vm.runInContext('player.guid', ctx) === 'g2', 'the bar did not take up what the phone plays');
|
|
ok(audio.paused === false && audio.currentTime === 120 && audio.duration === 1800, 'the bar is not at the phone\'s place');
|
|
ok(!last('load') && !last('play'), 'taking it up started it again');
|
|
audio.pause();
|
|
ok(last('pause'), "the bar's pause did not reach the phone's player");
|
|
ctx.window.ipxNative.on({ t: 'state', playing: false });
|
|
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
|
|
playing: false, cur: 130 });
|
|
ok(audio.paused === true && audio.currentTime === 130, 'told again on coming forward, the bar did not follow');
|
|
|
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
|
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
|
|
process.exit(0);
|