258 lines
14 KiB
JavaScript
258 lines
14 KiB
JavaScript
// 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 <img>/<a> 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 <img src> 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 <img>. 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 <img> 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);
|
||
|
|
}());
|