Open the cash drawer for real, and persist the back-office route
Cash drawer - openCashDrawer was a debugPrint. The drawer never opened. - It cannot go through the PDF pipeline: a PDF is rendered by the platform driver, which will not pass raw ESC/POS bytes to the device. So it goes over a socket instead — nearly every network thermal printer listens on 9100 and forwards whatever arrives straight to the print head, which makes the whole protocol five bytes. - Printer IP and port are configurable in Settings with a Test button that saves and fires immediately, because a drawer that does not open is indistinguishable from one that is not wired up. - Every failure explains itself: unreachable, refused, or simply not configured — which is the honest state for a USB printer, since there is no raw path to one from Flutter. - Now fires only on a cash tender. A card-only sale that pops the drawer is a shrinkage risk, and it is the first thing a shop notices. Back-office route - Host, port, TLS and transport persist to the database; username, password and API key go to the platform keystore (Keychain / Credential Manager / Android Keystore). Writing credentials into SQLite would put them in the same file as the bills, on a machine behind a shop counter. - Loaded at startup. Previously the dialog wrote settings that were silently ignored on the next launch, which reads exactly like they never saved — and credentials retyped every morning end up on a sticky note instead. - A saved route never overwrites the terminal's store or terminal id. Those belong to the device, and re-pointing a till at a different broker must not change who it is, or its bills and presence records stop lining up. Tests: 168 -> 176. The drawer test stands up a real socket server and asserts the exact bytes arrive. The config test asserts no credential appears anywhere in the meta table while the non-secret settings do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import '../local/app_database.dart';
|
||||
import '../local/catalogue_dao.dart';
|
||||
import '../local/order_dao.dart';
|
||||
import '../local/staff_dao.dart';
|
||||
import '../local/sync_config_store.dart';
|
||||
import '../local/sync_log_dao.dart';
|
||||
import '../local/terminal_identity.dart';
|
||||
|
||||
@@ -23,6 +24,7 @@ class LocalStore {
|
||||
late OrderDao orders;
|
||||
late SyncLogDao syncLog;
|
||||
late StaffDao staff;
|
||||
late SyncConfigStore syncConfig;
|
||||
late TerminalIdentityStore identityStore;
|
||||
|
||||
/// Who this till is. Minted on first run, then stable forever.
|
||||
@@ -52,6 +54,7 @@ class LocalStore {
|
||||
orders = OrderDao(AppDatabase.instance.db);
|
||||
syncLog = SyncLogDao(AppDatabase.instance.db);
|
||||
staff = StaffDao(AppDatabase.instance.db);
|
||||
syncConfig = SyncConfigStore(catalogue);
|
||||
identityStore = TerminalIdentityStore(catalogue);
|
||||
|
||||
// A terminal with no staff cannot be signed into at all, so this runs
|
||||
|
||||
@@ -414,6 +414,19 @@ class MetaKeys {
|
||||
static const String storePhone = 'store_phone';
|
||||
static const String storePlan = 'store_plan';
|
||||
|
||||
/// How this terminal reaches the back office. Non-secret only — the username,
|
||||
/// password and API key go to the platform keystore, not here.
|
||||
static const String syncTransport = 'sync_transport';
|
||||
static const String syncBrokerHost = 'sync_broker_host';
|
||||
static const String syncBrokerPort = 'sync_broker_port';
|
||||
static const String syncUseTls = 'sync_use_tls';
|
||||
static const String syncHttpBaseUrl = 'sync_http_base_url';
|
||||
|
||||
/// Network printer that owns the cash drawer, if it is not the receipt
|
||||
/// printer itself.
|
||||
static const String drawerHost = 'drawer_host';
|
||||
static const String drawerPort = 'drawer_port';
|
||||
|
||||
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
|
||||
/// `Printing.directPrintPdf` needs to target it without a dialog.
|
||||
static const String printerUrl = 'printer_url';
|
||||
|
||||
@@ -239,6 +239,16 @@ class CatalogueDao {
|
||||
return rows.isEmpty ? null : rows.first['value'] as String?;
|
||||
}
|
||||
|
||||
/// Every stored setting, for support and for tests that assert what is *not*
|
||||
/// in here — credentials, most of all.
|
||||
Future<Map<String, String>> allMeta() async {
|
||||
final rows = await _db.query(Tables.meta);
|
||||
return {
|
||||
for (final row in rows)
|
||||
row['key']! as String: (row['value'] as String?) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> setMeta(String key, String value) async {
|
||||
await _db.insert(
|
||||
Tables.meta,
|
||||
|
||||
105
lib/data/local/sync_config_store.dart
Normal file
105
lib/data/local/sync_config_store.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../core/config/sync_config.dart';
|
||||
import 'app_database.dart';
|
||||
import 'catalogue_dao.dart';
|
||||
|
||||
/// Persists how this terminal reaches the back office.
|
||||
///
|
||||
/// Split deliberately across two stores. Which broker, on which port, over TLS
|
||||
/// — that is configuration, and it goes in the database where it can be read
|
||||
/// during support. The username, password and API key are credentials, and go
|
||||
/// to the platform keystore: Keychain on macOS, Credential Manager on Windows,
|
||||
/// the Android Keystore on a tablet.
|
||||
///
|
||||
/// Writing them into SQLite would put them in the same file as the bills, on a
|
||||
/// machine behind a shop counter, readable by anything that can open it.
|
||||
class SyncConfigStore {
|
||||
SyncConfigStore(this._catalogue, {FlutterSecureStorage? secureStorage})
|
||||
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
final CatalogueDao _catalogue;
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
static const _kUsername = 'sync.username';
|
||||
static const _kPassword = 'sync.password';
|
||||
static const _kApiKey = 'sync.api_key';
|
||||
|
||||
/// Reads the stored configuration, falling back to [fallback] per field.
|
||||
///
|
||||
/// The fallback carries the terminal's own store and terminal ids, which are
|
||||
/// never overwritten from here — they belong to the device's identity.
|
||||
Future<SyncConfig> load(SyncConfig fallback) async {
|
||||
final transport = await _catalogue.meta(MetaKeys.syncTransport);
|
||||
final host = await _catalogue.meta(MetaKeys.syncBrokerHost);
|
||||
final port = await _catalogue.meta(MetaKeys.syncBrokerPort);
|
||||
final tls = await _catalogue.meta(MetaKeys.syncUseTls);
|
||||
final httpUrl = await _catalogue.meta(MetaKeys.syncHttpBaseUrl);
|
||||
|
||||
final credentials = await _readCredentials();
|
||||
|
||||
return fallback.copyWith(
|
||||
transport: TransportKind.values
|
||||
.where((k) => k.name == transport)
|
||||
.firstOrNull ??
|
||||
fallback.transport,
|
||||
brokerHost: host ?? fallback.brokerHost,
|
||||
brokerPort: int.tryParse(port ?? '') ?? fallback.brokerPort,
|
||||
useTls: tls == null ? fallback.useTls : tls == '1',
|
||||
httpBaseUrl: httpUrl ?? fallback.httpBaseUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
apiKey: credentials.apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(SyncConfig config) async {
|
||||
await _catalogue.setMeta(MetaKeys.syncTransport, config.transport.name);
|
||||
await _catalogue.setMeta(MetaKeys.syncBrokerHost, config.brokerHost);
|
||||
await _catalogue.setMeta(MetaKeys.syncBrokerPort, '${config.brokerPort}');
|
||||
await _catalogue.setMeta(MetaKeys.syncUseTls, config.useTls ? '1' : '0');
|
||||
await _catalogue.setMeta(MetaKeys.syncHttpBaseUrl, config.httpBaseUrl);
|
||||
|
||||
await _writeSecret(_kUsername, config.username);
|
||||
await _writeSecret(_kPassword, config.password);
|
||||
await _writeSecret(_kApiKey, config.apiKey);
|
||||
}
|
||||
|
||||
Future<({String? username, String? password, String? apiKey})>
|
||||
_readCredentials() async {
|
||||
try {
|
||||
return (
|
||||
username: await _secure.read(key: _kUsername),
|
||||
password: await _secure.read(key: _kPassword),
|
||||
apiKey: await _secure.read(key: _kApiKey),
|
||||
);
|
||||
} on Object catch (e) {
|
||||
// No keystore — a headless test host, or a Linux box with no secret
|
||||
// service. The terminal still runs; it just cannot authenticate until
|
||||
// someone re-enters the credentials, which is the safe way to fail.
|
||||
debugPrint('Secure storage unavailable, credentials not loaded: $e');
|
||||
return (username: null, password: null, apiKey: null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeSecret(String key, String? value) async {
|
||||
try {
|
||||
if (value == null || value.isEmpty) {
|
||||
await _secure.delete(key: key);
|
||||
} else {
|
||||
await _secure.write(key: key, value: value);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
debugPrint('Could not persist $key to secure storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipes stored credentials. Used when a terminal is handed on or re-pointed
|
||||
/// at a different back office.
|
||||
Future<void> clearCredentials() async {
|
||||
for (final key in [_kUsername, _kPassword, _kApiKey]) {
|
||||
await _writeSecret(key, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user