Counter kiosk: customers check in, sign, and the job makes itself
A touchscreen at the counter where customers fill in their details, sign the repair terms with a finger, and the job lands in the job system. No paper.
- Difficulty
- Moderate
- Parts cost
- A 15.6 inch USB-C touchscreen, plus a PC that's already at the counter
- Build time
- Built in stages, March to September
- Skills
- React, Node.js, SQLite, Linux desktop
The problem
A small repair workshop I ran had two brands sharing one counter: scooters and e-bikes on one side, cars on the other. Every drop-off needs the same things: a name, a phone number, what’s wrong with it, and the customer agreeing to the repair terms. I wanted that last part signed, every time, and kept somewhere I could find it again. If a customer ever argues about a scratch or a storage fee, the signed terms are what you show them.
So I built a sign-in kiosk. A touchscreen sits at the counter. The customer picks which side of the business they want, types in their details, reads the terms, ticks the box and signs with a finger. The job turns up on the job board straight away, with the signed terms attached. The goal was a customer in and out in under a minute.
The app was the easy part. Getting a Chrome window to open on the right screen, and stay there, took far longer. That story is in the lessons at the bottom.
How it works
The job system is a web app I wrote for the workshop: job cards, invoices, counter sales. It runs in Docker
on the workshop PC, with a React front end, a Node.js (Express) back end and a SQLite database. The kiosk
is just one more page in it, at /kiosk, with three screens:
- Landing. Two big cards, one per brand. Tap one to start.
- The form. Name and phone, then what you’re dropping off. The scooter side asks for a description and whether the charger is included. The car side asks for rego, make, model, year and odometer, plus a set of tap-to-pick issues (brakes, warning light, strange noise and so on). Then notes, five plain-English key terms, a “Read full Terms & Conditions” link that opens the full legal text, the “I have read and understood” tick box, and a signature pad. Submit stays greyed out until the name, phone, tick and signature are all there.
- Thank you. “Thank you [name]! Your job number is A261010-001. We’ll be with you shortly.” It goes back to the landing screen by itself after 10 seconds.
When the form is sent, the back end finds the customer by phone number (or adds them), adds a vehicle record, makes the next job number for that brand, saves the signature as a PNG file and stores an intake record that ties it all to the job. The job appears as pending, and the counter staff pick it up from there.
There’s a second, longer form for when we want more than the quick sign-in. Staff open a job, tap Customer Intake and hand the screen to the customer. That one is filled in from the job already and adds a pre-existing damage checklist (scratches, dents, cracks and so on), a declared value for the item, the customer’s concerns, the full terms and another signature. And there’s a read-only Waivers page on the staff side: search a name or phone number, open the job, and there’s exactly what they agreed to, their signature and the time they signed.
What you need
- A touchscreen monitor. Mine is a 15.6 inch USB-C touchscreen, plugged into the workshop PC as an extra screen next to the two staff monitors. The kiosk is only a web page, so a tablet should do the job too, but I haven’t run it on one.
- An always-on PC at the counter. Mine is a mini PC running Ubuntu 26.04 (GNOME 50), and it runs the job system too. It started out on Windows. Both are covered below.
- Google Chrome, run in kiosk mode (full screen, no address bar, no tabs) with its own profile.
- A job system you can add a page and an API route to. Mine is my own Node.js and SQLite app. The
snippets below assume Express and
better-sqlite3. react-simple-keyboardfor the on-screen keyboard.- Your own terms and conditions. Mine were written for my business. Get yours checked by someone who knows the law where you are. I’m a mechanic, not a lawyer.
Wiring
There isn’t much:
| From | To | Notes |
|---|---|---|
| Touchscreen USB-C | PC USB-C port | Linux shows it as a DisplayPort output (mine is DP-2) |
| PC | Network | Ethernet if you can, for the job system’s sake. The kiosk itself only talks to the same PC |
The one thing that matters: find the touchscreen by its name, never by its position. Run this with the screen plugged in and it lists every connected output with the text from its EDID (the ID block every monitor reports to the PC):
for d in /sys/class/drm/card*-*/; do
[ "$(cat $d/status)" = connected ] && { echo -n "$(basename $d): "; strings $d/edid | head -3 | tr '\n' ' '; echo; }
done
Note the connector name and a piece of text from the touchscreen’s line. You’ll need both below.
Firmware and config
There’s no firmware. It’s a database table, one API route, a React page, and some desktop plumbing to keep Chrome on the right screen.
The database
The signature is not stored in the database. It’s a PNG file on disk, and the table only keeps its file name. That keeps the database small, but it means the signature folder has to be in your backups too (see the lessons).
CREATE TABLE IF NOT EXISTS intake_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
division TEXT NOT NULL, -- which brand
item_description TEXT,
charger_included INTEGER DEFAULT 0,
notes TEXT,
tc_agreed INTEGER DEFAULT 0,
tc_agreed_at TEXT,
signature_path TEXT, -- just the file name, e.g. sig-A261010-001.png
pre_existing_damage TEXT, -- from the longer staff-led form, as JSON
declared_value REAL,
customer_concerns TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (job_id) REFERENCES jobs(id)
);
The last three columns came later, when I added the longer form. I added them with ALTER TABLE ... ADD COLUMN so nothing that already existed was touched.
The API route
This is my kiosk route, cut down to the scooter side and tidied for sharing. The car side is the same
shape with rego, make, model, year and odometer going into the vehicle record. Your customers, vehicles
and jobs tables will be different. The bit worth copying is the order of operations.
// routes/kiosk.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const { db } = require('../database'); // a better-sqlite3 connection
const router = express.Router();
const SIG_DIR = path.join(__dirname, '..', 'data', 'signatures');
const PREFIX = { brand_a: 'A', brand_b: 'B' }; // one job-number prefix per brand
// A260331-001, A260331-002 ... restarting each day.
function nextJobNumber(prefix) {
const d = new Date();
const day = prefix + String(d.getFullYear()).slice(-2)
+ String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
const last = db.prepare(
'SELECT job_number FROM jobs WHERE job_number LIKE ? ORDER BY job_number DESC LIMIT 1'
).get(day + '%');
const seq = last ? parseInt(last.job_number.slice(-3), 10) + 1 : 1;
return `${day}-${String(seq).padStart(3, '0')}`;
}
router.post('/submit', (req, res) => {
try {
const { business, name, phone, item_description, charger_included, notes,
tc_agreed, signature } = req.body;
// 1. The page checks these too. Check them again here.
if (!name || !phone) return res.status(400).json({ error: 'Name and phone are required' });
if (!tc_agreed) return res.status(400).json({ error: 'Terms and conditions must be agreed to' });
if (!signature) return res.status(400).json({ error: 'Signature is required' });
const division = PREFIX[business] ? business : 'brand_a';
// 2. Find the customer by phone, or add them.
let customer = db.prepare('SELECT * FROM customers WHERE phone = ?').get(phone);
if (!customer) {
const r = db.prepare('INSERT INTO customers (name, phone) VALUES (?, ?)').run(name, phone);
customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(r.lastInsertRowid);
}
// 3. A placeholder vehicle the workshop fills in properly later.
const vehicleId = db.prepare(
"INSERT INTO vehicles (customer_id, make, model, vehicle_type) VALUES (?, ?, '', 'scooter')"
).run(customer.id, item_description || 'To be added').lastInsertRowid;
// 4. The job, with everything the customer told us in the description.
const parts = [item_description, charger_included && 'Charger included', notes && `Notes: ${notes}`];
const workDesc = parts.filter(Boolean).join(' | ') || 'Kiosk drop-off - details to be added';
const jobNumber = nextJobNumber(PREFIX[division]);
const jobId = db.prepare(
"INSERT INTO jobs (job_number, customer_id, vehicle_id, work_description, status, business) VALUES (?, ?, ?, ?, 'pending', ?)"
).run(jobNumber, customer.id, vehicleId, workDesc, division).lastInsertRowid;
// 5. The signature: a PNG on disk, named after the job.
fs.mkdirSync(SIG_DIR, { recursive: true });
const sigFile = `sig-${jobNumber}.png`;
const png = signature.replace(/^data:image\/png;base64,/, '');
fs.writeFileSync(path.join(SIG_DIR, sigFile), Buffer.from(png, 'base64'));
// 6. The intake record ties it all together.
db.prepare(`
INSERT INTO intake_records (job_id, division, item_description, charger_included, notes,
tc_agreed, tc_agreed_at, signature_path)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
`).run(jobId, division, item_description || null, charger_included ? 1 : 0, notes || null,
new Date().toISOString(), sigFile);
res.json({ success: true, job_number: jobNumber, customer_name: name, job_id: jobId });
} catch (err) {
console.error('Kiosk submit error:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
Two more routes go with it: one that lists waivers with a ?search= on name or phone (a SQL LIKE on
both), and one that serves the PNGs so the Waivers page can show them. The kiosk page has to work without
anyone logged in, so these routes sit outside the job system’s login. Only expose what the kiosk needs.
One thing to decide for yourself: what happens when the phone number matches an existing customer but the name doesn’t, like a partner dropping off someone else’s scooter. My plan said “make the job under the name they typed, and don’t overwrite the customer”. Pick a rule and test it.
The signature pad
A plain HTML canvas. The two things that make it work on a touchscreen are touch-action: none (so a
finger draws instead of scrolling the page) and scaling the touch point from screen pixels to canvas
pixels:
// Inside the Kiosk component
const canvasRef = useRef(null);
const drawing = useRef(false);
const [hasSigned, setHasSigned] = useState(false);
const clearPad = () => {
const c = canvasRef.current, ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff'; // white background, so the PNG isn't see-through
ctx.fillRect(0, 0, c.width, c.height);
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
setHasSigned(false);
};
const point = (e) => {
const c = canvasRef.current, r = c.getBoundingClientRect();
const src = e.touches && e.touches.length ? e.touches[0] : e;
return { x: (src.clientX - r.left) * (c.width / r.width),
y: (src.clientY - r.top) * (c.height / r.height) };
};
const start = (e) => {
drawing.current = true;
setHasSigned(true);
const ctx = canvasRef.current.getContext('2d'), p = point(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
};
const move = (e) => {
if (!drawing.current) return;
const ctx = canvasRef.current.getContext('2d'), p = point(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
};
const stop = () => { drawing.current = false; };
// In the form:
<canvas ref={canvasRef} width={800} height={300}
style={{ width: '100%', height: 300, touchAction: 'none' }}
onTouchStart={(e) => { e.preventDefault(); start(e); }}
onTouchMove={(e) => { e.preventDefault(); move(e); }}
onTouchEnd={(e) => { e.preventDefault(); stop(); }}
onMouseDown={start} onMouseMove={move} onMouseUp={stop} />
<button onClick={clearPad}>Clear</button>
// On submit:
const signature = canvasRef.current.toDataURL('image/png');
Call clearPad() once when the form opens, to paint the white background. hasSigned goes true on the
first touch, so a single dot counts as a signature. If that matters to you, count the points drawn
instead.
Locking the page down
Kiosk mode in Chrome hides the browser. The page has to do the rest: stop people selecting text, zooming, swiping back, right-clicking, or typing on a keyboard someone left plugged in.
// Swallow the physical keyboard, right-click and drag. F5 and F11 stay, for staff.
useEffect(() => {
const blockKeys = (e) => {
if (e.key === 'F5' || e.key === 'F11') return;
e.preventDefault();
e.stopPropagation();
};
const block = (e) => e.preventDefault();
document.addEventListener('keydown', blockKeys, true);
document.addEventListener('contextmenu', block, true);
document.addEventListener('dragstart', block, true);
return () => {
document.removeEventListener('keydown', blockKeys, true);
document.removeEventListener('contextmenu', block, true);
document.removeEventListener('dragstart', block, true);
};
}, []);
Because the real keyboard is blocked, every text box is readOnly and typing goes through an on-screen
keyboard (react-simple-keyboard) that slides up when a box is tapped. The phone box gets a number pad
layout. readOnly also stops the operating system’s own on-screen keyboard popping up over the form.
<input className="kiosk-input" type="text" value={name} readOnly autoComplete="off"
onFocus={() => focusField('name')} onTouchStart={() => focusField('name')} />
.kiosk-container {
user-select: none; /* no text selection */
-webkit-user-select: none;
}
.kiosk-form-body {
overflow-y: scroll;
touch-action: pan-y; /* scroll up and down, no pinch zoom */
}
button {
touch-action: manipulation; /* no double-tap zoom */
}
Make the buttons and tick boxes big (I aimed for at least 60 pixels) and the text at least 18 pixels. Nobody wants to fiddle with a tiny tick box at a counter.
The idle reset. Someone always walks off halfway through. After 90 seconds without a touch, the page wipes the form and goes back to the landing screen, so the next customer never sees the last one’s phone number:
const IDLE_MS = 90000;
const idleTimer = useRef(null);
useEffect(() => {
if (screen === 'landing') return;
const restart = () => {
clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(goToLanding, IDLE_MS); // goToLanding() clears every field
};
restart();
document.addEventListener('touchstart', restart);
document.addEventListener('mousedown', restart);
return () => {
clearTimeout(idleTimer.current);
document.removeEventListener('touchstart', restart);
document.removeEventListener('mousedown', restart);
};
}, [screen, goToLanding]);
Chrome in kiosk mode (Linux)
A small script, run as a systemd user service, keeps kiosk Chrome running. It waits for the kiosk page to actually answer before it starts Chrome (see the lessons for why), and checks every 5 seconds that Chrome is still there.
#!/bin/bash
# ~/kiosk-guard.sh: keep kiosk Chrome alive, in its own profile, on the kiosk page.
KIOSK_URL="${KIOSK_URL:-http://localhost:3000/kiosk}"
PROFILE="${KIOSK_PROFILE:-$HOME/.kiosk-chrome}"
LOG="$HOME/kiosk-guard.log"
log(){ echo "$(date '+%F %T') $*" >> "$LOG"; }
start_kiosk(){
mkdir -p "$PROFILE"
log "launching kiosk chrome -> $KIOSK_URL"
nohup google-chrome \
--kiosk \
--user-data-dir="$PROFILE" \
--app="$KIOSK_URL" \
--noerrdialogs \
--disable-session-crashed-bubble \
--disable-infobars \
--no-first-run \
--disable-features=TranslateUI \
--check-for-update-interval=31536000 \
--password-store=basic \
>> "$LOG" 2>&1 &
}
log "=== kiosk-guard start (url=$KIOSK_URL) ==="
while true; do
if ! pgrep -f -- "--user-data-dir=$PROFILE" > /dev/null 2>&1; then
# Only launch once the page is really serving, or Chrome shows a cached error page.
if curl -sf -o /dev/null --max-time 5 "$KIOSK_URL"; then
start_kiosk
else
log "kiosk URL not responding yet - waiting"
fi
fi
sleep 5
done
# ~/.config/systemd/user/kiosk-guard.service
[Unit]
Description=Kiosk guard (keeps kiosk Chrome alive)
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
Environment=KIOSK_URL=http://localhost:3000/kiosk
ExecStart=%h/kiosk-guard.sh
Restart=always
RestartSec=10
[Install]
WantedBy=graphical-session.target
The separate profile (--user-data-dir) matters. It keeps the kiosk away from anyone’s normal Chrome,
and it’s how the script and the extension below recognise the kiosk window.
On Windows, the same idea is one line in a batch file in the Startup folder. Mine also had
--disable-pinch and --overscroll-history-navigation=0 (no swipe-to-go-back):
start "" "C:\Program Files\Google\Chrome\Application\chrome.exe" --kiosk --user-data-dir="C:\kiosk-chrome" --disable-pinch --overscroll-history-navigation=0 --disable-session-crashed-bubble --noerrdialogs --no-first-run --disable-infobars --disable-translate http://localhost:3000/kiosk
Pinning the window to the touchscreen (GNOME)
On a Linux desktop running Wayland (the modern way Linux draws windows), an app isn’t allowed to choose
which screen it opens on. Chrome’s --kiosk window lands on the main monitor, every time. The only thing
allowed to move it is the desktop itself, so the fix is a small GNOME Shell extension: a plug-in that runs
inside the desktop. This is mine, with the logging trimmed out.
// ~/.local/share/gnome-shell/extensions/kiosk-pin@example/extension.js
import Meta from 'gi://Meta';
import GLib from 'gi://GLib';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
const CONF = GLib.get_home_dir() + '/.config/kiosk-pin.conf'; // one line: the connector, e.g. DP-2
const PROFILE_MARK = '.kiosk-chrome'; // kiosk Chrome's --user-data-dir
const CLASS_MARK = 'localhost:3000/kiosk'; // kiosk Chrome's app id on Wayland
const MAX_TRIES = 60; // x 500 ms = 30 s
function readConnector() {
try {
const [ok, bytes] = GLib.file_get_contents(CONF);
if (ok) { const s = new TextDecoder().decode(bytes).trim(); if (s) return s; }
} catch (e) {}
return 'DP-2';
}
export default class KioskPin extends Extension {
enable() {
this._connector = readConnector();
this._timers = new Set();
this._wins = new Map();
this._sig = global.display.connect('window-created', (_d, win) => this._track(win));
global.get_window_actors().forEach(a => this._track(a.meta_window));
}
disable() {
if (this._sig) global.display.disconnect(this._sig);
this._sig = null;
for (const id of this._timers) GLib.source_remove(id);
this._timers.clear();
for (const [win, st] of this._wins) for (const s of st.sigs) { try { win.disconnect(s); } catch (e) {} }
this._wins.clear();
}
_targetMonitor() {
try {
return global.backend.get_monitor_manager().get_monitor_for_connector(this._connector);
} catch (e) { return -1; }
}
_isKiosk(win) {
if ((win.get_wm_class() || '').includes(CLASS_MARK)) return true;
const pid = win.get_pid();
if (pid <= 0) return false;
try {
const [ok, bytes] = GLib.file_get_contents(`/proc/${pid}/cmdline`);
return ok && new TextDecoder().decode(bytes).includes(PROFILE_MARK);
} catch (e) { return false; }
}
_track(win) {
if (!win || win.get_window_type() !== Meta.WindowType.NORMAL) return;
if (this._wins.has(win) || !this._isKiosk(win)) return;
const st = {sigs: [], tries: 0};
this._wins.set(win, st);
// Chrome moves itself back, so put it right again whenever it moves.
st.sigs.push(win.connect('shown', () => this._place(win, st)));
st.sigs.push(win.connect('position-changed', () => this._place(win, st)));
st.sigs.push(win.connect('size-changed', () => this._place(win, st)));
st.sigs.push(win.connect('unmanaged', () => this._wins.delete(win)));
const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
if (!this._wins.has(win)) { this._timers.delete(id); return GLib.SOURCE_REMOVE; }
st.tries++;
if (this._place(win, st) || st.tries >= MAX_TRIES) {
this._timers.delete(id); return GLib.SOURCE_REMOVE;
}
return GLib.SOURCE_CONTINUE;
});
this._timers.add(id);
}
_place(win, st) {
const idx = this._targetMonitor();
if (idx < 0) return false; // touchscreen not plugged in
if (win.get_monitor() === idx) {
if (!win.is_fullscreen()) win.make_fullscreen();
return true;
}
if (!win.showing_on_its_workspace() && st.tries < 6) return false;
// Three ways of moving it, tried in turn. On mine, strategy 2 is the one that sticks.
const strategy = st.tries % 3;
try {
if (strategy === 0) {
win.move_to_monitor(idx);
} else if (strategy === 1) {
if (win.is_fullscreen()) win.unmake_fullscreen();
win.move_to_monitor(idx);
win.make_fullscreen();
} else {
const g = global.display.get_monitor_geometry(idx);
if (win.is_fullscreen()) win.unmake_fullscreen();
win.move_resize_frame(false, g.x, g.y, g.width, g.height);
win.make_fullscreen();
}
} catch (e) {}
return win.get_monitor() === idx;
}
}
{
"uuid": "kiosk-pin@example",
"name": "Kiosk Pin",
"description": "Keeps the kiosk Chrome window on the touchscreen.",
"shell-version": ["49", "50"],
"version": 1
}
Put the connector name from the Wiring step in ~/.config/kiosk-pin.conf (just DP-2 on its own line,
or whatever yours is). My full version logs every move to the journal with a [kiosk-pin] tag, which is
how I worked out which of the three moves actually wins. Leave some logging in yours until it behaves.
Step by step
- Add the
intake_recordstable and the signatures folder to your job system. Add the signatures folder to your backup now, not later. - Add the
/submitroute and make sure it works without a login, then test it withcurlbefore there’s any page in front of it. - Build the kiosk page: landing, form, thank you. Use Chrome’s developer tools with touch emulation on while you build, then move to the real touchscreen.
- Add the staff side: a Waivers page that lists and searches the intake records and shows the signature, and a “View signed waiver” button on each job.
- Plug in the touchscreen. Find its connector name with the EDID loop above.
- Install
kiosk-guard.shand the user service:systemctl --user enable --now kiosk-guard.service. - On GNOME, install the extension, put the connector in
~/.config/kiosk-pin.conf, then log out and back in. GNOME only notices a new extension at login. Thengnome-extensions enable kiosk-pin@example. - In Settings, Displays, make one of the staff monitors the primary display, not the touchscreen.
- Reboot and watch where the kiosk lands.
Testing it
-
Is the page serving? This should print
200:curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/kiosk -
Fill it in as a customer, on the real touchscreen, with a finger. Then check three places: the job is on the job board as pending, the PNG is in the signatures folder with the job number in its name, and the Waivers page shows the signature. Delete the test job afterwards.
-
Try to break the form. Submit should stay greyed out until name, phone, the tick and a signature are all there. Open and close the full terms and check nothing you typed is lost.
-
Test the phone lookup three ways: a new customer, an existing phone with the same name, and an existing phone with a different name.
-
Walk away from it. After 90 seconds it should be back on the landing screen with an empty form.
-
Reboot the PC and check the kiosk comes up on the touchscreen by itself. On GNOME,
journalctl --user -b | grep kiosk-pinshows what the extension did, if you left logging in. -
Check the backup has the signatures, not just the database. Open one PNG from the backup.
Lessons learnt
A hard-coded screen position put the kiosk on the wrong screen. The first version, on Windows, opened
Chrome with --window-position=3840,0 because that’s where the touchscreen was. Then the monitors got
rearranged and the touchscreen ended up at 1920,0. The kiosk opened on a staff monitor, and other windows
drifted onto the customer’s screen. The fix was a guard script that found the touchscreen by its hardware
ID instead, every 2 seconds, moved the kiosk onto it, swept any other window off it, and kept the mouse on
the staff monitors. Never use coordinates. Monitors get moved.
Moving to Linux took most of that away. When the workshop PC went to Ubuntu, I found Wayland doesn’t
let one app move another app’s windows, and doesn’t let it fence in the mouse. Plain --kiosk opened on
the main monitor. I then tried four things that all failed. cage, a one-app display server, ignored the
setting that picks a screen (that version simply doesn’t have it) and spread across all three. Running
cage on its own console failed with “Permission denied” because GNOME already owns the graphics card,
and two display servers can’t share one. The old X11 desktop, which would have allowed the Windows
approach, has been removed from Ubuntu 26.04 altogether. And GNOME’s own window rules for kiosks turned
out to be read only by the separate gnome-kiosk session, not the normal desktop. The file sat there doing
nothing, and the kiosk still came up on the wrong screen after a reboot. The GNOME Shell extension worked
because it runs inside the desktop, which is the one thing allowed to move windows. I’ve still lost the
mouse fence and the window sweeping from the Windows version.
Moving the window once wasn’t enough: Chrome moved itself back. The first version of the extension
moved the kiosk window to the touchscreen as soon as it appeared. The log showed Chrome putting itself back
on the main monitor within the first second, and the desktop letting it. The version that works keeps
trying every half second and puts it back whenever it moves, rotating between three ways of moving it. The
one that wins on mine is: leave fullscreen, resize the window to exactly the touchscreen’s area, then go
fullscreen again. Also, GNOME caches extensions: after any edit to extension.js you have to log out and
back in. Turning it off and on again doesn’t reload it.
Chrome remembered an error page. After a reboot, the kiosk can start before the job system is up. Chrome shows its “can’t reach this page” screen, and a kiosk has no address bar to fix it from. The guard script now waits until the kiosk page actually answers before it starts Chrome at all.
The signatures aren’t in the database, and that bit me in a move. When the job system moved to a new
machine, the database came across fine. The 272 signature files and the job photos didn’t. They were owned
by root, because the Docker containers wrote them, so copying them as my normal user failed with
“Permission denied” on every file. The copy didn’t look like it had failed: the database went across and
nothing stopped. I caught it, made the archive with sudo to a file, and checked it matched at both ends.
Two rules came out of that: back up the signatures folder with the database, and after any copy, count the
files at the other end.