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:
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
// boot.js — runs the real server.js inside the app, with no server.
|
||||
//
|
||||
// This is the whole idea behind local mode: rather than reimplement fifty API routes in the
|
||||
// client and watch them drift from the server, the actual server code is bundled and run in
|
||||
// the WebView behind Node shims. Recurrence resets, scope rules, reconciliation invariants
|
||||
// and validation are then the same code in every mode, by construction.
|
||||
//
|
||||
// Order matters here and is deliberate: `process` and the virtual filesystem must exist
|
||||
// before server.js is required, because server.js reads env vars and touches the filesystem
|
||||
// while its module body is still evaluating.
|
||||
|
||||
const vfs = require('./vfs.js');
|
||||
const express = require('./shims/express.js');
|
||||
|
||||
const DATA_DIR = '/data';
|
||||
const PW_KEY = 'checklist:localAdminPassword';
|
||||
|
||||
// A local install has exactly one user and no network surface, but server.js is still the
|
||||
// real thing underneath, so it wants a master password. Generate one per install, keep it on
|
||||
// the device, and sign in with it automatically — the user never sees a login, and no
|
||||
// authentication check has been weakened to achieve that.
|
||||
function localAdminPassword() {
|
||||
let pw = '';
|
||||
try { pw = localStorage.getItem(PW_KEY) || ''; } catch (_) {}
|
||||
if (!pw) {
|
||||
const b = new Uint8Array(24);
|
||||
crypto.getRandomValues(b);
|
||||
pw = Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
try { localStorage.setItem(PW_KEY, pw); } catch (_) {}
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
|
||||
let readyPromise = null;
|
||||
let app = null;
|
||||
|
||||
async function start() {
|
||||
await vfs.init();
|
||||
|
||||
const ADMIN_PASSWORD = localAdminPassword();
|
||||
|
||||
// server.js reads these while its module body evaluates, so they must be in place first.
|
||||
// Email and cron are deliberately blank: no SMTP host means the digest code takes its
|
||||
// already-handled "not configured" path rather than erroring.
|
||||
globalThis.process = {
|
||||
env: {
|
||||
DATA_DIR,
|
||||
ADMIN_PASSWORD,
|
||||
PORT: '0',
|
||||
REPORT_EMAIL: '',
|
||||
REPORT_CRON: '0 15 * * 0',
|
||||
REPORT_TZ: Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/New_York',
|
||||
BACKUP_KEEP: '5', // a phone is not a NAS; keep a few, not thirty
|
||||
RESET_AFTER_DIGEST: '',
|
||||
SMTP_HOST: '', SMTP_PORT: '587', SMTP_SECURE: '', SMTP_USER: '', SMTP_PASS: '', SMTP_FROM: '',
|
||||
NATIVE_CORS_ORIGINS: '',
|
||||
},
|
||||
argv: ['node', 'server.js'],
|
||||
platform: 'browser',
|
||||
version: 'v22.0.0',
|
||||
exit() { /* nothing to exit */ },
|
||||
on() {},
|
||||
cwd: () => '/',
|
||||
nextTick: (fn, ...a) => Promise.resolve().then(() => fn(...a)),
|
||||
};
|
||||
|
||||
// server.js decodes uploaded photos and documents with Buffer.from(base64) — a Node
|
||||
// global that does not exist in a WebView, so BOTH upload routes threw here and local
|
||||
// mode could not accept a photo at all. Only Buffer.from + .length are ever used, and a
|
||||
// Uint8Array satisfies both (the virtual filesystem already stores Uint8Array), so a tiny
|
||||
// shim is enough; there is no need to pull in a full Buffer polyfill.
|
||||
globalThis.Buffer = {
|
||||
from(data, encoding) {
|
||||
if (encoding === 'base64') {
|
||||
// A data URL's payload can carry newlines; atob rejects them outright.
|
||||
const clean = String(data).replace(/\s+/g, '');
|
||||
const bin = atob(clean);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
if (typeof data === 'string') return new TextEncoder().encode(data);
|
||||
return new Uint8Array(data);
|
||||
},
|
||||
isBuffer(x) { return x instanceof Uint8Array; },
|
||||
};
|
||||
|
||||
vfs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// server.js resolves everything from __dirname; esbuild defines it to '/' for this bundle.
|
||||
require('../../../server/server.js');
|
||||
|
||||
app = express.__lastApp;
|
||||
if (!app) throw new Error('local runtime: server.js did not create an app');
|
||||
|
||||
// server.js calls app.listen() only after loadState() resolves, so the shim's listen
|
||||
// callback is the honest "the server is up" signal.
|
||||
await express.__listened;
|
||||
|
||||
await seedStarterTasks(app, ADMIN_PASSWORD);
|
||||
|
||||
return { app, ADMIN_PASSWORD };
|
||||
}
|
||||
|
||||
// A brand-new on-device install lands on a single empty list, which is a poor first
|
||||
// impression and tells a new user nothing about what the app is for. Seed a few example
|
||||
// steps the first time — and ONLY when there is not a single task anywhere, so this can
|
||||
// never appear again once someone has real content, and never touches a server install.
|
||||
async function seedStarterTasks(app, password) {
|
||||
try {
|
||||
const login = await app.handle('POST', '/api/login', { location: 'executive', password }, {});
|
||||
if (login.status !== 200 || !login.body || !login.body.token) return;
|
||||
const auth = { authorization: 'Bearer ' + login.body.token };
|
||||
|
||||
const state = await app.handle('GET', '/api/state', undefined, auth);
|
||||
const lists = (state.body && state.body.lists) || [];
|
||||
if (!lists.length) return;
|
||||
if (lists.some((l) => (l.items || []).length)) return; // never overwrite real content
|
||||
|
||||
const target = lists[0];
|
||||
const examples = [
|
||||
'Power on the sound board',
|
||||
'Turn on projectors and stage monitors',
|
||||
'Soundcheck wireless mics and in-ears',
|
||||
'Start the livestream',
|
||||
'Set house lights for worship',
|
||||
];
|
||||
for (const title of examples) {
|
||||
await app.handle('POST', `/api/lists/${target.id}/items`, { title }, auth);
|
||||
}
|
||||
} catch (_) {
|
||||
// Seeding is a nicety — never let it stop the app from starting.
|
||||
}
|
||||
}
|
||||
|
||||
const LocalServer = {
|
||||
/** Resolves once server.js has loaded its state and "started". */
|
||||
ready() {
|
||||
if (!readyPromise) readyPromise = start();
|
||||
return readyPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle one API call. Mirrors what fetch() would have done against a real server.
|
||||
* @returns {Promise<{status:number, headers:Object, body:any, isBuffer:boolean}>}
|
||||
*/
|
||||
async handle(method, url, body, headers) {
|
||||
const { app: a } = await LocalServer.ready();
|
||||
return a.handle(method, url, body, headers);
|
||||
},
|
||||
|
||||
/** The generated master password, so the client can sign itself in. */
|
||||
async adminPassword() {
|
||||
const { ADMIN_PASSWORD } = await LocalServer.ready();
|
||||
return ADMIN_PASSWORD;
|
||||
},
|
||||
|
||||
/** Wipe every local checklist. Used by "start over" in the setup screen. */
|
||||
async reset() {
|
||||
await vfs.init();
|
||||
await vfs.wipe();
|
||||
try { localStorage.removeItem(PW_KEY); } catch (_) {}
|
||||
},
|
||||
|
||||
vfs,
|
||||
};
|
||||
|
||||
module.exports = LocalServer;
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
// server.js uses crypto.randomBytes only — for session tokens and upload filenames.
|
||||
// Backed by the platform CSPRNG, so tokens are as unguessable as on the real server.
|
||||
function randomBytes(n) {
|
||||
const b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
b.toString = function (enc) {
|
||||
if (enc === 'hex') return Array.from(this).map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
if (enc === 'base64') return btoa(String.fromCharCode.apply(null, Array.from(this)));
|
||||
return Array.from(this).join(',');
|
||||
};
|
||||
return b;
|
||||
}
|
||||
module.exports = { randomBytes, webcrypto: crypto };
|
||||
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
// Configuration comes from the process shim in boot.js, not a .env file.
|
||||
module.exports = { config: () => ({ parsed: {} }) };
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
// A minimal Express, just large enough to run server.js in the app with no server.
|
||||
//
|
||||
// Only what server.js actually uses: app.use(fn) / app.use(path, fn), the four verb methods
|
||||
// with variadic middleware (routes are declared as `app.get(path, requireUser, handler)`),
|
||||
// express.json(), express.static(), and the req/res surface the handlers touch. Anything
|
||||
// beyond that is intentionally absent — a silent no-op would hide a real behaviour gap.
|
||||
//
|
||||
// Requests do not arrive over a socket. native-bridge.js calls app.handle() directly with
|
||||
// the method, path and parsed body it intercepted from fetch().
|
||||
|
||||
const vfs = require('../vfs.js');
|
||||
|
||||
function createApp() {
|
||||
const stack = []; // { kind:'use'|'route', method, path, keys, re, handlers }
|
||||
|
||||
// '/api/lists/:id/items/:itemId' → a regex plus the parameter names, so req.params works.
|
||||
function compile(pattern) {
|
||||
const keys = [];
|
||||
const source = String(pattern)
|
||||
.replace(/[.+*?^${}()|[\]\\]/g, '\\$&') // escape regex metacharacters
|
||||
.replace(/\/:([A-Za-z0-9_]+)/g, (_, k) => { keys.push(k); return '/([^/]+)'; });
|
||||
return { keys, re: new RegExp('^' + source + '/?$') };
|
||||
}
|
||||
|
||||
function addRoute(method, path, handlers) {
|
||||
const { keys, re } = compile(path);
|
||||
stack.push({ kind: 'route', method, path, keys, re, handlers });
|
||||
}
|
||||
|
||||
const app = function () {};
|
||||
|
||||
app.use = function (a, b) {
|
||||
if (typeof a === 'function') stack.push({ kind: 'use', mount: '', fn: a });
|
||||
else stack.push({ kind: 'use', mount: String(a).replace(/\/+$/, ''), fn: b });
|
||||
return app;
|
||||
};
|
||||
['get', 'post', 'put', 'patch', 'delete'].forEach((m) => {
|
||||
app[m] = function (path, ...handlers) { addRoute(m.toUpperCase(), path, handlers); return app; };
|
||||
});
|
||||
app.listen = function (_port, cb) { if (typeof cb === 'function') cb(); return { close() {} }; };
|
||||
app.set = function () { return app; };
|
||||
|
||||
/**
|
||||
* Run one request through the stack.
|
||||
* @returns {Promise<{status:number, headers:Object, body:any, isBuffer:boolean}>}
|
||||
*/
|
||||
app.handle = function (method, rawUrl, body, headers) {
|
||||
return new Promise((resolve) => {
|
||||
const qIndex = rawUrl.indexOf('?');
|
||||
const pathname = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
||||
const query = {};
|
||||
if (qIndex !== -1) {
|
||||
new URLSearchParams(rawUrl.slice(qIndex + 1)).forEach((v, k) => { query[k] = v; });
|
||||
}
|
||||
|
||||
const req = {
|
||||
method: String(method).toUpperCase(),
|
||||
url: rawUrl,
|
||||
originalUrl: rawUrl,
|
||||
path: pathname,
|
||||
query,
|
||||
params: {},
|
||||
body: body === undefined ? {} : body,
|
||||
headers: headers || {},
|
||||
get(name) { return (headers || {})[String(name).toLowerCase()]; },
|
||||
};
|
||||
|
||||
let finished = false;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
_headers: {},
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
setHeader(k, v) { this._headers[String(k).toLowerCase()] = v; return this; },
|
||||
getHeader(k) { return this._headers[String(k).toLowerCase()]; },
|
||||
type(t) { return this.setHeader('content-type', t); },
|
||||
json(obj) { this.setHeader('content-type', 'application/json'); this._done(obj, false); },
|
||||
send(data) { this._done(data, data instanceof Uint8Array); },
|
||||
end(data) { this._done(data === undefined ? '' : data, data instanceof Uint8Array); },
|
||||
async sendFile(p) {
|
||||
try {
|
||||
const content = await vfs.raw(p);
|
||||
if (content == null) { this.statusCode = 404; this._done({ error: 'Not found' }, false); return; }
|
||||
this._done(content, content instanceof Uint8Array);
|
||||
} catch (_) { this.statusCode = 500; this._done({ error: 'Read failed' }, false); }
|
||||
},
|
||||
_done(payload, isBuffer) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
resolve({ status: this.statusCode, headers: this._headers, body: payload, isBuffer: !!isBuffer });
|
||||
},
|
||||
};
|
||||
|
||||
// Build the chain for this request: matching app.use middleware, then the first
|
||||
// matching route's own middleware + handler.
|
||||
const chain = [];
|
||||
for (const layer of stack) {
|
||||
if (layer.kind === 'use') {
|
||||
if (!layer.mount || pathname === layer.mount || pathname.startsWith(layer.mount + '/')) {
|
||||
chain.push({ fn: layer.fn, mount: layer.mount });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (layer.method !== req.method) continue;
|
||||
const m = layer.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
layer.keys.forEach((k, i) => { req.params[k] = decodeURIComponent(m[i + 1]); });
|
||||
layer.handlers.forEach((fn) => chain.push({ fn, mount: '' }));
|
||||
break;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const next = (err) => {
|
||||
if (finished) return;
|
||||
if (err) { res.statusCode = 500; res._done({ error: String((err && err.message) || err) }, false); return; }
|
||||
const entry = chain[i++];
|
||||
if (!entry) {
|
||||
// Nothing handled it. Mirrors Express's default 404 so the client's apiFetch sees
|
||||
// the same shape it would from the real server.
|
||||
res.statusCode = 404;
|
||||
res._done({ error: 'Not found' }, false);
|
||||
return;
|
||||
}
|
||||
// A mounted middleware sees the path with its mount point stripped, like Express.
|
||||
const saved = req.url;
|
||||
if (entry.mount) req.url = pathname.slice(entry.mount.length) || '/';
|
||||
let out;
|
||||
try { out = entry.fn(req, res, next); } catch (e) { req.url = saved; next(e); return; }
|
||||
req.url = saved;
|
||||
if (out && typeof out.then === 'function') out.catch(next);
|
||||
};
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// boot.js needs the app instance server.js created, and a signal for when server.js called
|
||||
// listen() — which it does only after loadState() resolves, making it the real readiness
|
||||
// event rather than a guess about timing.
|
||||
let _resolveListened;
|
||||
const _listened = new Promise((r) => { _resolveListened = r; });
|
||||
|
||||
function express() {
|
||||
const app = createApp();
|
||||
const realListen = app.listen;
|
||||
app.listen = function (port, cb) { const out = realListen.call(app, port, cb); _resolveListened(app); return out; };
|
||||
express.__lastApp = app;
|
||||
return app;
|
||||
}
|
||||
express.__listened = _listened;
|
||||
|
||||
// server.js selects a body parser per route, but native-bridge hands us an already-parsed
|
||||
// body — there is no wire format to decode. These stay as pass-through middleware so the
|
||||
// call sites keep working untouched.
|
||||
express.json = function () { return function (req, res, next) { next(); }; };
|
||||
express.urlencoded = function () { return function (req, res, next) { next(); }; };
|
||||
|
||||
// Static mounts exist so /equip-images and /doc-files resolve out of the virtual filesystem.
|
||||
// The app bundle's own assets are served by the WebView, never through here.
|
||||
express.static = function (root) {
|
||||
return async function (req, res, next) {
|
||||
if (req.method !== 'GET') { next(); return; }
|
||||
const rel = decodeURIComponent(String(req.url).split('?')[0]);
|
||||
if (rel.indexOf('..') !== -1) { next(); return; } // never escape the mount root
|
||||
const content = await vfs.raw(root.replace(/\/+$/, '') + rel);
|
||||
if (content == null) { next(); return; }
|
||||
res.send(content);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = express;
|
||||
@@ -0,0 +1,2 @@
|
||||
'use strict';
|
||||
module.exports = require('./fs.js').promises;
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
// Stands in for Node's `fs` when server.js runs inside the app. Only the eleven calls
|
||||
// server.js actually makes are implemented — deliberately, so an unnoticed new fs call
|
||||
// fails loudly here instead of silently doing nothing.
|
||||
const vfs = require('../vfs.js');
|
||||
const promises = {
|
||||
readFile: (p, e) => vfs.readFile(p, e),
|
||||
writeFile: (p, d) => vfs.writeFile(p, d),
|
||||
readdir: (p) => vfs.readdir(p),
|
||||
stat: (p) => vfs.stat(p),
|
||||
unlink: (p) => vfs.unlink(p),
|
||||
rename: (a, b) => vfs.rename(a, b),
|
||||
copyFile: (a, b) => vfs.copyFile(a, b),
|
||||
mkdir: (p, o) => vfs.mkdir(p, o),
|
||||
};
|
||||
module.exports = {
|
||||
existsSync: (p) => vfs.existsSync(p),
|
||||
mkdirSync: (p, o) => vfs.mkdirSync(p, o),
|
||||
statSync: (p) => vfs.statSync(p),
|
||||
promises,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// Scheduled jobs make no sense in a process that only lives while the app is on screen.
|
||||
// The one that matters — list auto-resets — is not cron-driven anyway: server.js recomputes
|
||||
// it inside getState() on every request, so it stays correct simply by being used.
|
||||
module.exports = {
|
||||
validate: () => true,
|
||||
schedule: () => ({ start() {}, stop() {}, destroy() {} }),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
// No SMTP on a phone. Returning a transporter that reports "not sent" keeps the digest and
|
||||
// incident-alert code paths running normally instead of throwing — server.js already handles
|
||||
// an unconfigured mailer gracefully, so this is the shape it expects.
|
||||
module.exports = {
|
||||
createTransport() {
|
||||
return {
|
||||
sendMail: async () => ({ messageId: 'local-mode-no-email', accepted: [], rejected: [] }),
|
||||
verify: async () => false,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
// server.js uses only join() and basename(). POSIX semantics are all that is needed —
|
||||
// the virtual filesystem has no drive letters and no backslashes.
|
||||
function join(...parts) {
|
||||
const joined = parts.filter((p) => p !== '' && p != null).join('/');
|
||||
const absolute = joined.charAt(0) === '/';
|
||||
const out = [];
|
||||
for (const seg of joined.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') { if (out.length && out[out.length - 1] !== '..') out.pop(); else if (!absolute) out.push('..'); continue; }
|
||||
out.push(seg);
|
||||
}
|
||||
const res = out.join('/');
|
||||
return absolute ? '/' + res : (res || '.');
|
||||
}
|
||||
function basename(p, ext) {
|
||||
let b = String(p).replace(/\/+$/, '').split('/').pop() || '';
|
||||
if (ext && b.endsWith(ext)) b = b.slice(0, -ext.length);
|
||||
return b;
|
||||
}
|
||||
function dirname(p) {
|
||||
const s = String(p).replace(/\/+$/, '');
|
||||
const i = s.lastIndexOf('/');
|
||||
return i <= 0 ? (i === 0 ? '/' : '.') : s.slice(0, i);
|
||||
}
|
||||
function extname(p) { const b = basename(p); const i = b.lastIndexOf('.'); return i <= 0 ? '' : b.slice(i); }
|
||||
module.exports = { join, basename, dirname, extname, sep: '/', posix: { join, basename, dirname, extname, sep: '/' } };
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
// vfs.js — an IndexedDB-backed filesystem good enough to run server.js unmodified.
|
||||
//
|
||||
// server.js keeps everything on disk: the state JSON, equipment photos, attached documents
|
||||
// and nightly backups. To run that same code inside the app with no server, those calls have
|
||||
// to land somewhere. IndexedDB is the right store here — it is app-private, has a far larger
|
||||
// quota than localStorage, and needs no Capacitor plugin.
|
||||
//
|
||||
// THE AWKWARD BIT: server.js uses existsSync / mkdirSync / statSync, which must return
|
||||
// *synchronously*, and IndexedDB is asynchronous to the core. So this keeps a full in-memory
|
||||
// index of metadata (path → size, mtime, isDir) and answers every sync call from that, while
|
||||
// contents live in IndexedDB and are read/written asynchronously. The index holds no file
|
||||
// bodies, so a few hundred photos cost kilobytes of RAM rather than megabytes.
|
||||
//
|
||||
// Writes update the index immediately and persist in the background; a write that is still
|
||||
// in flight is therefore already visible to existsSync, which matches how the real fs behaves
|
||||
// closely enough for server.js's purposes.
|
||||
|
||||
const DB_NAME = 'checklist-local';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'files';
|
||||
|
||||
let db = null;
|
||||
/** path -> { dir:boolean, size:number, mtimeMs:number } */
|
||||
const index = new Map();
|
||||
|
||||
function open() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const d = req.result;
|
||||
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function tx(mode) {
|
||||
return db.transaction(STORE, mode).objectStore(STORE);
|
||||
}
|
||||
|
||||
function idbGet(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = tx('readonly').get(key);
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbPut(key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = tx('readwrite').put(value, key);
|
||||
r.onsuccess = () => resolve();
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbDel(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = tx('readwrite').delete(key);
|
||||
r.onsuccess = () => resolve();
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Paths arrive from path.join() so they are already normalised; this only strips a trailing
|
||||
// slash so '/data/' and '/data' are the same key.
|
||||
function norm(p) {
|
||||
const s = String(p).replace(/\/+$/, '');
|
||||
return s === '' ? '/' : s;
|
||||
}
|
||||
|
||||
function sizeOf(content) {
|
||||
if (content == null) return 0;
|
||||
if (typeof content === 'string') return content.length;
|
||||
if (content.byteLength != null) return content.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const vfs = {
|
||||
async init() {
|
||||
db = await open();
|
||||
// Rebuild the metadata index. Keys and sizes only — never the bodies, or opening an app
|
||||
// with a few hundred photos would pull every one of them into memory at boot.
|
||||
const store = tx('readonly');
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = store.openCursor();
|
||||
req.onsuccess = () => {
|
||||
const cur = req.result;
|
||||
if (!cur) { resolve(); return; }
|
||||
const v = cur.value;
|
||||
index.set(String(cur.key), {
|
||||
dir: v && v.__dir === true,
|
||||
size: v && v.__dir ? 0 : sizeOf(v && v.content),
|
||||
mtimeMs: (v && v.mtimeMs) || Date.now(),
|
||||
});
|
||||
cur.continue();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return vfs;
|
||||
},
|
||||
|
||||
// ── sync surface ──────────────────────────────────────────────────────────────────
|
||||
existsSync(p) { return index.has(norm(p)); },
|
||||
|
||||
statSync(p) {
|
||||
const e = index.get(norm(p));
|
||||
if (!e) { const err = new Error(`ENOENT: no such file or directory, stat '${p}'`); err.code = 'ENOENT'; throw err; }
|
||||
return { size: e.size, mtimeMs: e.mtimeMs, isDirectory: () => e.dir, isFile: () => !e.dir };
|
||||
},
|
||||
|
||||
mkdirSync(p, opts) {
|
||||
const path = norm(p);
|
||||
if (index.has(path)) return;
|
||||
index.set(path, { dir: true, size: 0, mtimeMs: Date.now() });
|
||||
idbPut(path, { __dir: true, mtimeMs: Date.now() }).catch(() => {});
|
||||
if (opts && opts.recursive) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let acc = '';
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
acc += '/' + seg;
|
||||
if (!index.has(acc)) {
|
||||
index.set(acc, { dir: true, size: 0, mtimeMs: Date.now() });
|
||||
idbPut(acc, { __dir: true, mtimeMs: Date.now() }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── async surface ─────────────────────────────────────────────────────────────────
|
||||
async readFile(p, enc) {
|
||||
const path = norm(p);
|
||||
const rec = await idbGet(path);
|
||||
if (!rec || rec.__dir) { const e = new Error(`ENOENT: no such file or directory, open '${p}'`); e.code = 'ENOENT'; throw e; }
|
||||
const content = rec.content;
|
||||
// server.js reads the state file as utf8 and media as buffers; honour both.
|
||||
if (enc === 'utf8' || enc === 'utf-8' || (enc && enc.encoding)) {
|
||||
return typeof content === 'string' ? content : new TextDecoder().decode(content);
|
||||
}
|
||||
return typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
||||
},
|
||||
|
||||
async writeFile(p, data) {
|
||||
const path = norm(p);
|
||||
const mtimeMs = Date.now();
|
||||
await idbPut(path, { content: data, mtimeMs });
|
||||
index.set(path, { dir: false, size: sizeOf(data), mtimeMs });
|
||||
},
|
||||
|
||||
async mkdir(p, opts) { vfs.mkdirSync(p, opts); },
|
||||
|
||||
async readdir(p) {
|
||||
const dir = norm(p);
|
||||
const prefix = dir === '/' ? '/' : dir + '/';
|
||||
const out = [];
|
||||
for (const key of index.keys()) {
|
||||
if (key === dir || !key.startsWith(prefix)) continue;
|
||||
const rest = key.slice(prefix.length);
|
||||
if (rest.indexOf('/') !== -1) continue; // immediate children only
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async stat(p) { return vfs.statSync(p); },
|
||||
|
||||
async unlink(p) {
|
||||
const path = norm(p);
|
||||
await idbDel(path);
|
||||
index.delete(path);
|
||||
},
|
||||
|
||||
async rename(from, to) {
|
||||
const src = norm(from);
|
||||
const rec = await idbGet(src);
|
||||
if (!rec) { const e = new Error(`ENOENT: no such file or directory, rename '${from}'`); e.code = 'ENOENT'; throw e; }
|
||||
await idbPut(norm(to), rec);
|
||||
index.set(norm(to), { dir: !!rec.__dir, size: sizeOf(rec.content), mtimeMs: Date.now() });
|
||||
await idbDel(src);
|
||||
index.delete(src);
|
||||
},
|
||||
|
||||
async copyFile(from, to) {
|
||||
const rec = await idbGet(norm(from));
|
||||
if (!rec) { const e = new Error(`ENOENT: no such file or directory, copyfile '${from}'`); e.code = 'ENOENT'; throw e; }
|
||||
await idbPut(norm(to), rec);
|
||||
index.set(norm(to), { dir: !!rec.__dir, size: sizeOf(rec.content), mtimeMs: Date.now() });
|
||||
},
|
||||
|
||||
// ── helpers used by the local runtime, not by server.js ───────────────────────────
|
||||
/** Raw record fetch, for serving media to <img> without going through fs semantics. */
|
||||
async raw(p) { const rec = await idbGet(norm(p)); return rec && !rec.__dir ? rec.content : null; },
|
||||
|
||||
/** Every stored path — used to build blob URLs for locally stored photos. */
|
||||
keys() { return Array.from(index.keys()); },
|
||||
|
||||
async wipe() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = tx('readwrite').clear();
|
||||
r.onsuccess = () => resolve(); r.onerror = () => reject(r.error);
|
||||
});
|
||||
index.clear();
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = vfs;
|
||||
@@ -0,0 +1,164 @@
|
||||
// native-shell.js — the iOS/Android-only layer. Bundled by esbuild into www/native-shell.js
|
||||
// and injected ONLY into the app bundles, never served to the website.
|
||||
//
|
||||
// Two jobs:
|
||||
// 1. Publish the Capacitor plugins on window.CapPlugins, synchronously, before
|
||||
// native-bridge.js and app.js parse. That's why this is bundled as an IIFE and loaded
|
||||
// as a classic <script> — the web app has no bundler and no module graph to join.
|
||||
// 2. Fill the gaps between "a web page in a WebView" and "an app": splash handover,
|
||||
// status bar, Android's hardware Back button, foreground resume, and a trustworthy
|
||||
// online/offline signal.
|
||||
//
|
||||
// Everything here is additive. The shared web app (server/public/) knows nothing about it
|
||||
// beyond two optional hooks: window.CapPlugins and the 'checklist:resume' event.
|
||||
|
||||
import { App } from '@capacitor/app';
|
||||
import { Browser } from '@capacitor/browser';
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
import { Keyboard } from '@capacitor/keyboard';
|
||||
import { Network } from '@capacitor/network';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
|
||||
// Published before anything else runs — native-bridge.js reads window.CapPlugins at parse
|
||||
// time to route navigator.vibrate() and external links.
|
||||
window.CapPlugins = { App, Browser, Haptics, ImpactStyle, Keyboard, Network, SplashScreen, StatusBar, Style };
|
||||
|
||||
const isNative = window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform();
|
||||
if (isNative) {
|
||||
const platform = window.Capacitor.getPlatform();
|
||||
|
||||
// ── Status bar ───────────────────────────────────────────────────────────────────────
|
||||
// The app is dark-themed (#1d2026 header); Style.Dark means light glyphs for a dark
|
||||
// background. Failures here are cosmetic, so each call swallows its own error.
|
||||
StatusBar.setStyle({ style: Style.Dark }).catch(() => {});
|
||||
if (platform === 'android') {
|
||||
StatusBar.setBackgroundColor({ color: '#1d2026' }).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Splash handover ──────────────────────────────────────────────────────────────────
|
||||
// launchAutoHide is false in capacitor.config.json so the splash covers the whole boot
|
||||
// (auth check + first /api/state) instead of flashing an empty shell. The handover point
|
||||
// is app.js's hideLoading(), which sets #loadingOverlay to display:none once the
|
||||
// checklist — or the login sheet — is actually on screen.
|
||||
//
|
||||
// A failsafe timer matters more than the observer: if boot() throws, or the server is
|
||||
// unreachable, #loadingOverlay never hides and without this the app is a dead splash
|
||||
// screen with no way forward. Always reveal the UI so the user can see the real error.
|
||||
let splashDone = false;
|
||||
const hideSplash = () => {
|
||||
if (splashDone) return;
|
||||
splashDone = true;
|
||||
SplashScreen.hide({ fadeOutDuration: 200 }).catch(() => {});
|
||||
};
|
||||
const watchBoot = () => {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (!overlay) { hideSplash(); return; }
|
||||
if (getComputedStyle(overlay).display === 'none') { hideSplash(); return; }
|
||||
const obs = new MutationObserver(() => {
|
||||
if (getComputedStyle(overlay).display === 'none') { obs.disconnect(); hideSplash(); }
|
||||
});
|
||||
obs.observe(overlay, { attributes: true, attributeFilter: ['style', 'class'] });
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watchBoot);
|
||||
else watchBoot();
|
||||
setTimeout(hideSplash, 8000); // failsafe: never strand the user on the splash
|
||||
|
||||
// ── Android hardware Back ────────────────────────────────────────────────────────────
|
||||
// Unhandled, Back closes the whole app from anywhere — including with a half-filled task
|
||||
// dialog open, which reads as a crash. Close the topmost thing instead, and only
|
||||
// background the app once nothing is open. minimizeApp (not exitApp) is the Android
|
||||
// convention: the app stays warm in the recents list.
|
||||
App.addListener('backButton', () => {
|
||||
if (closeTopmostLayer()) return;
|
||||
App.minimizeApp().catch(() => {});
|
||||
});
|
||||
|
||||
// Deliberately NOT an offsetParent check. Every dialog here is `position: fixed`
|
||||
// (.modal-overlay { position: fixed; inset: 0 }), and a fixed element's offsetParent is
|
||||
// always null — so that test rejected every open dialog and Back closed the whole app
|
||||
// instead. A bounding rect is the check that actually works for fixed positioning.
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
|
||||
function closeTopmostLayer() {
|
||||
// Popover menus first — they sit above the dialogs that opened them.
|
||||
const menu = document.querySelector('.list-menu.open, .move-menu.open');
|
||||
if (menu) { menu.classList.remove('open'); return true; }
|
||||
|
||||
// Then transient full-screen layers that own their own close path.
|
||||
for (const sel of ['.search-overlay', '.kb-cheat.open', '.firstrun']) {
|
||||
const layer = document.querySelector(sel);
|
||||
if (isVisible(layer)) { document.dispatchEvent(escapeKey()); return true; }
|
||||
}
|
||||
|
||||
// Then the topmost visible dialog. Later in the DOM wins ties, matching the app's
|
||||
// own stacking (equipCamModal is explicitly z-index:10002 and declared last).
|
||||
const dialogs = [...document.querySelectorAll('.modal-overlay')].filter(isVisible);
|
||||
if (!dialogs.length) return false;
|
||||
const top = dialogs.reduce((a, b) => {
|
||||
const za = parseInt(getComputedStyle(a).zIndex, 10) || 0;
|
||||
const zb = parseInt(getComputedStyle(b).zIndex, 10) || 0;
|
||||
return zb >= za ? b : a;
|
||||
});
|
||||
|
||||
// Click the dialog's own dismiss control rather than hiding the node, so app.js runs
|
||||
// its normal teardown (drafts, edit state, animation flags) exactly as if the user had
|
||||
// tapped Cancel. Never fall back to the primary/save button.
|
||||
const buttons = [...top.querySelectorAll('button')];
|
||||
const byLabel = buttons.find((b) => /^(cancel|close|done|back|not now|later)$/i.test((b.textContent || '').trim()));
|
||||
const bySecondary = buttons.find((b) => b.classList.contains('btn-secondary'));
|
||||
const target = byLabel || bySecondary;
|
||||
if (target) { target.click(); return true; }
|
||||
|
||||
// A dialog with no dismiss control (e.g. the forced login sheet) is a deliberate
|
||||
// dead end — let Back background the app rather than trapping the user in it.
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapeKey() {
|
||||
return new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true });
|
||||
}
|
||||
|
||||
// ── Foreground resume ────────────────────────────────────────────────────────────────
|
||||
// A backgrounded WebView has its timers suspended, so app.js's 6s poll is frozen while
|
||||
// the phone is in someone's pocket. Without this, reopening the app shows stale
|
||||
// check-offs for up to 6 seconds — long enough for two volunteers to duplicate work.
|
||||
App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) window.dispatchEvent(new Event('checklist:resume'));
|
||||
});
|
||||
|
||||
// ── Online/offline ───────────────────────────────────────────────────────────────────
|
||||
// app.js queues check-offs in localStorage when offline and flushes them on the window
|
||||
// 'online' event. In a WKWebView navigator.onLine is unreliable — it can report true on
|
||||
// a captive portal and miss transitions entirely — which strands the queue. The native
|
||||
// Network plugin is authoritative, so mirror it onto both the property and the events
|
||||
// the app already listens for.
|
||||
Network.addListener('networkStatusChange', ({ connected }) => {
|
||||
try {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => connected });
|
||||
} catch (_) {}
|
||||
window.dispatchEvent(new Event(connected ? 'online' : 'offline'));
|
||||
});
|
||||
Network.getStatus().then(({ connected }) => {
|
||||
try {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => connected });
|
||||
} catch (_) {}
|
||||
if (!connected) window.dispatchEvent(new Event('offline'));
|
||||
}).catch(() => {});
|
||||
|
||||
// ── Keyboard ─────────────────────────────────────────────────────────────────────────
|
||||
// Scroll the focused field into view once the keyboard's height is known. iOS does this
|
||||
// for plain inputs but misses fields inside the app's scrollable dialog bodies.
|
||||
Keyboard.addListener('keyboardDidShow', () => {
|
||||
const el = document.activeElement;
|
||||
if (el && typeof el.scrollIntoView === 'function') {
|
||||
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user