fix: pre-fetch bincio wheel via RN networking to avoid ATS blocking HTTP in WKWebView

WKWebView blocks HTTP requests (ATS) even when NSAllowsLocalNetworking is set for
the app's own networking — so fetch(http://192.168.x.x/api/wheel/download) inside
the WebView always fails with 'Load failed' on iOS.

- extractActivity.ts: rename wheelUrl param to wheelBase64; WebView now receives
  the wheel as pre-fetched base64 bytes rather than a URL to fetch itself
- PyodideWebView.tsx: decode wheelBase64 → Uint8Array → Blob → blob URL for
  micropip.install; fix baseUrl '' → 'https://localhost' (null origin blocks fetch
  on iOS)
- import.tsx: add fetchWheelBase64() that resolves the wheel URL via
  /api/wheel/version then fetches with native networking (HTTP works); caches
  result in memory so repeated imports in one session don't re-download
This commit is contained in:
Davide Scaini
2026-04-24 23:13:24 +02:00
parent 966528a0bf
commit 84e5cead08
3 changed files with 44 additions and 26 deletions
+29 -14
View File
@@ -91,14 +91,17 @@ export default function ImportScreen() {
encoding: FileSystem.EncodingType.Base64, encoding: FileSystem.EncodingType.Base64,
}); });
// Wheel URL: prefer the configured instance; fall back to bincio.org. // Fetch the bincio wheel here (React Native networking), not inside the
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
const instanceUrl = await getInstanceUrl(dbCtx); const instanceUrl = await getInstanceUrl(dbCtx);
const wheelUrl = await resolveWheelUrl(instanceUrl); setState({ status: 'loading', msg: 'Fetching Bincio engine…' });
const wheelBase64 = await fetchWheelBase64(instanceUrl);
const result = await extractFile( const result = await extractFile(
name, name,
base64, base64,
wheelUrl, wheelBase64,
(msg) => setState({ status: 'loading', msg }), (msg) => setState({ status: 'loading', msg }),
); );
@@ -146,11 +149,9 @@ export default function ImportScreen() {
{state.status === 'loading' && ( {state.status === 'loading' && (
<View style={styles.statusBox}> <View style={styles.statusBox}>
<Text style={styles.statusMsg}>{state.msg}</Text> <Text style={styles.statusMsg}>{state.msg}</Text>
{state.msg.startsWith('Load') && ( <Text style={styles.statusHint}>
<Text style={styles.statusHint}> First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
First run downloads ~35 MB of the Python runtime. Subsequent runs are instant. </Text>
</Text>
)}
</View> </View>
)} )}
@@ -204,17 +205,31 @@ async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<
return (row?.value ?? '').replace(/\/$/, ''); return (row?.value ?? '').replace(/\/$/, '');
} }
async function resolveWheelUrl(instanceUrl: string): Promise<string> { // In-memory cache so repeated imports in one session don't re-download the wheel.
let _cachedWheelBase64: string | null = null;
async function fetchWheelBase64(instanceUrl: string): Promise<string> {
if (_cachedWheelBase64) return _cachedWheelBase64;
const base = instanceUrl || 'https://bincio.org'; const base = instanceUrl || 'https://bincio.org';
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
let wheelUrl = `${base}/api/wheel/download`;
try { try {
const resp = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) }); const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
if (resp.ok) { if (vr.ok) {
const d = await resp.json() as { api_url?: string; url?: string }; const d = await vr.json() as { api_url?: string; url?: string };
const path = d.api_url ?? d.url ?? '/api/wheel/download'; const path = d.api_url ?? d.url ?? '/api/wheel/download';
return path.startsWith('http') ? path : `${base}${path}`; wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
} }
} catch {} } catch {}
return `${base}/api/wheel/download`;
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
const resp = await fetch(wheelUrl);
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
const buf = await resp.arrayBuffer();
_cachedWheelBase64 = Buffer.from(buf).toString('base64');
return _cachedWheelBase64;
} }
// ── Styles ─────────────────────────────────────────────────────────────────── // ── Styles ───────────────────────────────────────────────────────────────────
+11 -10
View File
@@ -87,10 +87,10 @@ var initError = null;
})(); })();
window._bincioExtract = async function(params) { window._bincioExtract = async function(params) {
var reqId = params.reqId; var reqId = params.reqId;
var filename = params.filename; var filename = params.filename;
var base64 = params.base64; var base64 = params.base64;
var wheelUrl = params.wheelUrl; var wheelBase64 = params.wheelBase64; // pre-fetched by React Native (avoids ATS/HTTP issues)
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); } function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
@@ -108,13 +108,14 @@ window._bincioExtract = async function(params) {
} }
if (initError) throw new Error(initError); if (initError) throw new Error(initError);
// Install bincio wheel on first extraction (lazy: keeps startup fast) // Install bincio wheel on first extraction.
// Wheel bytes arrive pre-fetched from the React Native side as base64,
// so the WebView never needs to make an HTTP request (avoids ATS blocks).
if (!wheelReady) { if (!wheelReady) {
post({ type: 'progress', msg: 'Loading Bincio…' }); post({ type: 'progress', msg: 'Loading Bincio…' });
var resp = await fetch(wheelUrl); var wheelBytes = Uint8Array.from(atob(wheelBase64), function(c) { return c.charCodeAt(0); });
if (!resp.ok) throw new Error('Failed to fetch Bincio wheel (' + resp.status + ')'); var wheelBlob = new Blob([wheelBytes]);
var wheelBlob = new Blob([await resp.arrayBuffer()]); var blobUrl = URL.createObjectURL(wheelBlob);
var blobUrl = URL.createObjectURL(wheelBlob);
pyodide.globals.set('_blobUrl', blobUrl); pyodide.globals.set('_blobUrl', blobUrl);
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL); await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
@@ -158,7 +159,7 @@ export function PyodideWebView() {
return ( return (
<WebView <WebView
ref={pyodideRef} ref={pyodideRef}
source={{ html: PYODIDE_HTML, baseUrl: '' }} source={{ html: PYODIDE_HTML, baseUrl: 'https://localhost' }}
style={styles.hidden} style={styles.hidden}
onMessage={handleWebViewMessage} onMessage={handleWebViewMessage}
javaScriptEnabled javaScriptEnabled
+4 -2
View File
@@ -54,10 +54,12 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void {
} }
} }
// wheelBase64 is the bincio .whl file pre-fetched by the React Native side
// (native networking supports HTTP on local network; WKWebView does not).
export function extractFile( export function extractFile(
filename: string, filename: string,
base64: string, base64: string,
wheelUrl: string, wheelBase64: string,
onStatus: (msg: string) => void = () => {}, onStatus: (msg: string) => void = () => {},
): Promise<ExtractionResult> { ): Promise<ExtractionResult> {
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress')); if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
@@ -67,7 +69,7 @@ export function extractFile(
isExtracting = true; isExtracting = true;
const reqId = String(++reqCounter); const reqId = String(++reqCounter);
const args = JSON.stringify({ reqId, filename, base64, wheelUrl }); const args = JSON.stringify({ reqId, filename, base64, wheelBase64 });
return new Promise<ExtractionResult>((resolve, reject) => { return new Promise<ExtractionResult>((resolve, reject) => {
pending.set(reqId, { pending.set(reqId, {