diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 324e2f0..38f5bba 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries'; import { PyodideWebView } from '@/extraction/PyodideWebView'; -import { extractFile } from '@/extraction/extractActivity'; +import { extractFile, waitForEngine } from '@/extraction/extractActivity'; import { useTheme } from '@/ThemeContext'; const FIT_EXTENSIONS = ['.fit', '.fit.gz']; @@ -54,7 +54,10 @@ export default function ImportScreen() { const path = await getSetting(db, 'auto_import_path'); if (!path) return; const instanceUrl = await getSetting(db, 'instance_url'); - if (!instanceUrl) return; // silently skip — engine can't be downloaded without an instance + if (!instanceUrl) return; + + // Wait for the extraction engine — but don't block forever on auto-scan. + try { await waitForEngine(120_000); } catch { return; } const newFiles = await discoverNewFiles(db, path); if (newFiles.length === 0) return; @@ -77,6 +80,14 @@ export default function ImportScreen() { return; } + setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 }); + try { + await waitForEngine(); + } catch (e: unknown) { + setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); + return; + } + setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 }); const newFiles = await discoverNewFiles(db, path); if (newFiles.length === 0) { diff --git a/mobile/extraction/PyodideWebView.tsx b/mobile/extraction/PyodideWebView.tsx index 856d337..7b18f45 100644 --- a/mobile/extraction/PyodideWebView.tsx +++ b/mobile/extraction/PyodideWebView.tsx @@ -2,7 +2,9 @@ import { StyleSheet } from 'react-native'; import WebView from 'react-native-webview'; import { handleWebViewMessage, pyodideRef } from './extractActivity'; -const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/'; +// v0.18.1: newest version whose pyodide.js is ES2017-compatible (no ??, no ?.) +// Chrome 61 (Karoo WebView) throws SyntaxError on 0.19+ which uses nullish-coalescing. +const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.18.1/full/'; // Python snippets embedded as JSON strings to avoid any JS/TS escaping issues. const PY_INSTALL_PACKAGES = [ @@ -74,10 +76,39 @@ var initError = null; (async function init() { try { _post({ type: 'progress', msg: 'Loading Python runtime…' }); + + // Chrome <63 (e.g. Karoo WebView 61) cannot parse the dynamic import() + // syntax that Pyodide uses to load pyodide.asm.js. The parser rejects + // the whole file before it runs, so loadPyodide is never defined. + // + // Fix: fetch pyodide.js as text, replace every "import(" with a + // script-tag–based loader that Chrome 61 can execute, then inject the + // patched code via a Blob URL. The Blob script is never parsed by the + // module-aware pre-scanner, so the import keyword is invisible to it. + window.__loadScript = function(url) { + return new Promise(function(res, rej) { + var s = document.createElement('script'); + s.src = url; + s.onload = res; + s.onerror = function() { rej(new Error('Failed to load ' + url)); }; + document.head.appendChild(s); + }); + }; + + var pyResp = await fetch(_CDN + 'pyodide.js'); + if (!pyResp.ok) throw new Error('Could not fetch pyodide.js (' + pyResp.status + ')'); + var pyCode = await pyResp.text(); + // Replace all dynamic import() calls. Node.js-only paths (importing + // "path", "fs", etc.) are guarded by IN_NODE and never run in WebView. + pyCode = pyCode.replace(/\bimport\(/g, '__loadScript('); + await new Promise(function(res, rej) { + var blob = new Blob([pyCode], { type: 'application/javascript' }); + var blobUrl = URL.createObjectURL(blob); var s = document.createElement('script'); - s.src = _CDN + 'pyodide.js'; - s.onload = res; s.onerror = rej; + s.src = blobUrl; + s.onload = function() { URL.revokeObjectURL(blobUrl); res(); }; + s.onerror = function() { URL.revokeObjectURL(blobUrl); rej(new Error('Failed to execute patched pyodide.js')); }; document.head.appendChild(s); }); diff --git a/mobile/extraction/extractActivity.ts b/mobile/extraction/extractActivity.ts index 9e1c121..cf8cc56 100644 --- a/mobile/extraction/extractActivity.ts +++ b/mobile/extraction/extractActivity.ts @@ -22,6 +22,24 @@ const pending = new Map(); let reqCounter = 0; let isExtracting = false; +// Engine readiness — tracked so callers can wait before batching files. +let _engineReady = false; +let _engineError: string | null = null; +const _engineResolvers: Array<() => void> = []; +const _engineRejecters: Array<(e: Error) => void> = []; + +export function waitForEngine(timeoutMs = 300_000): Promise { + if (_engineReady) return Promise.resolve(); + if (_engineError) return Promise.reject(new Error(_engineError)); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Extraction engine timed out — check network and Bincio instance URL')); + }, timeoutMs); + _engineResolvers.push(() => { clearTimeout(timer); resolve(); }); + _engineRejecters.push((e) => { clearTimeout(timer); reject(e); }); + }); +} + export function handleWebViewMessage(e: WebViewMessageEvent): void { let msg: Record; try { msg = JSON.parse(e.nativeEvent.data); } catch { return; } @@ -30,6 +48,14 @@ export function handleWebViewMessage(e: WebViewMessageEvent): void { const p = reqId ? pending.get(reqId) : undefined; switch (msg.type) { + case 'pyodide_ready': + _engineReady = true; + _engineResolvers.splice(0).forEach(fn => fn()); + break; + case 'init_error': + _engineError = msg.message as string; + _engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!))); + break; case 'result': if (p) { pending.delete(reqId!);