Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .github/workflows/windows-printer-tests.yml

This file was deleted.

18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,21 @@ on and what is off, and that is the only question it answers.
Integrations is for what genuinely faces outward on its own - API tokens,
webhooks, signed connectors, the shop's own analytics property - not for any
feature that happens to call somebody else's API.


## Local verification and CI costs

GitHub Actions spending is constrained. Prefer local verification.

- Run relevant tests, lint, packaging checks and attribution checks locally
before pushing. Report the commands, results and anything not verified.
- Do not create GitHub Actions workflows or add CI jobs, matrix entries,
scheduled runs, runners or broader triggers unless the user explicitly
requests that CI expansion. A feature or bug-fix request is not authorization.
- Keep regression tests in the repository and run them locally, including
Windows-specific printer checks on Windows. Removing a workflow does not
mean removing its tests.
- Batch changes and push after local checks pass. Do not repeatedly push or
rerun unchanged CI jobs for experimentation; reproduce failures locally.
- Preserve existing required checks and security protections. Do not disable
them or bypass branch protection without explicit user authorization.
2 changes: 1 addition & 1 deletion builds/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Var POSNIC_DELETE_DATA
; stalled there until it was answered.
IfSilent unposnic_done

MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON1 "Keep your business data (sales database and backups)?$\r$\n$\r$\nYES - Keep my data (recommended). Reinstalling Posnic later will find everything exactly as it was.$\r$\n$\r$\nNO - I want to permanently delete everything from this computer." /SD IDYES IDYES unposnic_done
MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON1 "Keep your business data and hardware settings?$\r$\n$\r$\nYES - Keep my data (recommended for updates and test reinstalls). Keeps sales, backups, printer selections, paper sizes and other hardware settings. Reinstall using the same Windows account.$\r$\n$\r$\nNO - I want to permanently delete everything from this computer." /SD IDYES IDYES unposnic_done

MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "WARNING - PERMANENT DELETION$\r$\n$\r$\nThis will erase ALL sales history, inventory, customers and settings from this computer. If you do not use Posnic Cloud, this is the ONLY copy of your business data - it CANNOT be recovered.$\r$\n$\r$\nYour backup folder (Documents\Posnic-Backups) will also be deleted. If you may ever need this data, click NO now and first copy that folder to a pen drive or another computer.$\r$\n$\r$\nPermanently delete everything?" /SD IDNO IDYES unposnic_wipe
Goto unposnic_done
Expand Down
18 changes: 18 additions & 0 deletions docs/WINDOWS-PRINTER-RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,21 @@ Do not replay diagnostic job metadata or actual orders. Keep a record of the sel
Automated tests use synthetic jobs and mock transports; Windows-only tests execute the actual PowerShell resolver with synthetic device records. They cannot establish that an extender or printer remains physically connected indefinitely.

References: [Microsoft powercfg options](https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/powercfg-command-line-options), [USB selective suspend](https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/usb-selective-suspend).

### Keeping the KOT computer awake and retaining settings

While kitchen printing is running, Posnic holds an Electron system-sleep
blocker. Pausing kitchen printing or quitting Posnic releases it. It does not
move the mouse, prevent screen locking, or send paper to keep Windows awake.
Forced sleep, signing out, shutdown and physical USB loss are not prevented.

For a test upgrade, install the EXE over the existing installation. A manual
uninstall also preserves configuration when **Keep my data** is selected.
Use the same Windows account and application identity. Choosing permanent
**Delete everything** removes settings too; that choice is intentionally
respected. Settings are not stored beside the EXE.

Printer preferences and KOT configuration are now saved by writing and flushing
a temporary file before replacing the existing file. Failed saves report an
error instead of silently claiming success. This protects against interrupted
writes; it cannot recover settings that were already deleted by an old uninstall.
22 changes: 21 additions & 1 deletion src/device-preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ function prefsPath() {
}

/** Everything this machine was told. Never throws; an unreadable file is {}. */
// Replace only after the complete JSON is durable. An interrupted save must
// leave the previous configuration readable, not a truncated preferences file.
function saveJson(file, value) {
const temp = file + '.tmp';
let fd;
try {
fd = fs.openSync(temp, 'w', 0o600);
fs.writeFileSync(fd, JSON.stringify(value, null, 2), 'utf8');
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temp, file);
} finally {
if (fd !== undefined) fs.closeSync(fd);
try { fs.unlinkSync(temp); } catch (_) { /* renamed, or never created */ }
}
}

function all() {
const file = prefsPath();
if (!file) return {};
Expand Down Expand Up @@ -135,7 +153,8 @@ function cloudPrintRelay() {
*/
function kotPrinterNames() {
try {
const file = path.join(app.getPath('userData'), 'kot-config.json');
const { app } = require('electron');
const file = path.join(app.getPath('userData'), 'kot-config.json');
if (!fs.existsSync(file)) return [];
const cfg = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
const fromList = Array.isArray(cfg.printers)
Expand Down Expand Up @@ -208,6 +227,7 @@ function validateDocumentPrintSettings(value) {
}

module.exports = {
saveJson,
all,
get,
receiptPrinterName,
Expand Down
17 changes: 9 additions & 8 deletions src/hardware-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,7 @@ function setupHardwareIPC(hardwareManager, kotManager, billManager) {
}

function _savePrefs(prefs) {
try {
fs.writeFileSync(_prefsPath, JSON.stringify(prefs, null, 2), 'utf8');
} catch (e) { /* ignore */ }
require('./device-preferences').saveJson(_prefsPath, prefs);
}

const preferences = _loadPrefs();
Expand All @@ -537,7 +535,7 @@ function setupHardwareIPC(hardwareManager, kotManager, billManager) {
const next = { ...preferences, item_print_languages: settings.itemLanguages || preferences.item_print_languages, receipt_printers: JSON.stringify(settings.sales),
receipt_printer: settings.sales[0].name, print_width: settings.sales[0].pageSize,
document_print_profiles: { invoice: settings.invoice, quotation: settings.quotation } };
fs.writeFileSync(_prefsPath, JSON.stringify(next, null, 2));
require('./device-preferences').saveJson(_prefsPath, next);
Object.assign(preferences, next);
return { success: true, settings };
} catch (error) { return { success: false, error: error.message }; }
Expand Down Expand Up @@ -589,17 +587,20 @@ function setupHardwareIPC(hardwareManager, kotManager, billManager) {
*/
if (!preferences.cloud_print_key) {
preferences.cloud_print_key = require('crypto').randomBytes(32).toString('hex');
_savePrefs(preferences);
console.log('[BILL] made this till a printing key for the cloud');
try {
_savePrefs(preferences);
console.log('[BILL] made this till a printing key for the cloud');
} catch (error) { console.error('[hardware] Could not persist relay key:', error.message); }
}

ipcMain.handle('preferences:get', (event, key) => {
return preferences[key] ?? null;
});

ipcMain.handle('preferences:set', (event, key, value) => {
preferences[key] = value;
_savePrefs(preferences);
const next = { ...preferences, [key]: value };
_savePrefs(next);
Object.assign(preferences, next);
if (key === 'mobile.maxDevices' && global.mobileTracker) {
global.mobileTracker.maxDevices = Math.max(1, parseInt(value, 10) || 6);
}
Expand Down
1 change: 1 addition & 0 deletions src/hardware-manager.html
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ <h4>Windows settings for reliable printing</h4>
</div>
</div>

<p style="color:#526174;font-size:13px;">While kitchen printing is running, Posnic prevents automatic system sleep. Your screen can still turn off or lock. Pause printing to allow normal sleep. Keep Posnic running; this does not prevent USB disconnections.</p>
<!-- Action buttons -->
<div style="display:flex;gap:14px;margin-bottom:24px;">
<button id="kotStartBtn" onclick="kotStart()" class="btn btn-success" style="flex:1;padding:16px;font-size:16px;">▶ Resume kitchen printing</button>
Expand Down
4 changes: 3 additions & 1 deletion src/kot-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ class KOTManager {

async saveConfig(config) {
try {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf8');
require('./device-preferences').saveJson(this.configPath, config);
} catch (e) {
console.error('[KOT] Failed to save config:', e.message);
throw e;
Expand Down Expand Up @@ -554,6 +554,7 @@ class KOTManager {
this.stopPolling();
this.config = next;
this.isPolling = true;
require('./till-stays-awake').setKitchenPrinting(true);
console.log('[KOT] Polling started — branch:', config.branchId, '| printers:', config.printerNames);
this._poll();
}
Expand All @@ -577,6 +578,7 @@ class KOTManager {
/* A note left by a nudge must not outlive the polling it was for. */
this._pollAgain = false;
this.isPolling = false;
require('./till-stays-awake').setKitchenPrinting(false);
console.log('[KOT] Polling stopped');
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4817,7 +4817,7 @@ app.whenReady().then(async () => {
*/
try {
const power = require('./till-stays-awake');
power.start();
power.start(!!kotManager?.isPolling);
power.whenWokenUp(() => {
/* Every path that collects work, not only the local one: a till that
slept through a cloud-relayed bill has to ask for that too. */
Expand Down
53 changes: 12 additions & 41 deletions src/till-stays-awake.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,8 @@
'use strict';

/*
* A TILL THAT IS ASLEEP IS A TILL THAT IS NOT PRINTING.
*
* Owner: "desktop app system got logged out or screen lock, then also our app
* should keep wake."
*
* THE FAILURE, WHICH NEEDS NO QUEUE TO HURT
*
* Windows sleeps an idle machine. A restaurant till is idle for long stretches
* between services, and the moment it sleeps the kitchen stops receiving
* tickets and handsets stop reaching it - while the screen still says Posnic
* and nothing anywhere reports a fault. The waiter presses send, the phone says
* sent, and no paper comes out. Nobody finds out until somebody walks to the
* printer.
*
* Two separate things are needed and they are routinely confused:
*
* SLEEP is the machine suspending. It stops the process, the network and the
* printer. That is what powerSaveBlocker prevents.
*
* THE LOCK SCREEN is not sleep. A locked Windows session keeps running
* everything, and printing continues. So locking the till is fine and should
* be encouraged - it is LOGGING OUT that kills it, because the session ends.
*
* WHAT THIS DELIBERATELY DOES NOT DO
*
* It does not keep the machine awake for ever. A till blocked from sleeping
* around the clock runs hotter, wears its disk, and takes Windows updates at
* whatever hour it likes. The block is held while the shop is trading and
* released when it is not, which is the honest version of "keep awake".
*
* And it never blocks the DISPLAY from turning off, only the system from
* suspending. A kitchen screen that cannot blank is a kitchen screen that burns
* in.
*
* NOTHING HERE MAY THROW. Every one of these APIs is optional in a way the
* documentation does not admit: powerSaveBlocker is absent under some remote
* sessions, and powerMonitor emits nothing on a machine with no ACPI. A till
* must start and sell whatever this module manages to do.
/* Prevent automatic system sleep only while kitchen printing is active.
* The screen may turn off and lock normally. This cannot prevent forced sleep,
* Windows logout, or a USB extender/device from losing its connection.
*/

/* Required lazily so the module loads and can be tested outside Electron. */
Expand Down Expand Up @@ -164,9 +128,9 @@ function watch() {
}

/** Everything, wrapped, for one call at startup. Never throws. */
function start() {
function start(enabled = false) {
try {
const held = keepAwake();
const held = setKitchenPrinting(enabled);
const watched = watch();
return { held, watched };
} catch (err) {
Expand All @@ -175,6 +139,12 @@ function start() {
}
}

function setKitchenPrinting(enabled) {
if (enabled === true) return keepAwake();
allowSleep();
return false;
}

function stop() {
try {
allowSleep();
Expand All @@ -184,6 +154,7 @@ function stop() {
}

module.exports = {
setKitchenPrinting,
keepAwake,
allowSleep,
isAwakeHeld,
Expand Down
3 changes: 2 additions & 1 deletion tests/document-print-settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ function ipc(prefs, file, fileSystem = fs) {
const end = source.indexOf('\n /*', from);
new Function('ipcMain', 'require', 'preferences', 'fs', '_prefsPath', source.slice(from, end))(
{ handle: (key, fn) => { handlers[key] = fn; } },
() => ({ documentPrintSettings, validateDocumentPrintSettings }), prefs, fileSystem, file);
() => ({ documentPrintSettings, validateDocumentPrintSettings,
saveJson: fileSystem === fs ? require('../src/device-preferences').saveJson : fileSystem.writeFileSync }), prefs, fileSystem, file);
return handlers;
}

Expand Down
53 changes: 53 additions & 0 deletions tests/kot-power-preferences.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
function load(name, electron, fileSystem = fs) {
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(path.join(root, 'src', name), 'utf8'), {
module, exports: module.exports, console,
require: id => id === 'electron' ? electron : id === 'fs' ? fileSystem : require(id),
});
return module.exports;
}
test('KOT lifecycle holds one blocker, permits display lock, and releases on pause/quit', () => {
const active = new Set(); const events = {}; let starts = 0;
const power = load('till-stays-awake.js', {
powerSaveBlocker: {
start(type) { assert.equal(type, 'prevent-app-suspension'); active.add(++starts); return starts; },
isStarted: id => active.has(id), stop: id => active.delete(id),
},
powerMonitor: { on: (name, fn) => { events[name] = fn; } },
});
power.start(false); assert.equal(active.size, 0);
power.setKitchenPrinting(true); power.setKitchenPrinting(true);
assert.equal(starts, 1); assert.equal(active.size, 1);
events['lock-screen'](); assert.equal(active.size, 1);
power.setKitchenPrinting(false); assert.equal(active.size, 0);
power.start(true); assert.equal(active.size, 1);
power.stop(); assert.equal(active.size, 0);
});
test('hardware choices survive module restart and failed replacement; kitchen assignments remain readable', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'posnic-prefs-'));
try {
const electron = { app: { getPath: () => dir } };
const prefs = load('device-preferences.js', electron);
const file = path.join(dir, 'preferences.json');
prefs.saveJson(file, { receipt_printer: 'Reception_Printer', print_width: '80mm' });
prefs.saveJson(path.join(dir, 'kot-config.json'), { printerNames: ['Kitchen_Printer'] });
const restarted = load('device-preferences.js', electron);
assert.equal(restarted.receiptPrinterName(), 'Reception_Printer');
assert.equal(restarted.isKitchenPrinter('Kitchen_Printer'), true);
assert.equal(restarted.isKitchenPrinter('Reception_Printer'), false);
const broken = load('device-preferences.js', electron, {
...fs, renameSync() { throw new Error('Access denied'); },
});
assert.throws(() => broken.saveJson(file, { receipt_printer: 'Wrong' }), /Access denied/);
assert.equal(restarted.receiptPrinterName(), 'Reception_Printer');
assert.equal(fs.existsSync(file + '.tmp'), false);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
2 changes: 1 addition & 1 deletion tests/scheduled-task.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const path = require('path');
const st = require('../src/scheduled-task');

const ROOT = path.join(__dirname, '..');
const MAIN = fs.readFileSync(path.join(ROOT, 'src', 'main.js'), 'utf8');
const MAIN = fs.readFileSync(path.join(ROOT, 'src', 'main.js'), 'utf8').replace(/\r\n/g, '\n');

test('only tasks main.js can actually run may be scheduled', () => {
const fn = MAIN.slice(MAIN.indexOf('async function runScheduledTaskAndExit'));
Expand Down
2 changes: 1 addition & 1 deletion tests/tender-receipt-preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ test('the guarded desktop preview handler never contacts hardware or opens the d
const handlers = new Map();
const mod = { exports: {} };
vm.runInNewContext(read('src/hardware-ipc.js'), {
module: mod, console: { log() {} }, global: {}, process, setTimeout() {},
module: mod, console: { log() {}, error() {} }, global: {}, process, setTimeout() {},
require(name) {
if (name === 'electron') return {
app: { getPath: () => path.join(__dirname, 'fixtures', 'no-user-data') },
Expand Down
Loading