// native-bridge.js — loaded by BOTH the website and the iOS/Android app bundles. // // The website and the two store apps run the exact same front-end files; this shim is the // only thing that knows the difference. In a browser it is a deliberate NO-OP: it defines // window.CHECKLIST_NATIVE with active:false and returns before touching anything. Every // behaviour change below happens only inside a Capacitor shell. // // Why it's needed: on the web the front-end and the API share an origin, so `/api/state` // and `/equip-images/x.jpg` just work. Inside a store app the front-end is served from the // app bundle (capacitor://localhost on iOS, https://localhost on Android) and the API lives // on a different origin, so every server-absolute path has to be re-pointed at the real // server. Rather than rewrite hundreds of call sites we patch fetch() once and expose a // resolveUrl() the handful of / sites use. // // The API base is injected by the mobile build (mobile/scripts/sync-web.js writes a // native-config.js into the bundle). It is intentionally NOT hard-coded here so the website // copy of this file carries no server URL and dev builds can point somewhere else. (function () { 'use strict'; var cfg = window.CHECKLIST_NATIVE_CONFIG || null; // present only in native bundles var cap = window.Capacitor; var isNative = !!(cap && typeof cap.isNativePlatform === 'function' && cap.isNativePlatform()); // Which server this install talks to. // // The app ships to anyone, not just Waters, so the server is a per-INSTALL choice rather // than something baked into the binary: the user picks it once at first launch. The // bundled apiBase is only a SUGGESTION pre-filled into that screen — using it // automatically would silently point every download at Waters' NAS. // Three modes, chosen once on the setup screen: // 'local' — no server at all; server.js itself runs in this WebView (local-server.js) // 'server' — talk to the address in checklist:server // '' — nothing chosen yet, show the setup screen var mode = ''; var chosen = ''; try { mode = localStorage.getItem('checklist:mode') || ''; chosen = localStorage.getItem('checklist:server') || ''; } catch (_) {} if (!mode && chosen) mode = 'server'; // installs from before local mode existed var base = String(chosen).replace(/\/+$/, ''); // Published surface. `active` is what app.js branches on; resolveUrl is safe to call // on the web too (it returns the path unchanged), so call sites need no platform test. var api = { active: isNative, platform: isNative && typeof cap.getPlatform === 'function' ? cap.getPlatform() : 'web', apiBase: isNative ? base : '', bundleVersion: (cfg && cfg.bundleVersion) || '', bundledAt: (cfg && cfg.bundledAt) || '', // Pre-filled into the setup screen so Waters volunteers just tap Continue. suggestedServer: (cfg && cfg.apiBase) || '', mode: mode, // app.js shows the setup screen instead of the sign-in sheet when this is true. needsSetup: isNative && mode !== 'local' && !base, setMode: function (m) { try { localStorage.setItem('checklist:mode', m); } catch (_) {} }, setServer: function (url) { try { localStorage.setItem('checklist:server', String(url).replace(/\/+$/, '')); } catch (_) {} }, clearServer: function () { try { localStorage.removeItem('checklist:server'); localStorage.removeItem('checklist:mode'); } catch (_) {} }, resolveUrl: function (u) { return u; } }; window.CHECKLIST_NATIVE = api; if (!isNative) return; // ── website: stop here, change nothing ── // ── Local mode ──────────────────────────────────────────────────────────────────── // No server anywhere. local-server.js runs the real server.js in this WebView, so fetch // is answered in-process rather than over the network. app.js is untouched by this: it // still calls apiGet('/api/state') and still gets exactly the response the real server // would have produced, because it IS the real server. if (mode === 'local') { api.apiBase = ''; setupLocalRouting(); return; } // No server chosen yet. Deliberately do NOT patch fetch — app.js will show the setup // screen and make no API calls until a server exists, and a half-configured patch would // turn that into a screen of confusing 404s. if (!base) return; // Server-absolute paths only: "/api/state" yes, "//cdn/x" no (protocol-relative), // "https://…" no (already absolute), "app.js" no (a bundled asset, must stay local). function isServerPath(u) { return typeof u === 'string' && u.charAt(0) === '/' && u.charAt(1) !== '/'; } api.resolveUrl = function (u) { return isServerPath(u) ? base + u : u; }; // ── fetch() ─────────────────────────────────────────────────────────────────────── // Everything in app.js goes through apiFetch()/apiGet()/apiPost() plus three raw // fetches, and all of them pass a server-absolute string. Patching here covers the lot // without touching a single call site. Request/URL inputs are handled defensively so a // future call site can't quietly break. var nativeFetch = window.fetch.bind(window); window.fetch = function (input, init) { try { if (isServerPath(input)) { input = base + input; } else if (typeof Request !== 'undefined' && input instanceof Request) { // A Request's url is already absolutised against the shell origin, so match // on that origin rather than on a leading slash. var abs = input.url; if (abs.indexOf(location.origin + '/') === 0) { input = new Request(base + abs.slice(location.origin.length), input); } } else if (typeof URL !== 'undefined' && input instanceof URL) { if (input.origin === location.origin) input = base + input.pathname + input.search; } } catch (_) { /* never let URL rewriting break the request itself */ } return nativeFetch(input, init); }; // Media stored by the local server lives in IndexedDB, and an cannot read that. // Blob URLs bridge the gap: every stored file gets one, resolveUrl hands it out, and new // uploads register theirs as soon as the upload response comes back. // NOTE: assigned inside setupLocalRouting, not out here. The local-mode branch above // returns early, so a `var` initialised at this position would still be undefined by the // time resolveUrl ran — hoisting lifts the declaration, never the assignment. var mediaUrls; // A Blob built with no type gets an empty content type, and WebKit can refuse to render // that in an . The stored filename carries the extension, so use it. function mimeFor(p) { var m = /\.([A-Za-z0-9]+)$/.exec(p || ''); var e = m ? m[1].toLowerCase() : ''; return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', pdf: 'application/pdf' })[e] || 'application/octet-stream'; } function setupLocalRouting() { mediaUrls = Object.create(null); if (!window.LocalServer) { console.error('[native-bridge] local mode selected but local-server.js is missing'); return; } var LS = window.LocalServer; api.resolveUrl = function (u) { if (typeof u !== 'string') return u; return mediaUrls[u] || u; }; // Turn every file already in the virtual filesystem into a blob URL, so photos taken // in a previous session show up immediately on this launch. api.indexLocalMedia = function () { return LS.ready().then(function () { var keys = LS.vfs.keys(); return Promise.all(keys.map(function (k) { var web = k.indexOf('/data/uploads/') === 0 ? '/equip-images/' + k.slice(14) : k.indexOf('/data/docs/') === 0 ? '/doc-files/' + k.slice(11) : ''; if (!web || mediaUrls[web]) return null; return LS.vfs.raw(k).then(function (content) { if (content == null) return; mediaUrls[web] = URL.createObjectURL(new Blob([content], { type: mimeFor(web) })); }).catch(function () {}); })); }); }; var nativeFetchLocal = window.fetch.bind(window); window.fetch = function (input, init) { var url = typeof input === 'string' ? input : (input && input.url ? input.url.replace(location.origin, '') : ''); var isApi = url.indexOf('/api/') === 0; var isMedia = url.indexOf('/equip-images/') === 0 || url.indexOf('/doc-files/') === 0; if (!isApi && !isMedia) return nativeFetchLocal(input, init); var opts = init || {}; var body; try { body = opts.body ? JSON.parse(opts.body) : undefined; } catch (_) { body = undefined; } var headers = {}; try { var h = opts.headers || {}; Object.keys(h).forEach(function (k) { headers[k.toLowerCase()] = h[k]; }); } catch (_) {} return LS.handle(opts.method || 'GET', url, body, headers).then(function (r) { // Register any file the server just wrote, so the that renders a moment // later already has a blob URL to point at. if (r.body && typeof r.body === 'object' && typeof r.body.path === 'string') { var p = r.body.path; if (!mediaUrls[p]) { var disk = p.indexOf('/equip-images/') === 0 ? '/data/uploads/' + p.slice(14) : p.indexOf('/doc-files/') === 0 ? '/data/docs/' + p.slice(11) : ''; if (disk) { return LS.vfs.raw(disk).then(function (c) { if (c != null) mediaUrls[p] = URL.createObjectURL(new Blob([c], { type: mimeFor(p) })); return toResponse(r); }); } } } return toResponse(r); }); }; function toResponse(r) { var isJson = r.body !== null && typeof r.body === 'object' && !r.isBuffer; var payload = r.isBuffer ? r.body : (isJson ? JSON.stringify(r.body) : String(r.body == null ? '' : r.body)); return new Response(payload, { status: r.status, headers: Object.assign({ 'Content-Type': isJson ? 'application/json' : 'application/octet-stream' }, r.headers || {}), }); } } // ── Haptics ─────────────────────────────────────────────────────────────────────── // app.js already calls navigator.vibrate() on check-off. iOS has no Vibration API at // all (it threw benign console errors), and inside a WebView Android's is unreliable. // Routing it to the native Haptics plugin makes check-offs feel right on both, with // zero changes in app.js. Durations map onto the three native impact weights. var plugins = window.CapPlugins || {}; if (plugins.Haptics) { navigator.vibrate = function (pattern) { try { var ms = Array.isArray(pattern) ? pattern[0] : pattern; ms = Number(ms) || 0; if (ms <= 0) return true; var style = ms <= 15 ? 'LIGHT' : (ms <= 40 ? 'MEDIUM' : 'HEAVY'); plugins.Haptics.impact({ style: style }); } catch (_) {} return true; // the Vibration API contract: report success synchronously }; } // ── External links ──────────────────────────────────────────────────────────────── // Attached documents and pasted URLs render as target="_blank". In a WebView that // either does nothing or replaces the app's own page with no way back — both look like // a crash. Hand them to the system browser instead (in-app Safari/Custom Tab), which // is also what App Review expects. Capture phase so we win before other handlers. document.addEventListener('click', function (ev) { var a = ev.target && ev.target.closest && ev.target.closest('a[href]'); if (!a) return; var href = a.getAttribute('href') || ''; if (!href || href.charAt(0) === '#') return; var abs = api.resolveUrl(href); if (!/^https?:\/\//i.test(abs)) return; // mailto:, tel:, blob: — leave to the OS if (abs.indexOf(location.origin) === 0) return; // a link inside our own bundle ev.preventDefault(); if (plugins.Browser) plugins.Browser.open({ url: abs, presentationStyle: 'popover' }); else window.open(abs, '_system'); }, true); }());