Compare commits
No commits in common. "dev" and "main" have entirely different histories.
|
|
@ -13,16 +13,4 @@ Thumbs.db
|
|||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.code-workspace
|
||||
|
||||
# Build output
|
||||
build-output/
|
||||
|
||||
# Private project/agent docs — never commit
|
||||
PROJECT.md
|
||||
PLAN.md
|
||||
FUTURE.md
|
||||
HISTORY.md
|
||||
DEVELOPMENT_LOG.md
|
||||
.learnings/
|
||||
.idea/
|
||||
|
|
@ -11,19 +11,6 @@ const config: CapacitorConfig = {
|
|||
server: {
|
||||
// androidScheme must be https for cookies to work correctly
|
||||
androidScheme: 'https',
|
||||
// In local mode, the WebView navigates to the embedded Node server on
|
||||
// 127.0.0.1 — without this it's treated as an external link and opened
|
||||
// in the system browser instead of the app's WebView.
|
||||
allowNavigation: ['127.0.0.1'],
|
||||
},
|
||||
plugins: {
|
||||
CapacitorHttp: {
|
||||
// Routes fetch()/XHR through native HTTP so the server-mode login
|
||||
// request isn't blocked by CORS, and the resulting session cookie is
|
||||
// written to the WebView's cookie store for the subsequent WebView
|
||||
// navigation to the server URL.
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// better-sqlite3's better_sqlite3.node is architecture-specific (it links
|
||||
// against libnode.so, which is the real Android binary already loaded into
|
||||
// this process by nodejs-mobile-cordova). The copy under node_modules/ is
|
||||
// whichever architecture it happened to be built for last; replace it with
|
||||
// the prebuild matching this device's actual arch before anything requires
|
||||
// better-sqlite3. See scripts/build-better-sqlite3-node18.sh.
|
||||
(function installBetterSqlite3Prebuild() {
|
||||
const archDirs = {
|
||||
arm64: 'arm64-v8a',
|
||||
arm: 'armeabi-v7a',
|
||||
x64: 'x86_64',
|
||||
ia32: 'x86',
|
||||
};
|
||||
const archDir = archDirs[process.arch];
|
||||
if (!archDir) return; // Unknown arch — fall back to whatever's bundled.
|
||||
|
||||
const prebuilt = path.join(__dirname, 'prebuilds', archDir, 'better_sqlite3.node');
|
||||
const target = path.join(__dirname, 'node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node');
|
||||
if (!fs.existsSync(prebuilt)) return;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(prebuilt, target);
|
||||
} catch (err) {
|
||||
console.error('[main.js] Failed to install better-sqlite3 prebuild for', process.arch, err);
|
||||
}
|
||||
})();
|
||||
|
||||
// nodejs-mobile's Node 18.20.4 build ships WITHOUT ICU (verified:
|
||||
// process.versions.icu === undefined, typeof Intl === 'undefined'), so `Intl`
|
||||
// is entirely absent. Polyfill just the bits the server uses — en-US number
|
||||
// formatting with thousands separators, and a no-op DateTimeFormat whose
|
||||
// resolvedOptions().timeZone the callers already treat as optional.
|
||||
//
|
||||
// (crypto.randomUUID and crypto.hkdfSync are NATIVE on Node 18 — the Node-12
|
||||
// shims for them are gone. See PLAN/FUTURE Batch 6.)
|
||||
if (typeof global.Intl === 'undefined') {
|
||||
global.Intl = {
|
||||
NumberFormat: function (_locale, options) {
|
||||
const opts = options || {};
|
||||
const minFrac = opts.minimumFractionDigits || 0;
|
||||
const maxFrac = Math.max(opts.maximumFractionDigits || 0, minFrac);
|
||||
this.format = function (value) {
|
||||
const fixed = Number(value).toFixed(maxFrac);
|
||||
let [intPart, fracPart] = fixed.split('.');
|
||||
const negative = intPart.startsWith('-');
|
||||
if (negative) intPart = intPart.slice(1);
|
||||
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
let out = (negative ? '-' : '') + intPart;
|
||||
if (fracPart) out += '.' + fracPart;
|
||||
return out;
|
||||
};
|
||||
},
|
||||
DateTimeFormat: function () {
|
||||
this.resolvedOptions = function () {
|
||||
return { timeZone: undefined };
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Server-side paths. CRITICAL: keep the database, backups, and temp OUTSIDE the
|
||||
// nodejs-project directory. nodejs-mobile-cordova re-copies nodejs-project from
|
||||
// the APK assets on every app update, which would wipe anything stored inside it
|
||||
// (a silent data-loss bug). __dirname is <filesDir>/www/nodejs-project, so two
|
||||
// levels up is the app-private filesDir, which persists across updates.
|
||||
const DATA_ROOT = path.join(__dirname, '..', '..', 'bt-data');
|
||||
process.env.DB_PATH = path.join(DATA_ROOT, 'db', 'bills.db');
|
||||
process.env.BACKUP_PATH = path.join(DATA_ROOT, 'backups');
|
||||
// Android has no /tmp; point os.tmpdir() at app-writable storage so the daily
|
||||
// worker's temp-export cleanup doesn't error (ENOENT scandir '/tmp').
|
||||
process.env.TMPDIR = path.join(DATA_ROOT, 'tmp');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(process.env.DB_PATH), { recursive: true });
|
||||
fs.mkdirSync(process.env.BACKUP_PATH, { recursive: true });
|
||||
fs.mkdirSync(process.env.TMPDIR, { recursive: true });
|
||||
} catch (_) { /* best effort — database.cts also ensures the DB dir */ }
|
||||
process.env.PORT = process.env.PORT || '3000';
|
||||
process.env.BIND_HOST = '127.0.0.1';
|
||||
// The Capacitor WebView's origin (androidScheme: 'https') is cross-origin
|
||||
// from this server's http://localhost:3000, so the LoadingScreen's
|
||||
// /api/health poll and the app's API calls need CORS allowed for it.
|
||||
process.env.CORS_ORIGIN = 'https://localhost';
|
||||
|
||||
const cordova = require('cordova-bridge');
|
||||
|
||||
// Surface fatal errors to logcat and to the WebView (LoadingScreen) instead of
|
||||
// dying silently behind an endless "Starting…" spinner.
|
||||
function reportError(where, err) {
|
||||
const msg = err && err.stack ? err.stack : String(err);
|
||||
console.error('[main.js] ' + where + ':', msg);
|
||||
try {
|
||||
cordova.channel.post('message', { type: 'serverError', message: String((err && err.message) || err) });
|
||||
} catch (_) { /* channel may not be ready */ }
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => reportError('uncaughtException', err));
|
||||
process.on('unhandledRejection', (err) => reportError('unhandledRejection', err));
|
||||
|
||||
// Wait for the WebView to hand over the device-bound encryption key (stored in
|
||||
// Android Keystore / iOS Keychain — see src/crypto.ts) before starting the
|
||||
// server, so encryptionService picks up TOKEN_ENCRYPTION_KEY on first use.
|
||||
cordova.channel.on('message', function (msg) {
|
||||
if (msg && msg.type === 'encryptionKey' && typeof msg.key === 'string') {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = msg.key;
|
||||
try {
|
||||
require('./server/server.cts');
|
||||
} catch (err) {
|
||||
reportError('server failed to start', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cordova.channel.post('message', { type: 'ready' });
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "nodejs-project",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"//": "Server-side runtime deps only (client deps ship prebuilt in dist/). Versions mirror ../../../bill-tracker. openid-client is intentionally omitted — it is ESM-only and unused in single-user local mode; scripts/prepare-local-mode-deps.js installs a CJS stub. kysely is ESM-only and gets bundled to CJS by the same script.",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"kysely": "^0.29.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^13.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodejs-mobile-gyp": "^0.4.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -8,13 +8,11 @@
|
|||
"name": "bill-tracker-mobile",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-biometric-auth": "^10.0.0",
|
||||
"@capacitor/android": "^8.4.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.0",
|
||||
"@capacitor/ios": "^8.4.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"nodejs-mobile-cordova": "^0.4.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
|
@ -24,26 +22,10 @@
|
|||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aparajita/capacitor-biometric-auth": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@aparajita/capacitor-biometric-auth/-/capacitor-biometric-auth-10.0.0.tgz",
|
||||
"integrity": "sha512-azjWaRucB8x1/gSzSTp/NxelaOZyg+m765gSzjwUkSQgg30RrZPmPq6pyLeeXVSZNDY3Rxf5Hj8obgZaXirCFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.2",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.2",
|
||||
"@capacitor/ios": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz",
|
||||
|
|
@ -156,448 +138,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/cli-framework-output": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
|
||||
|
|
@ -1435,15 +975,6 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/capacitor-secure-storage-plugin": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
|
||||
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
|
|
@ -1655,48 +1186,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -12,13 +12,11 @@
|
|||
"android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-biometric-auth": "^10.0.0",
|
||||
"@capacitor/android": "^8.4.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.0",
|
||||
"@capacitor/ios": "^8.4.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"nodejs-mobile-cordova": "^0.4.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
|
@ -28,8 +26,8 @@
|
|||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Cross-compiles better-sqlite3 for the embedded Node 18.20.4 (nodejs-mobile) ABI
|
||||
# for all three Android ABIs and writes the resulting better_sqlite3.node binaries
|
||||
# into nodejs-assets/nodejs-project/prebuilds/<abi>/. main.js installs the one
|
||||
# matching the device's actual process.arch at runtime — see
|
||||
# installBetterSqlite3Prebuild().
|
||||
#
|
||||
# Unlike the old Node-12 build, NO binder.cpp patch is needed: Node 18's V8 10.2
|
||||
# still exposes the (deprecated) v8::Object::CreationContext() that better-sqlite3
|
||||
# 12.x uses, so it compiles as-is (deprecation warning only).
|
||||
#
|
||||
# Requires: Android NDK (ANDROID_HOME or ANDROID_NDK_HOME set), the Node 18 runtime
|
||||
# staged into the plugin (scripts/install-node18-runtime.sh), and `npm install`
|
||||
# already run in nodejs-assets/nodejs-project.
|
||||
#
|
||||
# Usage: ./scripts/build-better-sqlite3-node18.sh [abi ...] (default: all three)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_TARGET="18.20.4"
|
||||
API=24 # matches android/variables.gradle minSdkVersion
|
||||
|
||||
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PKG_SRC="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
|
||||
PREBUILDS_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds"
|
||||
GYP="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/.bin/nodejs-mobile-gyp"
|
||||
NODEDIR="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/libs/android/libnode"
|
||||
|
||||
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||
if [[ -z "$NDK" ]]; then
|
||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
[[ -n "$SDK" ]] && NDK="$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1)"
|
||||
fi
|
||||
NDK="${NDK%/}"
|
||||
|
||||
[[ -d "$PKG_SRC" ]] || { echo "Error: $PKG_SRC not found. Run npm install in nodejs-project first." >&2; exit 1; }
|
||||
[[ -n "$NDK" && -d "$NDK" ]] || { echo "Error: Android NDK not found (set ANDROID_NDK_HOME or ANDROID_HOME)." >&2; exit 1; }
|
||||
[[ -x "$GYP" ]] || { echo "Error: $GYP not found. Run npm install in nodejs-project first." >&2; exit 1; }
|
||||
grep -q "18" "$NODEDIR/include/node/node_version.h" || { echo "Error: $NODEDIR is not the Node 18 runtime — run scripts/install-node18-runtime.sh first." >&2; exit 1; }
|
||||
|
||||
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64"
|
||||
|
||||
abi_arch() { case "$1" in arm64-v8a) echo arm64;; armeabi-v7a) echo arm;; x86_64) echo x64;; *) echo "" ;; esac; }
|
||||
abi_cc() { case "$1" in
|
||||
arm64-v8a) echo "aarch64-linux-android${API}-clang" ;;
|
||||
armeabi-v7a) echo "armv7a-linux-androideabi${API}-clang" ;;
|
||||
x86_64) echo "x86_64-linux-android${API}-clang" ;;
|
||||
esac; }
|
||||
|
||||
build_one() {
|
||||
local abi="$1"
|
||||
local gyp_arch cc cxx
|
||||
gyp_arch="$(abi_arch "$abi")"; cc="$(abi_cc "$abi")"; cxx="${cc}++"
|
||||
[[ -n "$gyp_arch" ]] || { echo "Unknown ABI: $abi" >&2; exit 1; }
|
||||
|
||||
# better-sqlite3's binding.gyp (via nodejs-mobile's common.gypi) needs the real
|
||||
# libnode.so (not the .gz) to add it as a NEEDED entry at link time.
|
||||
local libnode_dir="$NODEDIR/bin/$abi"
|
||||
if [[ ! -f "$libnode_dir/libnode.so" && -f "$libnode_dir/libnode.so.gz" ]]; then
|
||||
gunzip -k "$libnode_dir/libnode.so.gz"
|
||||
fi
|
||||
|
||||
local build_dir="$MOBILE_DIR/build-output/better-sqlite3-$abi"
|
||||
echo "── Building better-sqlite3 for $abi ($gyp_arch) against Node $NODE_TARGET ──"
|
||||
rm -rf "$build_dir"; mkdir -p "$build_dir"
|
||||
rsync -a --exclude=build --exclude=prebuilds "$PKG_SRC/" "$build_dir/"
|
||||
|
||||
(
|
||||
cd "$build_dir"
|
||||
CC="$TOOLCHAIN/bin/$cc" CXX="$TOOLCHAIN/bin/$cxx" AR="$TOOLCHAIN/bin/llvm-ar" \
|
||||
GYP_DEFINES="OS=android" "$GYP" configure \
|
||||
--target="$NODE_TARGET" --arch="$gyp_arch" --nodedir="$NODEDIR"
|
||||
|
||||
# nodejs-mobile's common.gypi only adds libnode.so as a NEEDED entry when
|
||||
# target_arch=="x86_64", but gyp reports "x64" — so the rule never fires and
|
||||
# the addon can't resolve V8/Node symbols (e.g. v8::Isolate::GetCurrent) at
|
||||
# dlopen time on Android. Inject the ABI's libnode.so into the link so its
|
||||
# SONAME (libnode.so) is recorded as NEEDED. (patchelf isn't required.)
|
||||
local mk="build/better_sqlite3.target.mk"
|
||||
if [[ -f "$mk" ]] && ! grep -q 'libnode\.so' "$mk"; then
|
||||
sed -i "s#^\([[:space:]]*\)-llog\$#\1-llog $libnode_dir/libnode.so#" "$mk"
|
||||
grep -q 'libnode\.so' "$mk" || { echo "Error: failed to inject libnode.so into $mk" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
CC="$TOOLCHAIN/bin/$cc" CXX="$TOOLCHAIN/bin/$cxx" AR="$TOOLCHAIN/bin/llvm-ar" \
|
||||
make -C build BUILDTYPE=Release
|
||||
)
|
||||
|
||||
mkdir -p "$PREBUILDS_DIR/$abi"
|
||||
cp "$build_dir/build/Release/better_sqlite3.node" "$PREBUILDS_DIR/$abi/better_sqlite3.node"
|
||||
echo "Wrote $PREBUILDS_DIR/$abi/better_sqlite3.node"
|
||||
}
|
||||
|
||||
ABIS=("$@")
|
||||
[[ ${#ABIS[@]} -eq 0 ]] && ABIS=(x86_64 arm64-v8a armeabi-v7a)
|
||||
for abi in "${ABIS[@]}"; do build_one "$abi"; done
|
||||
|
||||
echo "Done. Verify with: file $PREBUILDS_DIR/*/better_sqlite3.node"
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Batch 6 — swap the embedded runtime to Node 18.20.4.
|
||||
#
|
||||
# The published nodejs-mobile-cordova (0.4.3) and its community fork's `unstable`
|
||||
# branch both still ship Node 12.19.0 for Android. Node 18 for Android exists only
|
||||
# in the nodejs-mobile CORE release (github.com/nodejs-mobile/nodejs-mobile). This
|
||||
# script stages that core runtime into the installed plugin so the existing native
|
||||
# glue (native-lib.cpp calls node::Start, cordova-bridge is N-API) rebuilds against
|
||||
# Node 18 — verified: node::Start, AddLinkedBinding, and the N-API surface are all
|
||||
# present in the v18 libnode, and the SONAME is unchanged (libnode.so).
|
||||
#
|
||||
# node_modules/ is regenerated by `npm install`, so RE-RUN this after any install
|
||||
# (same pattern as fix-better-sqlite3-android.sh). Run from the mobile project root.
|
||||
#
|
||||
# Node 18 core ships arm64-v8a / armeabi-v7a / x86_64 only — there is no 32-bit x86
|
||||
# build, so the plugin's stale v12 x86 libnode is left untouched (x86 should be
|
||||
# dropped from abiFilters + prepare-android-assets.js in Batch 7).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_MOBILE_VERSION="18.20.4"
|
||||
ZIP_NAME="nodejs-mobile-v${NODE_MOBILE_VERSION}-android.zip"
|
||||
ZIP_URL="https://github.com/nodejs-mobile/nodejs-mobile/releases/download/v${NODE_MOBILE_VERSION}/${ZIP_NAME}"
|
||||
ABIS=(arm64-v8a armeabi-v7a x86_64)
|
||||
|
||||
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PLUGIN_LIBNODE="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/libs/android/libnode"
|
||||
CACHE_DIR="$MOBILE_DIR/build-output/nodejs-mobile-runtime" # gitignored
|
||||
EXTRACT_DIR="$CACHE_DIR/nm18-android"
|
||||
|
||||
if [[ ! -d "$PLUGIN_LIBNODE" ]]; then
|
||||
echo "Error: nodejs-mobile-cordova not installed at $PLUGIN_LIBNODE" >&2
|
||||
echo "Run 'npm install' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
# 1. Fetch the core Android runtime (cached).
|
||||
if [[ ! -f "$CACHE_DIR/$ZIP_NAME" ]]; then
|
||||
echo "Downloading $ZIP_NAME (~57 MB)…"
|
||||
curl -fL --retry 3 -o "$CACHE_DIR/$ZIP_NAME" "$ZIP_URL"
|
||||
fi
|
||||
|
||||
rm -rf "$EXTRACT_DIR"
|
||||
mkdir -p "$EXTRACT_DIR"
|
||||
unzip -q -o "$CACHE_DIR/$ZIP_NAME" -d "$EXTRACT_DIR"
|
||||
|
||||
# 2. Swap libnode.so per ABI (plugin ships them gzipped; hook gunzips at build time).
|
||||
for abi in "${ABIS[@]}"; do
|
||||
src="$EXTRACT_DIR/bin/$abi/libnode.so"
|
||||
destdir="$PLUGIN_LIBNODE/bin/$abi"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: missing $src in the core archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$destdir"
|
||||
gzip -c "$src" > "$destdir/libnode.so.gz"
|
||||
echo " staged libnode.so.gz -> bin/$abi ($(du -h "$destdir/libnode.so.gz" | cut -f1))"
|
||||
done
|
||||
|
||||
# 3. Swap headers (used to compile native-lib.cpp and native modules, e.g.
|
||||
# better-sqlite3 in Batch 8).
|
||||
rm -rf "$PLUGIN_LIBNODE/include/node"
|
||||
mkdir -p "$PLUGIN_LIBNODE/include"
|
||||
cp -R "$EXTRACT_DIR/include/node" "$PLUGIN_LIBNODE/include/node"
|
||||
|
||||
# 3.5 Patch the cordova-bridge linked-binding registration for Node 18.
|
||||
#
|
||||
# Node 18's NAPI_MODULE_X macro drops its priv/flags args and expands to the
|
||||
# symbol-based NAPI_MODULE registration, which Node only consumes when it
|
||||
# dlopen()s a .node file. cordova-bridge is compiled INTO the app's native lib,
|
||||
# so it was never registered and process._linkedBinding('cordova_bridge') failed
|
||||
# with "No such binding was linked". Register the linked binding explicitly via
|
||||
# the still-supported napi_module_register path (routes NM_F_LINKED modules into
|
||||
# the global modlist_linked at static-init, before node::Start). Idempotent.
|
||||
BRIDGE_CPP="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/src/common/cordova-bridge/cordova-bridge.cpp"
|
||||
if [[ -f "$BRIDGE_CPP" ]]; then
|
||||
python3 - "$BRIDGE_CPP" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
src = open(p).read()
|
||||
old = "NAPI_MODULE_X(cordova_bridge, Init, NULL, NM_F_LINKED)"
|
||||
new = """static napi_module _cordova_bridge_module = {
|
||||
NAPI_MODULE_VERSION,
|
||||
NM_F_LINKED,
|
||||
__FILE__,
|
||||
Init,
|
||||
"cordova_bridge",
|
||||
NULL,
|
||||
{0, 0, 0, 0},
|
||||
};
|
||||
__attribute__((constructor)) static void _register_cordova_bridge(void) {
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
napi_module_register(&_cordova_bridge_module);
|
||||
#pragma GCC diagnostic pop
|
||||
}"""
|
||||
if "_register_cordova_bridge" in src:
|
||||
print(" cordova-bridge.cpp already patched for Node 18 linked binding")
|
||||
elif old in src:
|
||||
open(p, "w").write(src.replace(old, new))
|
||||
print(" patched cordova-bridge.cpp linked-binding registration for Node 18")
|
||||
else:
|
||||
sys.exit(" ERROR: cordova-bridge.cpp registration line not found — plugin changed?")
|
||||
PY
|
||||
fi
|
||||
|
||||
# 4. Confirm.
|
||||
ver_h="$PLUGIN_LIBNODE/include/node/node_version.h"
|
||||
maj=$(grep -E 'define NODE_MAJOR_VERSION' "$ver_h" | awk '{print $3}')
|
||||
min=$(grep -E 'define NODE_MINOR_VERSION' "$ver_h" | awk '{print $3}')
|
||||
pat=$(grep -E 'define NODE_PATCH_VERSION' "$ver_h" | awk '{print $3}')
|
||||
echo "Done. Plugin libnode headers now report Node ${maj}.${min}.${pat}."
|
||||
echo "Next: rebuild the app (npx cap sync android + gradle) and boot main.js to confirm process.version."
|
||||
|
|
@ -87,39 +87,6 @@ if (fs.existsSync(nodeJsJava)) {
|
|||
}
|
||||
}
|
||||
|
||||
// Local mode runs the embedded Node server on http://127.0.0.1:3000, which
|
||||
// Android's Network Security Config blocks by default (cleartext traffic).
|
||||
// `cap sync`/`cap add android` don't know about this, so (re)write the config
|
||||
// and wire it into AndroidManifest.xml's <application> tag each time.
|
||||
const networkSecurityConfigXml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Local mode runs an embedded Node.js server on 127.0.0.1; the WebView
|
||||
(https://localhost) needs to reach it over plain HTTP. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
`;
|
||||
const networkSecurityConfigPath = path.join(
|
||||
MOBILE_DIR, 'android', 'app', 'src', 'main', 'res', 'xml', 'network_security_config.xml'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(networkSecurityConfigPath), { recursive: true });
|
||||
fs.writeFileSync(networkSecurityConfigPath, networkSecurityConfigXml);
|
||||
|
||||
const manifestPath = path.join(MOBILE_DIR, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const src = fs.readFileSync(manifestPath, 'utf8');
|
||||
if (!src.includes('android:networkSecurityConfig')) {
|
||||
const patched = src.replace(
|
||||
/(<application\b)/,
|
||||
'$1\n android:networkSecurityConfig="@xml/network_security_config"'
|
||||
);
|
||||
fs.writeFileSync(manifestPath, patched);
|
||||
console.log(`Patched ${path.relative(MOBILE_DIR, manifestPath)} (added networkSecurityConfig)`);
|
||||
}
|
||||
}
|
||||
|
||||
// nodejs-mobile-cordova's build.gradle sets up a CMake build with
|
||||
// `cmake.path = "libs/cdvnodejsmobile/CMakeLists.txt"` (relative to each
|
||||
// module's projectDir) for both `app` and `capacitor-cordova-android-plugins`.
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Makes the two ESM-only server dependencies loadable by the embedded Node 18
|
||||
// (nodejs-mobile) runtime, which cannot require() ESM. Run AFTER
|
||||
// `npm install` inside nodejs-assets/nodejs-project (re-run after any install).
|
||||
//
|
||||
// • kysely (used: routes/categories.cts -> db/kysely.cts) — bundled to a
|
||||
// self-contained CommonJS file; its package entry is repointed at it.
|
||||
// • openid-client (UNUSED in single-user local mode, but eagerly require()'d
|
||||
// at boot via auth.cts/authOidc.cts -> oidcService.cts) — replaced with a
|
||||
// CommonJS stub so boot doesn't crash. Any actual use throws.
|
||||
//
|
||||
// Both are loaded on the web app (Node 22, native ESM); this only affects the
|
||||
// on-device copy.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const esbuild = require('esbuild'); // from the mobile project's own node_modules
|
||||
|
||||
const PROJECT = path.join(__dirname, '..', 'nodejs-assets', 'nodejs-project');
|
||||
const NM = path.join(PROJECT, 'node_modules');
|
||||
|
||||
function fail(msg) {
|
||||
console.error('prepare-local-mode-deps: ' + msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── kysely → CommonJS ────────────────────────────────────────────────────────
|
||||
(function bundleKysely() {
|
||||
const dir = path.join(NM, 'kysely');
|
||||
if (!fs.existsSync(dir)) fail('kysely not installed — run `npm install` in nodejs-project first.');
|
||||
|
||||
const entry = path.join(dir, 'dist', 'index.js'); // ESM entry
|
||||
const outfile = path.join(dir, 'dist', 'index.cjs');
|
||||
esbuild.buildSync({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
const pkgPath = path.join(dir, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.main = './dist/index.cjs';
|
||||
pkg.exports = {
|
||||
'.': { require: './dist/index.cjs', import: './dist/index.js', default: './dist/index.cjs' },
|
||||
'./package.json': './package.json',
|
||||
};
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(' kysely -> bundled to CommonJS (dist/index.cjs)');
|
||||
})();
|
||||
|
||||
// ── openid-client → CommonJS stub ────────────────────────────────────────────
|
||||
(function stubOpenidClient() {
|
||||
const dir = path.join(NM, 'openid-client');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'package.json'),
|
||||
JSON.stringify({ name: 'openid-client', version: '0.0.0-local-stub', main: 'index.js' }, null, 2) + '\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'index.js'),
|
||||
"'use strict';\n" +
|
||||
'// Local-mode stub. OIDC/SSO is not available when running on-device.\n' +
|
||||
'function notSupported() { throw new Error("OIDC is not available in local mode"); }\n' +
|
||||
'module.exports = new Proxy({}, { get: function () { return notSupported; } });\n',
|
||||
);
|
||||
console.log(' openid-client -> CommonJS stub (OIDC disabled in local mode)');
|
||||
})();
|
||||
|
||||
// ── ICU-less \p{} regex compatibility ────────────────────────────────────────
|
||||
// nodejs-mobile's Node 18 ships WITHOUT ICU, so V8 cannot resolve Unicode
|
||||
// property escapes (\p{...}) — any regex using them throws "Invalid property
|
||||
// name" at compile time. path-to-regexp (express 5's router) uses
|
||||
// \p{ID_Start}/\p{ID_Continue} at module load to validate route-param names,
|
||||
// which crashes server boot. Rewrite to ASCII-safe classes; every route param
|
||||
// in this app is ASCII (:id, :userId, …). (minimatch also contains \p{} but only
|
||||
// as lazy POSIX-class strings that this app's globs never trigger — left as-is.)
|
||||
(function patchPathToRegexp() {
|
||||
const f = path.join(NM, 'path-to-regexp', 'dist', 'index.js');
|
||||
if (!fs.existsSync(f)) return;
|
||||
let s = fs.readFileSync(f, 'utf8');
|
||||
const before = s;
|
||||
s = s.replace(/\\p\{ID_Start\}/g, 'a-zA-Z').replace(/\\p\{ID_Continue\}/g, 'a-zA-Z0-9');
|
||||
if (s !== before) {
|
||||
fs.writeFileSync(f, s);
|
||||
console.log(' path-to-regexp -> ASCII identifier regex (ICU-less \\p{} fix)');
|
||||
}
|
||||
})();
|
||||
|
||||
console.log('prepare-local-mode-deps: done.');
|
||||
|
|
@ -1,20 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# Copies the bill-tracker backend into mobile/nodejs-assets/nodejs-project/server/
|
||||
# so it can be bundled into the Android app and run by nodejs-mobile (Node 18) in
|
||||
# local mode.
|
||||
#
|
||||
# The backend is now TypeScript (server.cts + .cts/.mts). Node 18 can't run TS, so
|
||||
# this transpiles the server's own sources to CommonJS in place (keeping filenames;
|
||||
# see scripts/transpile-node18.js). Dependencies run as-is on Node 18 and are NOT
|
||||
# transpiled — install them separately (see the "Next" steps printed at the end).
|
||||
# Copies the bill-tracker backend source into mobile/nodejs-assets/nodejs-project/server/
|
||||
# so it can be bundled into the Android app and run by nodejs-mobile in local mode.
|
||||
#
|
||||
# Usage: ./scripts/sync-nodejs-project.sh [--source /path/to/bill-tracker]
|
||||
# --source defaults to ../bill-tracker (sibling directory). Run from mobile root.
|
||||
# Run from the mobile project root.
|
||||
#
|
||||
# --source defaults to ../bill-tracker (sibling directory).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Parse --source flag
|
||||
REPO_ROOT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -22,13 +19,14 @@ while [[ $# -gt 0 ]]; do
|
|||
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REPO_ROOT" ]]; then
|
||||
REPO_ROOT="$(cd "$MOBILE_DIR/../bill-tracker" && pwd)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$REPO_ROOT/server.cts" ]]; then
|
||||
echo "Error: Bill Tracker source not found at $REPO_ROOT (no server.cts)." >&2
|
||||
echo "Use --source /path/to/bill-tracker to specify the project root." >&2
|
||||
if [[ ! -f "$REPO_ROOT/server.js" ]]; then
|
||||
echo "Error: Bill Tracker source not found at $REPO_ROOT" >&2
|
||||
echo "Use --source /path/to/bill-tracker to specify the Bill Tracker project root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -38,41 +36,41 @@ echo "Syncing backend source from $REPO_ROOT -> $DEST"
|
|||
rm -rf "$DEST"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
cp "$REPO_ROOT/server.cts" "$DEST/"
|
||||
cp "$REPO_ROOT/server.js" "$DEST/"
|
||||
|
||||
# Backend source trees. `types/` is type-only (erased by the transpile) but cheap
|
||||
# insurance; `setup/` holds firstRun.cts (required by server.cts on empty DB).
|
||||
for dir in db routes services middleware utils workers setup types; do
|
||||
[[ -d "$REPO_ROOT/$dir" ]] && cp -R "$REPO_ROOT/$dir" "$DEST/$dir"
|
||||
for dir in db routes services middleware utils workers; do
|
||||
cp -R "$REPO_ROOT/$dir" "$DEST/$dir"
|
||||
done
|
||||
|
||||
# routes/user.cts requires ../scripts/seedDemoData.cts (relative to server/).
|
||||
# routes/user.js requires ../scripts/seedDemoData (relative to server/).
|
||||
mkdir -p "$DEST/scripts"
|
||||
cp "$REPO_ROOT/scripts/seedDemoData.cts" "$DEST/scripts/"
|
||||
cp "$REPO_ROOT/scripts/seedDemoData.js" "$DEST/scripts/"
|
||||
|
||||
# Seed JSON read at first-run init:
|
||||
# db/data/*.json -> carried with db/ above (database.cts reads them)
|
||||
# docs/top_200_*.json -> db/migrations/versionedMigrations.cts reads ../../docs/
|
||||
mkdir -p "$DEST/docs"
|
||||
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
|
||||
|
||||
# Drop dev SQLite DBs and any stray node_modules that rode along with db/.
|
||||
rm -f "$DEST"/db/*.db "$DEST"/db/*.db-* 2>/dev/null || true
|
||||
# Drop dev SQLite DB files and node_modules if they were copied along with db/.
|
||||
rm -f "$DEST"/db/*.db "$DEST"/db/*.db-* "$DEST"/db/*.db-shm "$DEST"/db/*.db-wal
|
||||
rm -rf "$DEST/db/node_modules"
|
||||
|
||||
# Transpile the server's TypeScript sources to CommonJS in place (Node 18).
|
||||
# Runs BEFORE dist/ is copied so the browser ESM bundle isn't touched.
|
||||
node "$MOBILE_DIR/scripts/transpile-node18.js" "$DEST"
|
||||
# Seed-data JSON files read by db/database.js during first-run initialization.
|
||||
mkdir -p "$DEST/docs"
|
||||
cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/"
|
||||
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
|
||||
|
||||
# Built frontend, served as static files by server.cts (path.join(__dirname,'dist')).
|
||||
# Built frontend, served as static files by server.js (path.join(__dirname, 'dist')).
|
||||
cp -R "$REPO_ROOT/dist" "$DEST/dist"
|
||||
|
||||
cat <<EOF
|
||||
Done syncing server source.
|
||||
# nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support
|
||||
# optional chaining (?.) / nullish coalescing (??) used in this project and
|
||||
# in some npm dependencies (e.g. express-rate-limit). Transpile server-side
|
||||
# .js files down to Node 12-compatible syntax in place (excludes the
|
||||
# prebuilt frontend bundle in dist/).
|
||||
node "$MOBILE_DIR/scripts/transpile-node12.js" "$DEST"
|
||||
|
||||
Next (only when deps changed):
|
||||
1. cd "$MOBILE_DIR/nodejs-assets/nodejs-project" && npm install
|
||||
2. node "$MOBILE_DIR/scripts/prepare-local-mode-deps.js" # kysely->CJS, stub openid-client
|
||||
3. "$MOBILE_DIR/scripts/install-node18-runtime.sh" # (re)apply Node 18 runtime + bridge patch
|
||||
4. "$MOBILE_DIR/scripts/build-better-sqlite3-node18.sh" # better-sqlite3 for the Node 18 ABI
|
||||
EOF
|
||||
# Also transpile already-installed dependencies, if present (run
|
||||
# `npm install` in nodejs-assets/nodejs-project after this script to pick up
|
||||
# new/updated deps, then re-run this script to transpile them).
|
||||
NODE_MODULES="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules"
|
||||
if [ -d "$NODE_MODULES" ]; then
|
||||
node "$MOBILE_DIR/scripts/transpile-node12.js" "$NODE_MODULES"
|
||||
fi
|
||||
|
||||
echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install"
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
// Transpiles all .js files under a directory (in place) to syntax supported
|
||||
// by nodejs-mobile-cordova's embedded Node 12.19 — primarily optional
|
||||
// chaining (?.) and nullish coalescing (??) used by some npm dependencies
|
||||
// and by this project's own source.
|
||||
//
|
||||
// Usage: node transpile-node12.js <dir> [<dir> ...]
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Resolve esbuild from this project's own node_modules (no longer parent repo)
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const SKIP_DIRS = new Set(['.bin']);
|
||||
|
||||
function walk(dir, cb) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, cb);
|
||||
} else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs') || entry.name === 'package.json') {
|
||||
cb(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let pkgCount = 0;
|
||||
for (const dir of process.argv.slice(2)) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
walk(dir, (file) => {
|
||||
if (path.basename(file) === 'package.json') {
|
||||
// esbuild converts ESM (import/export) to CJS (module.exports) below,
|
||||
// but Node still treats .js files as ES modules if the nearest
|
||||
// package.json has "type": "module" — drop that so the converted
|
||||
// CJS output is loaded correctly under Node 12.
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (pkg.type === 'module') {
|
||||
delete pkg.type;
|
||||
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');
|
||||
pkgCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let out;
|
||||
try {
|
||||
out = esbuild.transformSync(src, { target: 'node12', format: 'cjs', loader: 'js' }).code;
|
||||
} catch (err) {
|
||||
console.warn(`Skipping ${file}: ${err.message.split('\n')[0]}`);
|
||||
return;
|
||||
}
|
||||
// Node 12 doesn't understand the "node:" builtin-module prefix
|
||||
// (added in Node 14.18/16), used by some dependencies (e.g. express-rate-limit).
|
||||
out = out.replace(/require\((['"])node:([a-z/_-]+)\1\)/g, "require($1$2$1)");
|
||||
|
||||
if (out !== src) {
|
||||
fs.writeFileSync(file, out);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log(`Transpiled ${count} file(s) and dropped "type": "module" from ${pkgCount} package.json file(s) for Node 12 compatibility.`);
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Transpiles the Bill Tracker server's TypeScript sources to plain CommonJS for
|
||||
// the embedded Node 18.20.4 runtime (nodejs-mobile). Node 18 runs modern JS
|
||||
// syntax natively, so — unlike the old Node-12 pass — this ONLY strips
|
||||
// TypeScript; it does not rewrite dependency syntax and does not touch
|
||||
// node_modules (their JS runs as-is on Node 18).
|
||||
//
|
||||
// The server is CommonJS-with-TS: files use require()/module.exports plus
|
||||
// `import type`/`as typeof import()` for types, and require() each other with
|
||||
// EXPLICIT `.cts`/`.mts` extensions (115 .cts + 40 .mts call sites). We therefore
|
||||
// transpile each .cts/.mts file IN PLACE, keeping its original name: esbuild
|
||||
// strips the types and emits CJS, and Node 18's CommonJS loader loads unknown
|
||||
// extensions (.cts/.mts) through the default `.js` handler as CommonJS — so every
|
||||
// explicit-extension require() path keeps resolving with zero path rewriting.
|
||||
//
|
||||
// Usage: node transpile-node18.js <serverDir> [<dir> ...]
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const SKIP_DIRS = new Set(['.bin', 'node_modules', 'dist']);
|
||||
|
||||
function loaderFor(file) {
|
||||
if (/\.(cts|mts|ts)$/.test(file)) return 'ts';
|
||||
if (/\.(cjs|js)$/.test(file)) return 'js';
|
||||
return null;
|
||||
}
|
||||
|
||||
function walk(dir, cb) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, cb);
|
||||
else if (loaderFor(entry.name)) cb(full);
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let failed = 0;
|
||||
for (const dir of process.argv.slice(2)) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
walk(dir, (file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let out;
|
||||
try {
|
||||
out = esbuild.transformSync(src, {
|
||||
loader: loaderFor(file),
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
sourcefile: file,
|
||||
}).code;
|
||||
} catch (err) {
|
||||
console.error(`FAILED ${file}: ${err.message.split('\n')[0]}`);
|
||||
failed++;
|
||||
return;
|
||||
}
|
||||
if (out !== src) {
|
||||
fs.writeFileSync(file, out);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`Transpile finished with ${failed} failure(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Transpiled ${count} file(s) to CommonJS for Node 18.`);
|
||||
130
src/App.tsx
130
src/App.tsx
|
|
@ -1,122 +1,50 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth';
|
||||
import { LOCAL_URL, PREF_SERVER_URL, PREF_BIOMETRIC, LOCAL_MODE_SENTINEL } from './config';
|
||||
import SetupScreen from './SetupScreen';
|
||||
import LoadingScreen from './LoadingScreen';
|
||||
import LoginScreen from './LoginScreen';
|
||||
import BiometricSetup from './BiometricSetup';
|
||||
import BiometricLock from './BiometricLock';
|
||||
import MainApp from './MainApp';
|
||||
|
||||
type Stage = 'checking' | 'setup' | 'boot' | 'login' | 'biometric-setup' | 'biometric-lock' | 'app';
|
||||
type Mode = 'local' | 'server';
|
||||
const LOCAL_URL = 'http://localhost:3000';
|
||||
|
||||
export default function App() {
|
||||
const [stage, setStage] = useState<Stage>('checking');
|
||||
const [mode, setMode] = useState<Mode>('server');
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [booted, setBooted] = useState(false);
|
||||
|
||||
// API base — the on-device server in local mode, or the remote server.
|
||||
const baseUrl = mode === 'local' ? LOCAL_URL : serverUrl;
|
||||
const [ready, setReady] = useState(false);
|
||||
const [localMode, setLocalMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Preferences.get({ key: PREF_SERVER_URL })
|
||||
.then(async ({ value }) => {
|
||||
if (value === LOCAL_MODE_SENTINEL) {
|
||||
setMode('local');
|
||||
setStage('boot');
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
setMode('server');
|
||||
setServerUrl(value);
|
||||
const { value: bio } = await Preferences.get({ key: PREF_BIOMETRIC });
|
||||
setStage(bio === 'true' ? 'biometric-lock' : 'app');
|
||||
return;
|
||||
}
|
||||
setStage('setup');
|
||||
})
|
||||
.catch(() => setStage('setup'));
|
||||
Preferences.get({ key: 'serverUrl' }).then(({ value }) => {
|
||||
if (value === 'local') {
|
||||
setLocalMode(true);
|
||||
} else if (value) {
|
||||
// Navigate the WebView to the saved server URL.
|
||||
// From this point the remote server's UI takes over entirely.
|
||||
window.location.replace(value);
|
||||
} else {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleConnect(url: string) {
|
||||
setMode('server');
|
||||
setServerUrl(url);
|
||||
setStage('login');
|
||||
async function handleConnect(url: string) {
|
||||
await Preferences.set({ key: 'serverUrl', value: url });
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
async function handleLocalMode() {
|
||||
await Preferences.set({ key: PREF_SERVER_URL, value: LOCAL_MODE_SENTINEL });
|
||||
setMode('local');
|
||||
setStage(booted ? 'app' : 'boot');
|
||||
await Preferences.set({ key: 'serverUrl', value: 'local' });
|
||||
setLocalMode(true);
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
if (mode === 'server') {
|
||||
await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl });
|
||||
}
|
||||
// Offer biometric unlock once, if the device supports it and it's undecided.
|
||||
try {
|
||||
const { isAvailable } = await BiometricAuth.checkBiometry();
|
||||
const { value: bioPref } = await Preferences.get({ key: PREF_BIOMETRIC });
|
||||
setStage(isAvailable && bioPref == null ? 'biometric-setup' : 'app');
|
||||
} catch {
|
||||
setStage('app');
|
||||
}
|
||||
if (localMode) {
|
||||
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
|
||||
}
|
||||
|
||||
async function handleBiometricSetupDone(enabled: boolean) {
|
||||
await Preferences.set({ key: PREF_BIOMETRIC, value: enabled ? 'true' : 'false' });
|
||||
setStage('app');
|
||||
if (!ready) {
|
||||
// Brief blank while we check preferences before redirecting
|
||||
return (
|
||||
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleBiometricFallback() {
|
||||
await Preferences.set({ key: PREF_BIOMETRIC, value: 'false' });
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
function handleUnauthenticated() {
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await Preferences.remove({ key: PREF_BIOMETRIC });
|
||||
setStage('login');
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case 'boot':
|
||||
return <LoadingScreen onReady={() => { setBooted(true); setStage('app'); }} />;
|
||||
|
||||
case 'setup':
|
||||
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
|
||||
|
||||
case 'login':
|
||||
return (
|
||||
<LoginScreen
|
||||
serverUrl={baseUrl}
|
||||
onSuccess={handleLoginSuccess}
|
||||
onBack={() => setStage(mode === 'local' ? 'app' : 'setup')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'biometric-setup':
|
||||
return <BiometricSetup onDone={handleBiometricSetupDone} />;
|
||||
|
||||
case 'biometric-lock':
|
||||
return <BiometricLock onUnlocked={() => setStage('app')} onFallback={handleBiometricFallback} />;
|
||||
|
||||
case 'app':
|
||||
return <MainApp baseUrl={baseUrl} onUnauthenticated={handleUnauthenticated} onSignOut={handleSignOut} />;
|
||||
|
||||
default:
|
||||
// 'checking' — brief blank while we read stored preferences.
|
||||
return (
|
||||
<div className="app-splash">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { BiometricAuth, BiometryError, BiometryErrorType } from '@aparajita/capacitor-biometric-auth';
|
||||
|
||||
interface Props {
|
||||
onUnlocked: () => void;
|
||||
/** User can't (or won't) use biometrics — fall back to server login. */
|
||||
onFallback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-lock gate shown on launch when biometric unlock is enabled. The
|
||||
* server session cookie persists in the WebView's cookie store between
|
||||
* launches, so this is what stands between "phone unlocked" and "Bill
|
||||
* Tracker dashboard visible".
|
||||
*/
|
||||
export default function BiometricLock({ onUnlocked, onFallback }: Props) {
|
||||
const [error, setError] = useState('');
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
async function attempt() {
|
||||
setError('');
|
||||
setChecking(true);
|
||||
try {
|
||||
await BiometricAuth.authenticate({
|
||||
reason: 'Unlock Bill Tracker',
|
||||
cancelTitle: 'Cancel',
|
||||
allowDeviceCredential: true,
|
||||
androidTitle: 'Unlock Bill Tracker',
|
||||
});
|
||||
onUnlocked();
|
||||
} catch (err) {
|
||||
if (err instanceof BiometryError && err.code === BiometryErrorType.userCancel) {
|
||||
setError('');
|
||||
} else {
|
||||
setError('Authentication failed. Try again.');
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
attempt();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card center">
|
||||
{checking ? (
|
||||
<div className="spinner" />
|
||||
) : (
|
||||
<>
|
||||
<h1 className="setup-title">Locked</h1>
|
||||
<p className="setup-subtitle">{error || 'Authenticate to continue.'}</p>
|
||||
<button className="btn-primary" onClick={attempt} style={{ width: '100%' }}>
|
||||
Try Again
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={onFallback} style={{ width: '100%' }}>
|
||||
Sign In Instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth';
|
||||
|
||||
interface Props {
|
||||
/** Called with whether biometric unlock should be enabled going forward. */
|
||||
onDone: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown once, right after a successful server login, while biometry is
|
||||
* available on the device. Confirms the user can actually authenticate
|
||||
* before turning the lock on.
|
||||
*/
|
||||
export default function BiometricSetup({ onDone }: Props) {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleEnable() {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await BiometricAuth.authenticate({
|
||||
reason: 'Confirm to enable biometric unlock',
|
||||
cancelTitle: 'Cancel',
|
||||
allowDeviceCredential: true,
|
||||
androidTitle: 'Enable biometric unlock',
|
||||
});
|
||||
onDone(true);
|
||||
} catch {
|
||||
setError('Could not verify your identity. You can try again or skip for now.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card center">
|
||||
<div className="logo-mark icon-accent" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.15" />
|
||||
<path
|
||||
d="M20 12a6 6 0 0 0-6 6v2a6 6 0 0 0 .5 2.4M20 12a6 6 0 0 1 6 6v2c0 3-1 5-2 6.5M14 20v2c0 2.5.7 4.3 1.8 5.8M26 20v2c0 1.3-.2 2.6-.6 3.8M17.5 27.5c.8.9 1.8 1.7 2.9 2.2M11 14a9 9 0 0 1 18 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="setup-title">Enable biometric unlock?</h1>
|
||||
<p className="setup-subtitle">
|
||||
Use your fingerprint or face to quickly unlock Bill Tracker the next time you open the
|
||||
app.
|
||||
</p>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<button className="btn-primary" onClick={handleEnable} disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? 'Verifying…' : 'Enable Biometric Unlock'}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => onDone(false)} disabled={loading} style={{ width: '100%' }}>
|
||||
Not Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { getOrCreateEncryptionKey } from './crypto';
|
||||
import { HEALTH_URL, HEALTH_POLL_INTERVAL_MS, HEALTH_POLL_TIMEOUT_MS } from './config';
|
||||
|
||||
const LOCAL_URL = 'http://localhost:3000';
|
||||
const HEALTH_URL = `${LOCAL_URL}/api/health`;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
interface Props {
|
||||
/** Called once the embedded server responds to a health check. */
|
||||
|
|
@ -9,10 +11,7 @@ interface Props {
|
|||
|
||||
/**
|
||||
* Shown while the embedded Node.js server (nodejs-mobile) boots in local mode.
|
||||
* Starts the engine on mount, hands over the encryption key, then polls
|
||||
* /api/health until it responds. Surfaces a real error — instead of spinning
|
||||
* forever — if the key can't be produced, the server reports a startup error
|
||||
* over the channel, or the health check never succeeds within the timeout.
|
||||
* Starts the engine on mount, then polls /api/health until it responds.
|
||||
*/
|
||||
export default function LoadingScreen({ onReady }: Props) {
|
||||
const [error, setError] = useState('');
|
||||
|
|
@ -20,61 +19,31 @@ export default function LoadingScreen({ onReady }: Props) {
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout>;
|
||||
const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;
|
||||
|
||||
function fail(msg: string) {
|
||||
if (!cancelled) setError(msg);
|
||||
}
|
||||
|
||||
function pollHealth() {
|
||||
if (cancelled) return;
|
||||
if (Date.now() > deadline) {
|
||||
fail('The local server did not start in time. Please reopen the app to try again.');
|
||||
return;
|
||||
}
|
||||
fetch(HEALTH_URL)
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
if (res.ok) onReady();
|
||||
else pollTimer = setTimeout(pollHealth, HEALTH_POLL_INTERVAL_MS);
|
||||
if (!cancelled && res.ok) {
|
||||
onReady();
|
||||
} else if (!cancelled) {
|
||||
pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) pollTimer = setTimeout(pollHealth, HEALTH_POLL_INTERVAL_MS);
|
||||
if (!cancelled) pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
const nodejs = window.nodejs;
|
||||
if (!nodejs) {
|
||||
if (!window.nodejs) {
|
||||
setError('Local server engine is unavailable on this platform.');
|
||||
return;
|
||||
}
|
||||
|
||||
nodejs.start('main.js', err => {
|
||||
if (cancelled) return;
|
||||
if (err) {
|
||||
fail('Failed to start local server: ' + String(err));
|
||||
window.nodejs.start('main.js', err => {
|
||||
if (err && !cancelled) {
|
||||
setError('Failed to start local server: ' + String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// The embedded process relays fatal startup errors as { type: 'serverError' }
|
||||
// (see main.js). Register the listener before replying to 'ready' so we
|
||||
// never miss it, and hand over the device-bound encryption key once main.js
|
||||
// reports ready — main.js waits for it before requiring the server.
|
||||
nodejs.channel.on('message', msg => {
|
||||
if (cancelled) return;
|
||||
const m = msg as { type?: string; message?: string };
|
||||
if (m?.type === 'serverError') {
|
||||
fail('The local server failed to start: ' + (m.message || 'unknown error'));
|
||||
return;
|
||||
}
|
||||
if (m?.type !== 'ready') return;
|
||||
getOrCreateEncryptionKey()
|
||||
.then(key => {
|
||||
if (!cancelled) nodejs.channel.post('message', { type: 'encryptionKey', key });
|
||||
})
|
||||
.catch(() => fail('Could not unlock secure storage on this device.'));
|
||||
});
|
||||
|
||||
pollHealth();
|
||||
});
|
||||
|
||||
|
|
@ -86,7 +55,7 @@ export default function LoadingScreen({ onReady }: Props) {
|
|||
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card center">
|
||||
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
|
||||
{error ? (
|
||||
<>
|
||||
<h1 className="setup-title">Couldn't start local server</h1>
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { login, totpChallenge } from './api';
|
||||
|
||||
interface Props {
|
||||
serverUrl: string;
|
||||
/** Called once the backend has confirmed the credentials and set a session cookie. */
|
||||
onSuccess: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type Stage = 'credentials' | 'totp' | 'webauthn';
|
||||
|
||||
export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
|
||||
const [stage, setStage] = useState<Stage>('credentials');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [challengeToken, setChallengeToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function handleKey(e: React.KeyboardEvent) {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (stage === 'credentials') handleLogin();
|
||||
if (stage === 'totp') handleTotp();
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.trim() || !password) {
|
||||
setError('Enter your username and password.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
// api.login normalizes network/timeout/429/5xx into res.error.
|
||||
const res = await login(serverUrl, username.trim(), password);
|
||||
setLoading(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(res.error || 'Invalid username or password.');
|
||||
return;
|
||||
}
|
||||
const data = res.data;
|
||||
if (data?.requires_totp) {
|
||||
setChallengeToken(data.challenge_token || '');
|
||||
setStage('totp');
|
||||
return;
|
||||
}
|
||||
if (data?.requires_webauthn) {
|
||||
setStage('webauthn');
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
async function handleTotp() {
|
||||
if (!code.trim()) {
|
||||
setError('Enter your authenticator code.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
// totpChallenge fetches + echoes the CSRF token (the challenge endpoint,
|
||||
// unlike /login, is not CSRF-exempt).
|
||||
const res = await totpChallenge(serverUrl, challengeToken, code.trim());
|
||||
setLoading(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(res.error || 'Invalid authenticator code.');
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
if (stage === 'webauthn') {
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card">
|
||||
<h1 className="setup-title">Security key required</h1>
|
||||
<p className="setup-subtitle">
|
||||
This account signs in with a security key. Continue to sign in through the server's
|
||||
web interface.
|
||||
</p>
|
||||
<button className="btn-primary" onClick={onSuccess}>
|
||||
Continue
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === 'totp') {
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card">
|
||||
<h1 className="setup-title">Two-factor code</h1>
|
||||
<p className="setup-subtitle">Enter the code from your authenticator app.</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="totp-code">Code</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
className="form-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={e => { setCode(e.target.value); setError(''); }}
|
||||
onKeyDown={handleKey}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={handleTotp} disabled={loading || !code.trim()}>
|
||||
{loading ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={onBack} disabled={loading}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card">
|
||||
<h1 className="setup-title">Sign in</h1>
|
||||
<p className="setup-subtitle">Sign in to {serverUrl.includes('127.0.0.1') ? 'this device' : serverUrl}</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="login-username">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="form-input"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); setError(''); }}
|
||||
onKeyDown={handleKey}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="form-input"
|
||||
type="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setError(''); }}
|
||||
onKeyDown={handleKey}
|
||||
disabled={loading}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={handleLogin} disabled={loading || !username.trim() || !password}>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={onBack} disabled={loading}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
interface Props {
|
||||
/** Pixel size of the (square) mark. Defaults to the CSS .logo-mark size. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bill Tracker brand mark — a calm green bar chart with a checkmark.
|
||||
* Single source for the logo across the native shell (kept in sync with the web
|
||||
* app's client/public/brand/mark.svg). Shared so no screen hand-inlines the SVG.
|
||||
*/
|
||||
export default function LogoMark({ size, className = 'logo-mark' }: Props) {
|
||||
return (
|
||||
<span className={className} style={size ? { width: size, height: size } : undefined} aria-hidden="true">
|
||||
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bill Tracker">
|
||||
<defs>
|
||||
<linearGradient id="bt-bars" x1="48" x2="206" y1="210" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#1e9f93" />
|
||||
<stop offset="0.58" stopColor="#40c878" />
|
||||
<stop offset="1" stopColor="#96e6a1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="bt-ink" x1="34" x2="222" y1="34" y2="222" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#172025" />
|
||||
<stop offset="1" stopColor="#0f1417" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="58" fill="url(#bt-ink)" />
|
||||
<rect x="61" y="123" width="28" height="59" rx="8" fill="url(#bt-bars)" opacity="0.86" />
|
||||
<rect x="101" y="95" width="28" height="87" rx="8" fill="url(#bt-bars)" opacity="0.94" />
|
||||
<rect x="141" y="69" width="28" height="113" rx="8" fill="url(#bt-bars)" />
|
||||
<path d="M62 80.5 97.5 116 190 45" fill="none" stroke="#96e6a1" strokeLinecap="round" strokeLinejoin="round" strokeWidth="18" />
|
||||
<path d="M62 80.5 97.5 116 190 45" fill="none" stroke="#40c878" strokeLinecap="round" strokeLinejoin="round" strokeWidth="10" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
110
src/MainApp.tsx
110
src/MainApp.tsx
|
|
@ -1,110 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import LogoMark from './LogoMark';
|
||||
import TrackerScreen from './TrackerScreen';
|
||||
import { getSession, logout, type SessionUser } from './api';
|
||||
|
||||
interface Props {
|
||||
/** API base — the remote server, or the on-device server in local mode. */
|
||||
baseUrl: string;
|
||||
/** Session is invalid — bounce back to the login screen. */
|
||||
onUnauthenticated: () => void;
|
||||
/** Signed out — return to first-run/login. */
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'tracker' | 'calendar' | 'insights' | 'account';
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'tracker',
|
||||
label: 'Tracker',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="17" rx="2" /><path d="M3 9h18M8 2v4M16 2v4" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
label: 'Insights',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18" /><path d="M7 15l4-5 3 3 4-6" /></svg>,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: 'Account',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></svg>,
|
||||
},
|
||||
];
|
||||
|
||||
function Placeholder({ title, note }: { title: string; note: string }) {
|
||||
return (
|
||||
<div className="placeholder">
|
||||
<h2>{title}</h2>
|
||||
<p>{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainApp({ baseUrl, onUnauthenticated, onSignOut }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('tracker');
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSession(baseUrl).then(res => {
|
||||
if (res.status === 401) { onUnauthenticated(); return; }
|
||||
const u = res.data?.user ?? (res.data as SessionUser | undefined);
|
||||
if (u?.username) setUser(u);
|
||||
});
|
||||
}, [baseUrl, onUnauthenticated]);
|
||||
|
||||
async function handleSignOut() {
|
||||
setSigningOut(true);
|
||||
await logout(baseUrl);
|
||||
onSignOut();
|
||||
}
|
||||
|
||||
const initial = (user?.username || '?').charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-topbar">
|
||||
<LogoMark size={30} />
|
||||
<span className="app-topbar__word">Bill <b>Tracker</b></span>
|
||||
<span className="app-topbar__spacer" />
|
||||
<button className="avatar" onClick={() => setTab('account')} aria-label="Account">{initial}</button>
|
||||
</header>
|
||||
|
||||
<main className="app-main" key={tab}>
|
||||
{tab === 'tracker' && <TrackerScreen baseUrl={baseUrl} onUnauthenticated={onUnauthenticated} />}
|
||||
{tab === 'calendar' && <Placeholder title="Calendar" note="A month-at-a-glance view of when bills are due is coming here next." />}
|
||||
{tab === 'insights' && <Placeholder title="Insights" note="Spending trends and this-vs-last-month comparisons are coming here next." />}
|
||||
{tab === 'account' && (
|
||||
<div className="app-state">
|
||||
<div className="avatar" style={{ width: 56, height: 56, fontSize: '1.5rem' }}>{initial}</div>
|
||||
<h2>{user?.username ?? 'Signed in'}</h2>
|
||||
<p>{user?.role === 'admin' ? 'Administrator' : 'Signed in to this device'}</p>
|
||||
<button className="btn-secondary" style={{ maxWidth: 220 }} onClick={handleSignOut} disabled={signingOut}>
|
||||
{signingOut ? 'Signing out…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<nav className="tabbar">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={'tab' + (tab === t.id ? ' is-active' : '')}
|
||||
onClick={() => setTab(t.id)}
|
||||
aria-current={tab === t.id ? 'page' : undefined}
|
||||
>
|
||||
{t.icon}
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import LogoMark from './LogoMark';
|
||||
|
||||
interface Props {
|
||||
onConnect: (url: string) => void;
|
||||
|
|
@ -22,37 +21,9 @@ function isValidUrl(url: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
// Plain http:// (to anything but loopback) sends the password in cleartext during
|
||||
// the native login POST. Warn and require an explicit acknowledgement.
|
||||
function isInsecureUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== 'http:') return false;
|
||||
return !(u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PhoneIcon = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="6" y="2" width="12" height="20" rx="3" />
|
||||
<path d="M11 18h2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ServerIcon = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function SetupScreen({ onConnect, onLocalMode }: Props) {
|
||||
const [mode, setMode] = useState<'choose' | 'server'>('choose');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [insecureAck, setInsecureAck] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
function handleConnect() {
|
||||
|
|
@ -61,12 +32,6 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) {
|
|||
setError('Enter a valid URL, e.g. https://bills.yourdomain.com');
|
||||
return;
|
||||
}
|
||||
// First tap on an insecure URL: warn and require a second, explicit tap.
|
||||
if (isInsecureUrl(normalized) && !insecureAck) {
|
||||
setError('');
|
||||
setInsecureAck(true);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setConnecting(true);
|
||||
onConnect(normalized);
|
||||
|
|
@ -79,87 +44,65 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) {
|
|||
return (
|
||||
<div className="setup-root">
|
||||
<div className="setup-card">
|
||||
<div className="brand">
|
||||
<LogoMark />
|
||||
<div className="wordmark">Bill <b>Tracker</b></div>
|
||||
<p className="setup-subtitle">Calm command for household money.</p>
|
||||
{/* Logo mark */}
|
||||
<div className="logo-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" />
|
||||
<path d="M20 8v4M20 28v4M8 20h4M28 20h4" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" />
|
||||
<rect x="13" y="13" width="14" height="14" rx="3" stroke="#6366f1" strokeWidth="2" />
|
||||
<path d="M16 20h8M20 16v8" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{mode === 'choose' ? (
|
||||
<>
|
||||
<div className="choices">
|
||||
{/* Local first: the simplest, most private option is one tap. */}
|
||||
<button type="button" className="door" onClick={onLocalMode}>
|
||||
<span className="door__icon">{PhoneIcon}</span>
|
||||
<span className="door__body">
|
||||
<span className="door__title">Run on this phone</span>
|
||||
<span className="door__desc">Private and offline. Nothing leaves your device.</span>
|
||||
</span>
|
||||
<span className="door__chev" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<h1 className="setup-title">Bill Tracker</h1>
|
||||
<p className="setup-subtitle">Connect to your server to get started.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="door"
|
||||
onClick={() => { setMode('server'); setError(''); }}
|
||||
>
|
||||
<span className="door__icon">{ServerIcon}</span>
|
||||
<span className="door__body">
|
||||
<span className="door__title">Connect to a server</span>
|
||||
<span className="door__desc">Your self-hosted Bill Tracker.</span>
|
||||
</span>
|
||||
<span className="door__chev" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="setup-footer">You can switch modes later by clearing the app's storage.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="server-url">Server URL</label>
|
||||
<input
|
||||
id="server-url"
|
||||
className="form-input"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://bills.yourdomain.com"
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); setError(''); setInsecureAck(false); }}
|
||||
onKeyDown={handleKey}
|
||||
disabled={connecting}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
{insecureAck && (
|
||||
<p className="form-error">
|
||||
This is an insecure <strong>http://</strong> address — your password will be sent
|
||||
unencrypted. Only continue on a network you trust. Tap again to connect anyway.
|
||||
</p>
|
||||
)}
|
||||
<p className="form-hint">
|
||||
The address of your Bill Tracker server. Must be reachable from this device.
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="server-url">Server URL</label>
|
||||
<input
|
||||
id="server-url"
|
||||
className="form-input"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://bills.yourdomain.com"
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); setError(''); }}
|
||||
onKeyDown={handleKey}
|
||||
disabled={connecting}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<p className="form-hint">
|
||||
The address of your Bill Tracker server. Must be reachable from this device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !url.trim()}
|
||||
>
|
||||
{connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => { setMode('choose'); setError(''); setInsecureAck(false); }}
|
||||
disabled={connecting}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !url.trim()}
|
||||
>
|
||||
{connecting ? 'Connecting…' : 'Connect to Server'}
|
||||
</button>
|
||||
|
||||
<div className="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onLocalMode}
|
||||
disabled={connecting}
|
||||
>
|
||||
<span>Run on this phone</span>
|
||||
</button>
|
||||
|
||||
<p className="setup-footer">
|
||||
To change your server later, go to <strong>Android Settings → Apps → Bill Tracker → Clear Storage</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getTracker, toggleBillPaid, type TrackerData, type Bill } from './api';
|
||||
|
||||
interface Props {
|
||||
baseUrl: string;
|
||||
/** Called when the API reports the session is no longer valid (401). */
|
||||
onUnauthenticated: () => void;
|
||||
}
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
/** "2026-07-01" → "Jul 1" without tripping over timezones. */
|
||||
function shortDate(iso: string): string {
|
||||
const [, m, d] = iso.split('-').map(Number);
|
||||
return `${MONTHS_SHORT[(m || 1) - 1]} ${d}`;
|
||||
}
|
||||
|
||||
function billTone(b: Bill): 'paid' | 'overdue' | 'upcoming' {
|
||||
if (b.is_settled || b.status === 'paid') return 'paid';
|
||||
if (b.status === 'missed') return 'overdue';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
function statusLabel(b: Bill): string {
|
||||
const tone = billTone(b);
|
||||
if (tone === 'paid') return 'Paid';
|
||||
if (tone === 'overdue') return 'Overdue';
|
||||
return 'Upcoming';
|
||||
}
|
||||
|
||||
const CheckIcon = (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function TrackerScreen({ baseUrl, onUnauthenticated }: Props) {
|
||||
const [ym, setYm] = useState<{ year: number; month: number } | null>(null);
|
||||
const [data, setData] = useState<TrackerData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (target?: { year: number; month: number }) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const res = await getTracker(baseUrl, target?.year, target?.month);
|
||||
if (res.status === 401) {
|
||||
onUnauthenticated();
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.error || 'Could not load your bills.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setData(res.data);
|
||||
setYm({ year: res.data.year, month: res.data.month });
|
||||
setLoading(false);
|
||||
},
|
||||
[baseUrl, onUnauthenticated],
|
||||
);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function nav(delta: number) {
|
||||
if (!ym) return;
|
||||
let month = ym.month + delta;
|
||||
let year = ym.year;
|
||||
if (month < 1) { month = 12; year -= 1; }
|
||||
if (month > 12) { month = 1; year += 1; }
|
||||
load({ year, month });
|
||||
}
|
||||
|
||||
async function toggle(bill: Bill) {
|
||||
setBusyId(bill.id);
|
||||
const res = await toggleBillPaid(baseUrl, bill.id);
|
||||
setBusyId(null);
|
||||
if (res.status === 401) { onUnauthenticated(); return; }
|
||||
if (res.ok) load(ym ?? undefined);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="app-state">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="app-state">
|
||||
<h2>Couldn't load your bills</h2>
|
||||
<p>{error}</p>
|
||||
<button className="btn-primary" style={{ maxWidth: 200 }} onClick={() => load(ym ?? undefined)}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const d = data!;
|
||||
const rows = d.rows || [];
|
||||
const s = d.summary;
|
||||
const unpaid = rows.length - s.count_paid;
|
||||
const lastDay = new Date(d.year, d.month, 0).getDate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="tracker-kicker">Monthly tracker</div>
|
||||
<h1 className="tracker-title">{MONTHS[d.month - 1]} {d.year}</h1>
|
||||
<div className="tracker-range">{MONTHS_SHORT[d.month - 1]} 1 – {MONTHS_SHORT[d.month - 1]} {lastDay}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
<div className="stat stat--paid">
|
||||
<span className="stat__label">Paid</span>
|
||||
<span className="stat__value">{s.count_paid}</span>
|
||||
</div>
|
||||
<div className={'stat' + (unpaid > 0 ? ' stat--warn' : '')}>
|
||||
<span className="stat__label">Needs action</span>
|
||||
<span className="stat__value">{unpaid}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat__label">Bills</span>
|
||||
<span className="stat__value">{rows.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat__label">Remaining</span>
|
||||
<span className="stat__value">{money(s.total_remaining)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="month-nav">
|
||||
<div className="month-nav__pager">
|
||||
<button onClick={() => nav(-1)} aria-label="Previous month">‹</button>
|
||||
<span>{MONTHS[d.month - 1]} {d.year}</span>
|
||||
<button onClick={() => nav(1)} aria-label="Next month">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="app-state">
|
||||
<h2>No bills this month</h2>
|
||||
<p>When you add bills, they'll show up here with what's paid and what's due.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bill-list">
|
||||
{rows.map(b => {
|
||||
const tone = billTone(b);
|
||||
return (
|
||||
<div key={b.id} className={`bill bill--${tone}`}>
|
||||
<button
|
||||
className="bill__toggle"
|
||||
onClick={() => toggle(b)}
|
||||
disabled={busyId === b.id}
|
||||
aria-pressed={tone === 'paid'}
|
||||
aria-label={tone === 'paid' ? `Mark ${b.name} unpaid` : `Mark ${b.name} paid`}
|
||||
>
|
||||
{CheckIcon}
|
||||
</button>
|
||||
<div className="bill__body">
|
||||
<span className="bill__name">{b.name}</span>
|
||||
<span className="bill__meta">
|
||||
{b.category_name ? `${b.category_name} · ` : ''}due {shortDate(b.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bill__right">
|
||||
<div className="bill__amount">{money(b.expected_amount)}</div>
|
||||
<div className="bill__status">{statusLabel(b)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
207
src/api.ts
207
src/api.ts
|
|
@ -1,207 +0,0 @@
|
|||
// Shared HTTP client for the native (pre-WebView) server-mode auth flow.
|
||||
//
|
||||
// One place for: credentials mode, request timeouts, CSRF (double-submit token
|
||||
// fetched from a readable endpoint and echoed as a header), and error
|
||||
// normalization into user-facing messages. CapacitorHttp (enabled globally in
|
||||
// capacitor.config.ts) routes fetch() through native HTTP so these calls aren't
|
||||
// blocked by CORS and the session cookie lands in the WebView's cookie store.
|
||||
|
||||
import { REQUEST_TIMEOUT_MS, CSRF_HEADER, CSRF_TOKEN_PATH } from './config';
|
||||
|
||||
export interface ApiResult<T> {
|
||||
ok: boolean;
|
||||
/** HTTP status, or 0 for a network/timeout failure before any response. */
|
||||
status: number;
|
||||
data: T | null;
|
||||
/** Normalized, user-facing message when !ok; null on success. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
requires_totp?: boolean;
|
||||
requires_webauthn?: boolean;
|
||||
challenge_token?: string;
|
||||
user?: { id: number; username: string; role: string };
|
||||
}
|
||||
|
||||
interface ApiErrorBody {
|
||||
message?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Map a failed response to a single honest, user-facing sentence. */
|
||||
function messageForStatus(status: number, body: ApiErrorBody | null): string {
|
||||
const serverMsg = body?.message || body?.error;
|
||||
switch (true) {
|
||||
case status === 401:
|
||||
case status === 403:
|
||||
return serverMsg || 'Not authorized. Please sign in again.';
|
||||
case status === 429:
|
||||
return serverMsg || 'Too many attempts. Please wait a moment and try again.';
|
||||
case status >= 500:
|
||||
return 'The server had a problem. Please try again in a moment.';
|
||||
default:
|
||||
return serverMsg || `Request failed (${status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
): Promise<ApiResult<T>> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
let data: T | null = null;
|
||||
try {
|
||||
data = (await res.json()) as T;
|
||||
} catch {
|
||||
data = null; // empty or non-JSON body
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, data, error: messageForStatus(res.status, data as ApiErrorBody | null) };
|
||||
}
|
||||
return { ok: true, status: res.status, data, error: null };
|
||||
} catch (err) {
|
||||
const aborted = err instanceof DOMException ? err.name === 'AbortError' : (err as { name?: string })?.name === 'AbortError';
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: null,
|
||||
error: aborted
|
||||
? 'The server took too long to respond. Check your connection and try again.'
|
||||
: 'Could not reach the server. Check the URL and your connection.',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function postJson<T>(url: string, body: unknown, headers: Record<string, string> = {}): Promise<ApiResult<T>> {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/auth/login — CSRF-exempt (no session yet). */
|
||||
export function login(baseUrl: string, username: string, password: string): Promise<ApiResult<LoginResponse>> {
|
||||
return postJson<LoginResponse>(`${baseUrl}/api/auth/login`, { username, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the double-submit CSRF token from the readable endpoint. Returns '' on
|
||||
* failure (the caller surfaces the downstream 403 rather than blocking here).
|
||||
*/
|
||||
export async function fetchCsrfToken(baseUrl: string): Promise<string> {
|
||||
const res = await request<{ token?: string }>(`${baseUrl}${CSRF_TOKEN_PATH}`, { method: 'GET' });
|
||||
return res.ok && res.data?.token ? res.data.token : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/totp/challenge — NOT CSRF-exempt, so fetch + echo the token.
|
||||
*/
|
||||
export async function totpChallenge(
|
||||
baseUrl: string,
|
||||
challengeToken: string,
|
||||
code: string,
|
||||
): Promise<ApiResult<LoginResponse>> {
|
||||
const csrf = await fetchCsrfToken(baseUrl);
|
||||
return postJson<LoginResponse>(
|
||||
`${baseUrl}/api/auth/totp/challenge`,
|
||||
{ challenge_token: challengeToken, code },
|
||||
csrf ? { [CSRF_HEADER]: csrf } : {},
|
||||
);
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — current session; 401 when not signed in. */
|
||||
export function getSession(baseUrl: string): Promise<ApiResult<{ user?: SessionUser } & Partial<SessionUser>>> {
|
||||
return request(`${baseUrl}/api/auth/me`, { method: 'GET' });
|
||||
}
|
||||
|
||||
// ── Domain: the monthly Tracker (subset of /api/tracker we render) ────────────
|
||||
|
||||
export interface Bill {
|
||||
id: number;
|
||||
name: string;
|
||||
category_name: string | null;
|
||||
due_date: string; // YYYY-MM-DD
|
||||
due_day: number;
|
||||
bucket: string;
|
||||
expected_amount: number;
|
||||
balance: number;
|
||||
total_paid: number;
|
||||
status: string; // 'paid' | 'missed' | 'upcoming' | 'partial' | 'autodraft' | …
|
||||
is_settled: boolean;
|
||||
has_payment: boolean;
|
||||
autopay_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TrackerSummary {
|
||||
total_expected: number;
|
||||
total_paid: number;
|
||||
remaining: number;
|
||||
total_remaining: number;
|
||||
overdue: number;
|
||||
count_paid: number;
|
||||
count_upcoming: number;
|
||||
count_late: number;
|
||||
remaining_label: string;
|
||||
}
|
||||
|
||||
export interface TrackerData {
|
||||
year: number;
|
||||
month: number; // 1–12
|
||||
today: string;
|
||||
summary: TrackerSummary;
|
||||
rows: Bill[];
|
||||
}
|
||||
|
||||
/** Authed mutation: fetch the CSRF token, then echo it as x-csrf-token. */
|
||||
async function mutate<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
||||
const csrf = await fetchCsrfToken(baseUrl);
|
||||
return request<T>(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(csrf ? { [CSRF_HEADER]: csrf } : {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/tracker — a month's bills + summary (defaults to the current month). */
|
||||
export function getTracker(baseUrl: string, year?: number, month?: number): Promise<ApiResult<TrackerData>> {
|
||||
const q = year && month ? `?year=${year}&month=${month}` : '';
|
||||
return request<TrackerData>(`${baseUrl}/api/tracker${q}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
/** POST /api/bills/:id/toggle-paid — flip a bill's paid state for the month. */
|
||||
export function toggleBillPaid(baseUrl: string, billId: number): Promise<ApiResult<unknown>> {
|
||||
return mutate(baseUrl, 'POST', `/api/bills/${billId}/toggle-paid`, {});
|
||||
}
|
||||
|
||||
/** POST /api/bills/ — create a bill. */
|
||||
export function createBill(
|
||||
baseUrl: string,
|
||||
bill: { name: string; due_day: number; expected_amount: number },
|
||||
): Promise<ApiResult<Bill>> {
|
||||
return mutate<Bill>(baseUrl, 'POST', '/api/bills/', bill);
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
export function logout(baseUrl: string): Promise<ApiResult<unknown>> {
|
||||
return mutate(baseUrl, 'POST', '/api/auth/logout', {});
|
||||
}
|
||||
309
src/app.css
309
src/app.css
|
|
@ -1,309 +0,0 @@
|
|||
/* Native main-app screens (Tracker, tabs). Token-driven like the shell — see
|
||||
theme.css. Kept separate from index.css (the auth/setup shell) for clarity. */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Top bar ─────────────────────────────────────────────────────── */
|
||||
.app-topbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding-top: max(var(--space-3), env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.app-topbar .logo-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.app-topbar__word {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.app-topbar__word b { color: var(--color-primary); }
|
||||
|
||||
.app-topbar__spacer { flex: 1; }
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Scroll region ───────────────────────────────────────────────── */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: var(--space-4) var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Tracker: month header ───────────────────────────────────────── */
|
||||
.tracker-kicker {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.tracker-title {
|
||||
font-size: 1.9rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tracker-range {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Stat tiles ──────────────────────────────────────────────────── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.stat__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.stat__label::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-faint);
|
||||
}
|
||||
.stat__value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat--paid { border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); }
|
||||
.stat--paid .stat__label { color: var(--color-primary); }
|
||||
.stat--paid .stat__label::before { background: var(--color-primary); }
|
||||
.stat--warn { border-color: color-mix(in srgb, var(--color-warn) 40%, var(--color-border)); }
|
||||
.stat--warn .stat__label { color: var(--color-warn); }
|
||||
.stat--warn .stat__label::before { background: var(--color-warn); }
|
||||
.stat--warn .stat__value { color: var(--color-warn); }
|
||||
|
||||
/* ── Month nav + add ─────────────────────────────────────────────── */
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.month-nav__pager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.month-nav__pager button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.month-nav__pager button:disabled { opacity: 0.35; }
|
||||
.icon-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.icon-btn--primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.icon-btn--primary:active { background: var(--color-primary-active); }
|
||||
.icon-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-dim);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Bill rows ───────────────────────────────────────────────────── */
|
||||
.bill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.bill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-text-faint);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.bill--paid { border-left-color: var(--color-primary); }
|
||||
.bill--overdue { border-left-color: var(--color-warn); }
|
||||
|
||||
.bill__toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: 1.6px solid var(--color-border-strong);
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.bill__toggle svg { width: 16px; height: 16px; }
|
||||
.bill--paid .bill__toggle {
|
||||
background: var(--color-primary-tint);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.bill__toggle:disabled { opacity: 0.5; }
|
||||
|
||||
.bill__body { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.bill__name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bill__meta { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.bill__right { margin-left: auto; text-align: right; flex: 0 0 auto; }
|
||||
.bill__amount {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bill__status {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bill--paid .bill__status { color: var(--color-primary); }
|
||||
.bill--overdue .bill__status { color: var(--color-warn); }
|
||||
.bill--upcoming .bill__status { color: var(--color-text-faint); }
|
||||
|
||||
/* ── States ──────────────────────────────────────────────────────── */
|
||||
.app-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.app-state h2 { font-size: 1.05rem; font-weight: 700; color: var(--color-text); }
|
||||
.app-state p { font-size: 0.875rem; max-width: 34ch; }
|
||||
|
||||
.placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.placeholder__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.placeholder h2 { font-size: 1.1rem; font-weight: 700; color: var(--color-text); }
|
||||
.placeholder p { font-size: 0.875rem; max-width: 30ch; }
|
||||
|
||||
/* ── Bottom tab bar ──────────────────────────────────────────────── */
|
||||
.tabbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0.55rem 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.tab svg { width: 22px; height: 22px; }
|
||||
.tab.is-active { color: var(--color-primary); }
|
||||
.tab:focus-visible { outline: 2px solid var(--color-ring); outline-offset: -2px; }
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// Single source of truth for the React shell's local-mode + networking constants.
|
||||
// (main.js — the embedded Node entry — sets the matching PORT/BIND_HOST on its
|
||||
// own side; keep the port here in sync with it.)
|
||||
|
||||
export const LOCAL_PORT = 3000;
|
||||
export const LOCAL_URL = `http://127.0.0.1:${LOCAL_PORT}`;
|
||||
export const HEALTH_URL = `${LOCAL_URL}/api/health`;
|
||||
|
||||
// @capacitor/preferences keys
|
||||
export const PREF_SERVER_URL = 'serverUrl';
|
||||
export const PREF_BIOMETRIC = 'biometricEnabled';
|
||||
/** Value stored under PREF_SERVER_URL when the user chose on-device local mode. */
|
||||
export const LOCAL_MODE_SENTINEL = 'local';
|
||||
|
||||
// capacitor-secure-storage-plugin
|
||||
export const SECURE_KEY_NAME = 'tokenEncryptionKey';
|
||||
export const ENCRYPTION_KEY_BYTES = 48;
|
||||
|
||||
// Networking / boot
|
||||
export const REQUEST_TIMEOUT_MS = 15_000;
|
||||
export const HEALTH_POLL_INTERVAL_MS = 250;
|
||||
/** Give up the "starting local server" spinner and surface a real error instead
|
||||
* of polling forever. Generous — the first launch unpacks the Node project. */
|
||||
export const HEALTH_POLL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// CSRF (double-submit). The cookie is httpOnly, so the token is read from a
|
||||
// readable endpoint and echoed in a header — mirroring client/api.ts on the web.
|
||||
export const CSRF_HEADER = 'x-csrf-token';
|
||||
export const CSRF_TOKEN_PATH = '/api/auth/csrf-token';
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
|
||||
import { SECURE_KEY_NAME, ENCRYPTION_KEY_BYTES } from './config';
|
||||
|
||||
function generateHexKey(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
globalThis.crypto.getRandomValues(arr);
|
||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device's local-mode database encryption key (TOKEN_ENCRYPTION_KEY),
|
||||
* generating and persisting one in secure storage (Android Keystore / iOS Keychain)
|
||||
* on first launch. Throws if secure storage is unavailable — the caller
|
||||
* (LoadingScreen) surfaces that rather than starting the server with no key.
|
||||
*/
|
||||
export async function getOrCreateEncryptionKey(): Promise<string> {
|
||||
try {
|
||||
const { value } = await SecureStoragePlugin.get({ key: SECURE_KEY_NAME });
|
||||
if (value) return value;
|
||||
} catch {
|
||||
// Not found — fall through to generate one.
|
||||
}
|
||||
|
||||
const key = generateHexKey(ENCRYPTION_KEY_BYTES);
|
||||
try {
|
||||
await SecureStoragePlugin.set({ key: SECURE_KEY_NAME, value: key });
|
||||
} catch (err) {
|
||||
throw new Error('Failed to persist the encryption key to secure storage: ' + String(err));
|
||||
}
|
||||
return key;
|
||||
}
|
||||
277
src/index.css
277
src/index.css
|
|
@ -1,9 +1,3 @@
|
|||
@import './theme.css';
|
||||
|
||||
/* Native shell styles. Every value below resolves to a token from theme.css —
|
||||
no raw hex, so the whole shell rethemes from one place. All screens share
|
||||
these classes, so a change here cascades to setup, login, loading, biometric. */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
|
|
@ -12,28 +6,19 @@
|
|||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
/* ── Setup screen ──────────────────────────────────────────────── */
|
||||
|
||||
/* ── App shell / splash ─────────────────────────────────────────── */
|
||||
|
||||
.setup-root,
|
||||
.app-splash {
|
||||
.setup-root {
|
||||
min-height: 100dvh;
|
||||
background: var(--color-bg);
|
||||
background: #09090b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.setup-root {
|
||||
padding: var(--space-5) var(--space-4);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
|
|
@ -41,147 +26,33 @@ body {
|
|||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.setup-card.center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Brand block ────────────────────────────────────────────────── */
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 26%;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
|
||||
.logo-mark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.wordmark b {
|
||||
color: var(--color-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
color: #fafafa;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.setup-subtitle {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Accent-colored icon wrapper (e.g. biometric fingerprint) */
|
||||
.icon-accent {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Choice "doors" (first-run mode picker) ─────────────────────── */
|
||||
|
||||
.choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.door {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.05s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.door:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.door:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.door:focus-visible {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.door__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-primary-tint);
|
||||
color: var(--color-primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.door__icon svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.door__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.door__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.door__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.door__chev {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
color: #71717a;
|
||||
margin-top: -0.75rem;
|
||||
}
|
||||
|
||||
/* ── Form ───────────────────────────────────────────────────────── */
|
||||
|
|
@ -189,7 +60,7 @@ body {
|
|||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
|
|
@ -197,29 +68,29 @@ body {
|
|||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
padding: 0.75rem 1rem;
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 0.625rem;
|
||||
color: #fafafa;
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 0.15s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-ring);
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
|
|
@ -228,43 +99,37 @@ body {
|
|||
|
||||
.form-error {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-danger);
|
||||
line-height: 1.5;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-faint);
|
||||
color: #52525b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
/* ── Buttons ─────────────────────────────────────────────────────── */
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.8125rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background: var(--color-primary-active);
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
|
@ -273,18 +138,26 @@ body {
|
|||
}
|
||||
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
padding: 0.8125rem 1rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-dim);
|
||||
border: 1px solid var(--color-border);
|
||||
color: #a1a1aa;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
gap: 0.625rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-text);
|
||||
border-color: #3f3f46;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
|
|
@ -292,47 +165,63 @@ body {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.form-input:focus-visible {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
.badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #27272a;
|
||||
color: #71717a;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ── Footer note ────────────────────────────────────────────────── */
|
||||
/* ── Divider ─────────────────────────────────────────────────────── */
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #3f3f46;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
/* ── Footer note ─────────────────────────────────────────────────── */
|
||||
|
||||
.setup-footer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-faint);
|
||||
color: #3f3f46;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
padding: 0 var(--space-1);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.setup-footer strong {
|
||||
color: var(--color-text-muted);
|
||||
color: #52525b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Spinner ────────────────────────────────────────────────────── */
|
||||
/* ── Spinner (shown briefly while checking preferences) ─────────── */
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border: 2px solid #27272a;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation-duration: 1.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './app.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Design tokens — the single source of truth for the native shell's look.
|
||||
Change the brand HERE and every screen updates; components never hard-code a
|
||||
raw color, radius, or spacing value. Two tiers:
|
||||
1. primitives — the raw brand ramp (from the web app's mark.svg) + neutrals
|
||||
2. semantic — the roles components actually reference (--color-*, --radius-*, --space-*)
|
||||
The "Seamless" direction: the web app's dark ink ground + spring-green accent,
|
||||
so the native shell and the app it opens read as one product.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* ── 1. Primitives ───────────────────────────────────────────────────────── */
|
||||
/* Brand ramp — the greens straight out of client/public/brand/mark.svg */
|
||||
--bt-green-300: #96e6a1; /* mint highlight */
|
||||
--bt-green-400: #57e08e;
|
||||
--bt-green-500: #40c878; /* spring — primary */
|
||||
--bt-green-600: #2fae67;
|
||||
--bt-green-700: #1e9f93; /* teal depth */
|
||||
|
||||
/* Green-tinted neutrals (chosen, not a default grey — biased toward the ink in mark.svg) */
|
||||
--bt-ink-900: #0e1315; /* app background */
|
||||
--bt-ink-850: #111917; /* raised surface */
|
||||
--bt-ink-800: #141b1e;
|
||||
--bt-ink-750: #182024; /* input field */
|
||||
--bt-line-800: #1e2925;
|
||||
--bt-line-700: #25322e; /* hairline border */
|
||||
--bt-line-600: #2b3934; /* stronger border */
|
||||
--bt-fg: #eef3f0;
|
||||
--bt-fg-dim: #b6c7be;
|
||||
--bt-fg-muted:#8ea69c;
|
||||
--bt-fg-faint:#5c6d66;
|
||||
|
||||
--bt-red-400: #f2777a; /* danger */
|
||||
--bt-amber-400: #e0b64b; /* warning */
|
||||
|
||||
/* ── 2. Semantic roles (what components use) ─────────────────────────────── */
|
||||
--color-bg: var(--bt-ink-900);
|
||||
--color-surface: var(--bt-ink-850);
|
||||
--color-surface-2: var(--bt-ink-750);
|
||||
--color-border: var(--bt-line-700);
|
||||
--color-border-strong: var(--bt-line-600);
|
||||
|
||||
--color-text: var(--bt-fg);
|
||||
--color-text-dim: var(--bt-fg-dim);
|
||||
--color-text-muted: var(--bt-fg-muted);
|
||||
--color-text-faint: var(--bt-fg-faint);
|
||||
|
||||
--color-primary: var(--bt-green-500);
|
||||
--color-primary-hover: var(--bt-green-600);
|
||||
--color-primary-active: var(--bt-green-700);
|
||||
--color-on-primary: #052012;
|
||||
--color-primary-tint: color-mix(in srgb, var(--bt-green-500) 14%, transparent);
|
||||
--color-ring: color-mix(in srgb, var(--bt-green-500) 32%, transparent);
|
||||
|
||||
--color-danger: var(--bt-red-400);
|
||||
--color-warn: var(--bt-amber-400);
|
||||
|
||||
/* ── Shape & rhythm ──────────────────────────────────────────────────────── */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--space-1: 0.375rem;
|
||||
--space-2: 0.625rem;
|
||||
--space-3: 0.875rem;
|
||||
--space-4: 1.25rem;
|
||||
--space-5: 1.75rem;
|
||||
--space-6: 2.25rem;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
@ -4,9 +4,5 @@
|
|||
interface Window {
|
||||
nodejs?: {
|
||||
start: (filename: string, callback?: (err: unknown) => void) => void;
|
||||
channel: {
|
||||
on: (event: string, callback: (msg: unknown) => void) => void;
|
||||
post: (event: string, message: unknown) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue