---
phase: 03-dashboard-intake-ui
plan: "05"
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- web/public/manifest.json
- web/public/sw.js
- web/public/icons/icon-192.png
- web/public/icons/icon-512.png
- web/src/hooks/usePWA.ts
- web/src/pages/ScanPage.tsx
- web/src/router.tsx
- web/index.html
autonomous: true
requirements: [PWA-01, PWA-02, PWA-03]
must_haves:
truths:
- "Chrome on Android shows the 'Add to Home Screen' banner for the app"
- "The installed PWA launches in standalone mode (no browser chrome)"
- "Visiting /scan activates the device rear camera and scans QR codes in real-time"
- "A scanned HW-XXXXX QR code navigates the user to /item/:id for that item"
- "Service worker caches the app shell so the UI loads offline (no network required for the shell)"
artifacts:
- path: "web/public/manifest.json"
provides: "PWA web app manifest (name, icons, display=standalone, theme_color)"
- path: "web/public/sw.js"
provides: "Service worker — app shell cache strategy for offline shell"
- path: "web/src/pages/ScanPage.tsx"
provides: "/scan route — camera QR scanner using @zxing/browser"
- path: "web/src/hooks/usePWA.ts"
provides: "Service worker registration hook"
key_links:
- from: "web/index.html"
to: "web/public/manifest.json"
via: ""
- from: "web/src/hooks/usePWA.ts"
to: "web/public/sw.js"
via: "navigator.serviceWorker.register('/sw.js')"
- from: "web/src/pages/ScanPage.tsx"
to: "http://localhost:8080/api/inventory"
via: "Decoded QR text parsed as HW-XXXXX → navigate to /item/:id"
---
Set up the PWA manifest, service worker, and camera-based QR scanner so HWLab is installable on Android and items can be looked up by scanning their printed QR labels.
Purpose: PWA installability and QR scanning close the hardware inventory loop — print a label on intake, scan it later to pull up the item record instantly.
Output: Installable PWA with app shell offline caching and a /scan camera QR scanner that resolves HW IDs.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
```typescript
import { BrowserQRCodeReader, IScannerControls } from '@zxing/browser'
const reader = new BrowserQRCodeReader()
// Get list of cameras:
const devices = await BrowserQRCodeReader.listVideoInputDevices()
// Start scanning — calls callback on each decoded result:
const controls: IScannerControls = await reader.decodeFromVideoDevice(
deviceId, // undefined = default (rear) camera
videoElement, // HTMLVideoElement ref
(result, _err, controls) => {
if (result) {
const text = result.getText()
// text will be the QR code content
controls.stop()
}
}
)
// Stop: controls.stop()
```
// QR codes encode: http://mac-mini.mg:8080/hw/XXXXX
// Or just the HW ID: HW-00001
// Parse: extract HW ID from URL path or direct match
// Lookup: GET /api/inventory returns hw_id field; match by hw_id to get numeric id
// Navigate: router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
// import { router } from '@/router'
// router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
// bg-canvas, text-volt, bg-near-black, border-charcoal/80
// Button variant="forest" for primary actions
Task 1: PWA manifest, service worker, icons, and registration hook
web/public/manifest.json,
web/public/sw.js,
web/public/icons/icon-192.png,
web/public/icons/icon-512.png,
web/src/hooks/usePWA.ts,
web/index.html
Read first: `web/index.html` (current state from Plan 01).
**1. Create web/public/ directory structure:**
```
web/public/
manifest.json
sw.js
icons/
icon-192.png (placeholder — generate programmatically)
icon-512.png (placeholder — generate programmatically)
```
**2. Create web/public/manifest.json** (PWA-01: installable on Android):
```json
{
"name": "HWLab",
"short_name": "HWLab",
"description": "Self-hosted hardware inventory with AI photo intake",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#faff69",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["utilities", "productivity"],
"screenshots": []
}
```
**3. Generate placeholder icons using Node.js (no ImageMagick required):**
Run this script to create minimal valid PNG icons programmatically. Create `web/scripts/gen-icons.cjs`:
```js
#!/usr/bin/env node
// Generates minimal PNG icons for the PWA manifest.
// Uses pure Node.js — no canvas or image library needed.
// Output: a valid 1x1 black PNG with correct PNG header and IDAT chunk.
// For production, replace with properly designed icons.
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
function makePNG(size, bgHex, fgHex) {
// PNG signature
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
// IHDR chunk
const ihdrData = Buffer.alloc(13)
ihdrData.writeUInt32BE(size, 0)
ihdrData.writeUInt32BE(size, 4)
ihdrData[8] = 8 // bit depth
ihdrData[9] = 2 // color type RGB
ihdrData[10] = 0 // compression
ihdrData[11] = 0 // filter
ihdrData[12] = 0 // interlace
const ihdr = makeChunk('IHDR', ihdrData)
// IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row)
const bg = hexToRgb(bgHex)
const fg = hexToRgb(fgHex)
const rawRows = []
const margin = Math.floor(size * 0.2)
for (let y = 0; y < size; y++) {
const row = [0] // filter byte
for (let x = 0; x < size; x++) {
// Simple "H" monogram in the center
const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin
const isStroke =
inMargin &&
((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) ||
(Math.abs(y - size / 2) < Math.floor(size * 0.06)))
const c = isStroke ? fg : bg
row.push(c[0], c[1], c[2])
}
rawRows.push(Buffer.from(row))
}
const rawData = Buffer.concat(rawRows)
const compressed = zlib.deflateSync(rawData)
const idat = makeChunk('IDAT', compressed)
// IEND
const iend = makeChunk('IEND', Buffer.alloc(0))
return Buffer.concat([sig, ihdr, idat, iend])
}
function makeChunk(type, data) {
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const typeBytes = Buffer.from(type)
const crc = crc32(Buffer.concat([typeBytes, data]))
const crcBuf = Buffer.alloc(4)
crcBuf.writeInt32BE(crc)
return Buffer.concat([len, typeBytes, data, crcBuf])
}
function hexToRgb(hex) {
const n = parseInt(hex.replace('#', ''), 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
function crc32(buf) {
let crc = 0xffffffff
const table = makeCRCTable()
for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]
return (crc ^ 0xffffffff) | 0
}
function makeCRCTable() {
const t = new Int32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
t[i] = c
}
return t
}
const iconsDir = path.join(__dirname, '..', 'public', 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69'))
fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69'))
console.log('Icons generated: icon-192.png, icon-512.png')
```
Run: `node web/scripts/gen-icons.cjs`
**4. Create web/public/sw.js** — app shell cache strategy:
```javascript
const CACHE_NAME = 'hwlab-shell-v1'
// App shell assets to cache on install
const SHELL_ASSETS = [
'/',
'/index.html',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
)
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
// Remove old caches
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
)
self.clients.claim()
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// API requests: network only — never cache
if (url.pathname.startsWith('/api/')) {
return
}
// Navigation requests: serve from cache, fall back to network (app shell)
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match('/').then((cached) => cached ?? fetch(event.request))
)
return
}
// Static assets: cache-first
event.respondWith(
caches.match(event.request).then(
(cached) => cached ?? fetch(event.request).then((response) => {
if (response.ok && event.request.method === 'GET') {
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
}
return response
})
)
)
})
```
**5. Create web/src/hooks/usePWA.ts** — service worker registration:
```typescript
import { useEffect } from 'react'
export function usePWA() {
useEffect(() => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
console.log('[PWA] Service worker registered:', reg.scope)
})
.catch((err) => {
console.warn('[PWA] Service worker registration failed:', err)
})
})
}
}, [])
}
```
**6. Update web/src/App.tsx** — call usePWA() hook:
Read current `web/src/App.tsx`, then add:
```typescript
import { usePWA } from '@/hooks/usePWA'
export function App() {
usePWA() // Register service worker
// ... rest unchanged
}
```
**7. Verify web/index.html already has the manifest link** (it was added in Plan 01):
`grep "manifest" web/index.html` — if missing, add `` inside ``.
Add `` inside `` if not already present.
Run `npm run build`.
cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 && ls public/icons/ && node -e "const b = require('fs').readFileSync('public/icons/icon-192.png'); console.log('PNG sig:', b.slice(0,4).toString('hex') === '89504e47' ? 'VALID' : 'INVALID')"
`npm run build` exits 0. `web/public/icons/icon-192.png` and `icon-512.png` exist as valid PNG files (PNG signature 89504E47). `web/public/manifest.json` exists with `"display": "standalone"` and `"theme_color": "#faff69"`. `web/public/sw.js` exists. `web/src/hooks/usePWA.ts` exports `usePWA`.
Task 2: QR scanner page with @zxing/browser and router wiring
web/src/pages/ScanPage.tsx,
web/src/router.tsx
Read first: `web/src/router.tsx` (current state — has stub scanRoute), `web/src/lib/api.ts` (fetchInventory for HW ID lookup).
**Create web/src/pages/ScanPage.tsx** — camera QR code scanner:
Flow:
1. Request camera access via `BrowserQRCodeReader.listVideoInputDevices()`
2. Stream rear camera to `cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/ScanPage.tsx` exports `ScanPage`. `web/src/router.tsx` lazy-loads ScanPage. `extractHWID` function handles both URL and bare HW-ID formats.
PWA setup and QR scanner:
- web/public/manifest.json — installable PWA config (name, theme_color=#faff69, display=standalone, icons)
- web/public/sw.js — app shell cache (API calls always network-only, navigation uses cache-first)
- web/public/icons/ — 192px and 512px PNG icons (black canvas, volt "H" monogram)
- web/src/pages/ScanPage.tsx — /scan route with live camera, volt reticle overlay, HW ID parsing
- Service worker registered on app load via usePWA() hook
**PWA installability (Chrome on Android or Chrome DevTools > Application):**
1. Build: `cd web && npm run build`
2. Start Go backend (which serves the built SPA): `go run ./cmd/hwlab/...`
3. Open http://mac-mini.mg:8080 in Chrome
4. Open DevTools > Application > Manifest
- [ ] Manifest loads with correct name, theme_color #faff69
- [ ] Icons show as 192px and 512px (may be the simple monogram placeholder)
- [ ] No manifest errors in DevTools
5. DevTools > Application > Service Workers
- [ ] sw.js registered and status is "Activated and is running"
6. On Android Chrome: address bar shows install banner or + icon to "Add to Home screen"
**QR scanner (http://localhost:5173/scan or installed PWA):**
1. Navigate to /scan
- [ ] "Scan QR Code" heading, gray QR code icon, "Start Camera" forest green button
2. Click "Start Camera"
- [ ] Camera permission dialog appears
- [ ] After permission: live video feed shows in viewfinder, volt reticle overlay appears
3. Point camera at any QR code (or print test): `http://mac-mini.mg:8080/hw/HW-00001`
- [ ] Toast appears on match if item exists in NetBox, navigates to /item/:id
- [ ] Toast "HW-00001 not found in inventory" if item doesn't exist yet
4. Test "Stop Scanner" button stops camera and returns to idle state
**Mobile layout (390px):**
- [ ] /scan viewfinder is square and fills the screen width
- [ ] "Start Camera" button is full-width, tap-friendly
Type "approved" or describe issues to fix
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Camera → QR decode | Raw camera frames decoded by @zxing — output is untrusted text |
| QR text → navigation | Decoded QR text is parsed before any navigation or API call |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-14 | Tampering | QR text → extractHWID | mitigate | `extractHWID` regex only matches `HW-\d{5,}` pattern; arbitrary text cannot trigger navigation; lookup uses numeric NetBox ID from server-returned data, not from QR content directly |
| T-03-15 | Info Disclosure | Camera stream | accept | Camera stream is local to the device; no frames are sent to the server; @zxing decodes frames locally |
| T-03-16 | Tampering | Service worker cache | accept | SW caches only app shell (static assets); API responses are explicitly excluded from cache (network-only path); no risk of stale auth or inventory data |
| T-03-17 | DoS | Rapid QR scan triggers | mitigate | `lastScanned` state debounces repeated scans of the same QR code; scanner stops before resolving item |
1. `cd web && npm run build` — exits 0
2. `ls web/public/manifest.json web/public/sw.js web/public/icons/icon-192.png web/public/icons/icon-512.png` — all exist
3. `node -e "require('fs').readFileSync('web/public/manifest.json'); console.log('manifest valid JSON')"` — parses without error
4. `grep "ScanPage" web/src/router.tsx` — lazy import present
5. `grep "extractHWID" web/src/pages/ScanPage.tsx` — HW ID parsing function present
6. Visual/functional verification via checkpoint task
- manifest.json loads in Chrome DevTools with no errors, display=standalone, theme_color=#faff69
- Service worker registered and active (DevTools > Application > Service Workers)
- PWA "Add to Home Screen" installable on Android Chrome (PWA-01)
- /scan activates rear camera and scans QR codes in real-time
- Decoded HW-XXXXX QR navigates to /item/:id (PWA-03)
- QR text parsing handles both URL format (http://.../hw/HW-00001) and bare HW-ID
- Camera permission denied handled gracefully (error state, not crash)
- Human verification checkpoint passed