Add lightweight Tauri shell, vendor offline libs, fix bugs and race conditions

- Add tauri/ subfolder: a Tauri-based desktop shell that loads the same
  index.html as the Electron app, producing a ~2MB installer (vs ~71MB for
  Electron) by using the OS WebView2 instead of bundled Chromium. Includes
  sync-frontend.js to keep tauri/dist/ in sync with the canonical index.html
  and vendor/ at build time (no UI duplication/drift).
- Vendor lucide, jsPDF, and jspdf-autotable locally under vendor/ instead of
  loading from CDNs, so the app works fully offline in both shells.
- Fix server.js path traversal vulnerability (sanitize/contain request paths).
- Fix exportPDF() using stale/edited piece data that could produce
  Infinity/NaN in generated PDFs; add a recalculate guard.
- Fix race conditions: overlapping copy/save-defaults button revert timers,
  and FileReader callbacks for logo/CSV import that could apply out of order.
- Minor cleanups: explicit array index parsing, hasResult() helper, consistent
  event binding for NMFC search results, cached DOM lookups.
- Document the dual-shell architecture and vendoring conventions in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Devon Bontrager
2026-06-08 11:19:48 -04:00
parent 3c3ef02f20
commit e5ed1d3c9f
24 changed files with 25160 additions and 12 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "freight-calculator",
"runtimeExecutable": "node",
"runtimeArgs": ["server.js"],
"port": 3010
}
]
}
+62
View File
@@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Two desktop shells — same frontend
This repo ships **two interchangeable desktop shells** that both load the exact same `index.html`:
1. **Electron** (original, root of repo) — heavier (~70MB installer) but mature/familiar
2. **Tauri** (`tauri/` subfolder) — much lighter (~2MB installer, ~8MB exe) using the OS's built-in WebView2 instead of bundled Chromium
`index.html` (plus `vendor/`) is the **single source of truth** for the UI — never duplicate or fork it. The Tauri shell's `tauri/dist/` is a *generated* copy (gitignored, rebuilt by `tauri/sync-frontend.js` before each `dev`/`build`run) — edit `index.html` at the repo root, never inside `tauri/dist/`.
### Electron commands (run from repo root)
- `npm start` — run the app as an Electron desktop window (loads `index.html` via `main.js`)
- `npm run serve` — run a plain Node HTTP server (`server.js`) at http://localhost:3010, serving the same `index.html` for browser-based testing
- `npm run build` — package the Windows installer via `electron-builder` (NSIS, output goes to `dist/`)
### Tauri commands (run from `tauri/`)
- `npm run dev` — sync the frontend into `tauri/dist/` and launch the Tauri dev shell
- `npm run build` — sync the frontend and produce MSI + NSIS installers (output in `tauri/src-tauri/target/release/bundle/`)
- `npm run serve` — same static server as the Electron side, for browser-based testing
- Requires the Rust toolchain (`rustup`/`cargo`) plus MSVC Build Tools and WebView2 (Tauri prerequisites) — see https://tauri.app/start/prerequisites/
There is no test suite, linter, or build step for the app code itself — `index.html` is loaded directly (as a file in Electron/Tauri, or statically served). To verify a change, just reload the app/page in whichever shell you're using.
## Architecture
This is a single-page Electron desktop app (also runnable as a static page) for calculating NMFC freight class from shipment dimensions and weight. Almost all logic lives in one file:
- **`index.html`** — contains everything: markup, CSS (`<style>` block), and all application JavaScript (`<script>` block starting around line 370). There are no separate JS/CSS files or build/bundling steps; what you see in `index.html` is what runs.
- **`main.js`** — minimal Electron bootstrap (creates the `BrowserWindow`, loads `index.html`, with `nodeIntegration: true` / `contextIsolation: false`)
- **`server.js`** — minimal static file server used only for `npm run serve` (browser-based dev/testing instead of Electron)
- **`vendor/`** — local copies of third-party JS libraries (`lucide.js`, `jspdf.umd.min.js`, `jspdf.plugin.autotable.min.js`), referenced by `index.html` via relative `<script src="vendor/...">` tags so the app runs **fully offline** with no CDN dependency. Not gitignored — these files are committed and get bundled into the installer by `electron-builder` automatically. **Do not switch these back to CDN URLs** — if a library needs upgrading, download the new version into `vendor/` and update the filename/reference together.
### Structure inside `index.html`'s script
The script is organized into clearly marked sections (look for the `// ─── Section ───` banner comments) in this order:
1. **Data**`densityRanges` (density → freight class lookup table with descriptions) and `nmfcData` (searchable list of NMFC codes/descriptions/classes)
2. **State**`appState`, persisted to `localStorage` under the key `freightCalcState` via `saveState()`. Defaults are merged with `Object.assign` so new fields can be added without breaking existing saved state. Shape: `{ theme, branding: {name, color, logo}, history[], units: {dim, wt}, defaults: {L, W, H, Weight} }`. The working `pieces` array (current shipment line items being entered) is ephemeral and NOT persisted.
3. **Init**`init()`, wires up the page on load, applies branding, populates settings/defaults forms, renders the density reference table
4. **Theme** — light/dark toggle
5. **Branding** — company name/color/logo customization, applied to the page header and PDF export
6. **Defaults tab** — user-configurable default piece dimensions/weight pre-filled on new entries
7. **Modal** — settings modal open/close/revert logic (`pendingBranding` holds unsaved edits so Cancel can discard them)
8. **Tabs** — settings modal tab switching (Branding / Defaults / History)
9. **History** — calculation history stored in `appState.history` (capped at 50 entries), CSV export/import, restore-from-history (re-applies the exact units the entry was saved in)
10. **Unit toggles** — switching between in/cm and lbs/kg; conversions are applied at calculation time via `dimFactor`/`wtFactor`, not by mutating stored values
11. **Pieces** — managing the dynamic list of shipment pieces (`renderPieces`, `addPiece`)
12. **Validation & Clear** — input validation/error display, form reset
13. **Density gauge** — visual gauge showing where the calculated density falls
14. **Calculate**`calculateFreightClass()`: the core calculation. Converts all dimensions/weights to inches/lbs, computes total cubic feet and total weight, derives density, looks up the class from `densityRanges` (first range where `density >= min` wins), saves a history entry, and updates the UI/gauge
15. **Copy to clipboard** — copies the result summary as text
16. **PDF Export**`exportPDF()` builds a branded PDF report using `jsPDF` + `jspdf-autotable` (loaded from local `vendor/` copies via `<script>` tags in the `<head>` — see `vendor/` note above), including logo, company branding, pieces table, and footer
17. **NMFC Search** — live search over `nmfcData` by code or description; selecting a result highlights the matching row in the on-page density reference table
### Key conventions
- All persisted user data lives in one `appState` object in `localStorage`; always go through `saveState()` after mutating it.
- Unit conversions (`in``cm`, `lbs``kg`) happen only at calculation/display time — stored values keep their original units alongside a `units` field so they can be restored exactly.
- External libraries (`lucide` icons, `jsPDF`, `jspdf-autotable`) are vendored locally in `vendor/` and loaded via relative `<script>` tags in the `<head>`**not** from CDNs — so the app works completely offline.
+55 -11
View File
@@ -3,9 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Freight Calculator Pro</title> <title>Freight Calculator Pro</title>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script> <!-- Vendored locally (vendor/) so the app works fully offline — see CLAUDE.md -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> <script src="vendor/lucide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script> <script src="vendor/jspdf.umd.min.js"></script>
<script src="vendor/jspdf.plugin.autotable.min.js"></script>
<style> <style>
:root { :root {
--primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9; --primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9;
@@ -472,6 +473,13 @@ let pendingBranding = null;
// Working pieces array (ephemeral, not persisted) // Working pieces array (ephemeral, not persisted)
let pieces = [{ L:'', W:'', H:'', Weight:'' }]; let pieces = [{ L:'', W:'', H:'', Weight:'' }];
// Race-condition guards: track in-flight timers/readers so rapid repeat
// actions don't let a stale callback clobber a newer one.
let copyRevertTimer = null;
let saveDefaultsRevertTimer = null;
let logoReadToken = 0;
let csvImportToken = 0;
function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); } function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); }
// ─── Init ────────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────────
@@ -549,8 +557,10 @@ document.getElementById('setting-color').oninput = e => {
}; };
document.getElementById('setting-logo').onchange = e => { document.getElementById('setting-logo').onchange = e => {
if (!e.target.files[0]) return; if (!e.target.files[0]) return;
const myToken = ++logoReadToken;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = evt => { reader.onload = evt => {
if (myToken !== logoReadToken) return; // a newer file selection superseded this read
appState.branding.logo = evt.target.result; appState.branding.logo = evt.target.result;
const preview = document.getElementById('logo-preview'); const preview = document.getElementById('logo-preview');
preview.src = evt.target.result; preview.style.display = 'block'; preview.src = evt.target.result; preview.style.display = 'block';
@@ -574,8 +584,12 @@ document.getElementById('save-defaults-btn').onclick = () => {
}; };
saveState(); saveState();
const btn = document.getElementById('save-defaults-btn'); const btn = document.getElementById('save-defaults-btn');
if (saveDefaultsRevertTimer) clearTimeout(saveDefaultsRevertTimer);
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons(); btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
setTimeout(() => { btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons(); }, 1800); saveDefaultsRevertTimer = setTimeout(() => {
btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons();
saveDefaultsRevertTimer = null;
}, 1800);
}; };
document.getElementById('apply-defaults-btn').onclick = () => { document.getElementById('apply-defaults-btn').onclick = () => {
const d = appState.defaults; const d = appState.defaults;
@@ -697,14 +711,18 @@ function exportHistoryCSV() {
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`; a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 100); // Defer revocation generously so slower systems/rapid consecutive exports
// have time to actually start the download before the blob URL dies.
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} }
// Import history from CSV // Import history from CSV
function importHistoryCSV(e) { function importHistoryCSV(e) {
const file = e.target.files[0]; if (!file) return; const file = e.target.files[0]; if (!file) return;
const myToken = ++csvImportToken;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = evt => { reader.onload = evt => {
if (myToken !== csvImportToken) return; // a newer import selection superseded this read
const lines = evt.target.result.trim().split('\n').slice(1); // skip header const lines = evt.target.result.trim().split('\n').slice(1); // skip header
const imported = []; const imported = [];
lines.forEach(line => { lines.forEach(line => {
@@ -782,7 +800,7 @@ function renderPieces() {
}); });
// Bind input events // Bind input events
container.querySelectorAll('input[type="number"]').forEach(el => { container.querySelectorAll('input[type="number"]').forEach(el => {
el.addEventListener('input', e => { pieces[e.target.dataset.idx][e.target.dataset.field] = e.target.value; }); el.addEventListener('input', e => { pieces[parseInt(e.target.dataset.idx, 10)][e.target.dataset.field] = e.target.value; });
el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); }); el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); });
}); });
// Bind delete buttons // Bind delete buttons
@@ -801,7 +819,10 @@ function addPiece() {
// Focus first input of new row // Focus first input of new row
const rows = document.querySelectorAll('.piece-row'); const rows = document.querySelectorAll('.piece-row');
const lastRow = rows[rows.length-1]; const lastRow = rows[rows.length-1];
if (lastRow) lastRow.querySelector('input') && lastRow.querySelector('input').focus(); if (lastRow) {
const firstInput = lastRow.querySelector('input');
if (firstInput) firstInput.focus();
}
} }
// ─── Validation & Clear ─────────────────────────────────────────────────────── // ─── Validation & Clear ───────────────────────────────────────────────────────
@@ -884,9 +905,14 @@ function calculateFreightClass() {
lucide.createIcons(); lucide.createIcons();
} }
// Returns true once a calculation has produced a result (i.e. not the placeholder '--')
function hasResult() {
return document.getElementById('class-result').textContent !== '--';
}
// ─── Copy to clipboard ──────────────────────────────────────────────────────── // ─── Copy to clipboard ────────────────────────────────────────────────────────
function copyResult() { function copyResult() {
if (document.getElementById('class-result').textContent === '--') return; if (!hasResult()) return;
const cls = document.getElementById('class-result').textContent; const cls = document.getElementById('class-result').textContent;
const density = document.getElementById('density-result').textContent; const density = document.getElementById('density-result').textContent;
const du = appState.units.dim, wu = appState.units.wt; const du = appState.units.dim, wu = appState.units.wt;
@@ -894,19 +920,34 @@ function copyResult() {
const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`; const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn'); const btn = document.getElementById('copy-btn');
if (copyRevertTimer) clearTimeout(copyRevertTimer);
btn.classList.add('copied'); btn.classList.add('copied');
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons(); btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons(); }, 2000); copyRevertTimer = setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons();
copyRevertTimer = null;
}, 2000);
}); });
} }
// ─── PDF Export ─────────────────────────────────────────────────────────────── // ─── PDF Export ───────────────────────────────────────────────────────────────
function exportPDF() { function exportPDF() {
if (document.getElementById('class-result').textContent === '--') { if (!hasResult()) {
alert('Please calculate a freight class before exporting a PDF.'); alert('Please calculate a freight class before exporting a PDF.');
return; return;
} }
if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; } if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; }
// Guard against stale/edited piece data producing Infinity/NaN in the PDF
// (e.g. user calculates, then clears a dimension before exporting without recalculating).
const piecesValidForExport = pieces.every(p => {
const L = parseFloat(p.L), Wd = parseFloat(p.W), H = parseFloat(p.H), Wt = parseFloat(p.Weight);
return L > 0 && Wd > 0 && H > 0 && Wt > 0;
});
if (!piecesValidForExport) {
alert('Piece values have changed since the last calculation. Please recalculate before exporting a PDF.');
return;
}
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit:'mm', format:'letter' }); const doc = new jsPDF({ unit:'mm', format:'letter' });
const W = doc.internal.pageSize.getWidth(); const W = doc.internal.pageSize.getWidth();
@@ -1031,7 +1072,7 @@ function nmfcSearch(query) {
} }
let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>'; let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>';
results.forEach(n => { results.forEach(n => {
html += `<tr onclick="nmfcSelect(${n.cls})"> html += `<tr data-cls="${n.cls}">
<td style="font-family:monospace;font-size:0.85rem">${n.code}</td> <td style="font-family:monospace;font-size:0.85rem">${n.code}</td>
<td>${n.desc}</td> <td>${n.desc}</td>
<td><span class="nmfc-class-badge">${n.cls}</span></td> <td><span class="nmfc-class-badge">${n.cls}</span></td>
@@ -1039,6 +1080,9 @@ function nmfcSearch(query) {
}); });
html += '</tbody></table>'; html += '</tbody></table>';
container.innerHTML = html; container.innerHTML = html;
container.querySelectorAll('tbody tr').forEach(row => {
row.addEventListener('click', () => nmfcSelect(parseFloat(row.dataset.cls)));
});
} }
function nmfcSelect(cls) { function nmfcSelect(cls) {
// Highlight the matching row in the density reference table // Highlight the matching row in the density reference table
+10 -1
View File
@@ -14,7 +14,16 @@ const mimeTypes = {
}; };
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url); // Strip query string / hash and decode URI components before resolving,
// then confirm the resolved path stays inside __dirname (no ../ escapes).
const urlPath = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
const reqPath = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
const filePath = path.join(__dirname, reqPath);
if (!filePath.startsWith(__dirname + path.sep) && filePath !== __dirname) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('403 Forbidden');
return;
}
const ext = path.extname(filePath); const ext = path.extname(filePath);
const contentType = mimeTypes[ext] || 'application/octet-stream'; const contentType = mimeTypes[ext] || 'application/octet-stream';
+7
View File
@@ -0,0 +1,7 @@
node_modules/
src-tauri/target/
src-tauri/gen/schemas
*.log
# Generated by sync-frontend.js — copy of ../index.html and ../vendor/, not source of truth
dist/
+16
View File
@@ -0,0 +1,16 @@
{
"name": "freight-calculator-pro-tauri",
"version": "1.2.0",
"description": "Professional Freight Class Calculator (Tauri shell)",
"private": true,
"scripts": {
"sync": "node sync-frontend.js",
"dev": "npm run sync && tauri dev",
"build": "npm run sync && tauri build",
"serve": "node ../server.js"
},
"license": "ISC",
"devDependencies": {
"@tauri-apps/cli": "^2.11.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4941
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "freight-calculator-pro"
version = "1.2.0"
description = "Professional Freight Class Calculator"
authors = ["Devon Bontrager"]
license = "ISC"
repository = "https://gitea.netbird.zimspace.uk:5938/debont80/FreightCalculatorPro.git"
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Freight Calculator Pro",
"version": "1.2.0",
"identifier": "com.freightcalc.pro",
"build": {
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Freight Calculator Pro",
"width": 950,
"height": 950,
"minWidth": 800,
"minHeight": 700,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copies the canonical frontend (../index.html and ../vendor/) into ./dist
// before each Tauri dev/build run, so the Tauri shell always packages the
// exact same UI as the Electron version — no manual sync, no drift.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const DIST = path.join(__dirname, 'dist');
function copyRecursive(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
copyRecursive(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
}
}
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
copyRecursive(path.join(ROOT, 'index.html'), path.join(DIST, 'index.html'));
copyRecursive(path.join(ROOT, 'vendor'), path.join(DIST, 'vendor'));
console.log(`Synced frontend (index.html + vendor/) from ${ROOT} -> ${DIST}`);
File diff suppressed because one or more lines are too long
+398
View File
File diff suppressed because one or more lines are too long
+19522
View File
File diff suppressed because it is too large Load Diff