Bring the checklist app under version control

The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
// 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);
}());