Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

34 changed files with 292 additions and 6066 deletions

14
.gitignore vendored
View File

@ -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/

View File

@ -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,
},
},
};

View File

@ -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

View File

@ -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"
}
}

511
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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"

View File

@ -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."

View File

@ -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`.

View File

@ -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.');

View File

@ -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"

View File

@ -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.`);

View File

@ -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.`);

View File

@ -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} />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
)}
</>
);
}

View File

@ -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; // 112
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', {});
}

View File

@ -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; }

View File

@ -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';

View File

@ -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;
}

View File

@ -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); }
}

View File

@ -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(

View File

@ -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
src/vite-env.d.ts vendored
View File

@ -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;
};
};
}