+{/if}
diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts
index 2cab7d5..b5a0d5e 100644
--- a/src/lib/core/adb/adb.ts
+++ b/src/lib/core/adb/adb.ts
@@ -11,6 +11,7 @@ import { Consumable, MaybeConsumable, ReadableStream } from '@yume-chan/stream-e
import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
import { addNotification } from '../stores/noti';
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
+import { GlobalEventBus } from '../utils/eventBus';
import { adbWriter } from '../stores/adbWriter';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
@@ -137,7 +138,7 @@ async function connectWithRetry(
export async function connnectViaWebUSB(connectAndroidServer = true) {
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
- console.log('usb ok', globalThis.navigator.usb);
+ console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
if (device) {
console.log('connect ', device.name);
@@ -362,6 +363,92 @@ export async function executeCmd(command: string) {
}
}
+export async function goToMachineHome() {
+ if (!getAdbInstance()) return;
+ try {
+ await executeCmd('input keyevent KEYCODE_HOME');
+ } catch (e) {
+ console.error('[goToMachineHome] error', e);
+ }
+}
+
+/**
+ * Execute an ADB command and stream its output via callbacks.
+ * Used for commands like `logcat` that run indefinitely.
+ *
+ * Returns a cleanup function that can be called to abort the stream.
+ */
+export async function executeStreamingCmd(
+ command: string,
+ callbacks: {
+ onData?: (chunk: string) => void;
+ onError?: (error: string) => void;
+ onExit?: (exitCode: number | undefined) => void;
+ }
+): Promise<() => void> {
+ const instance = getAdbInstance();
+ let aborted = false;
+
+ if (!instance) {
+ callbacks.onError?.('No ADB device connected');
+ return () => {};
+ }
+
+ try {
+ // NOTE: Always use noneProtocol.spawn() for streaming.
+ // shellProtocol.spawnWaitText() waits for the process to exit — fine for
+ // batch commands like 'echo foo', but logcat runs indefinitely, so the
+ // Promise would never resolve and onData would never fire.
+ const process = await instance.subprocess.noneProtocol.spawn(command);
+
+ const reader = process.output.getReader();
+ const decoder = new TextDecoder();
+
+ // Start reading in the background
+ (async () => {
+ try {
+ while (!aborted) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const text = decoder.decode(value, { stream: true });
+ if (text && !aborted) {
+ callbacks.onData?.(text);
+ }
+ }
+ } catch (e: any) {
+ if (!aborted) {
+ callbacks.onError?.(e.message ?? 'Stream read error');
+ }
+ } finally {
+ if (!aborted) {
+ reader.releaseLock();
+ callbacks.onExit?.(0);
+ }
+ }
+ })();
+
+ // Return cleanup function to abort the stream
+ return () => {
+ aborted = true;
+ try {
+ reader.cancel();
+ } catch {
+ // reader may already be done
+ }
+ };
+ } catch (e: any) {
+ if (!aborted) {
+ if (e.message?.includes('ExactReadable ended')) {
+ callbacks.onError?.('Connection closed');
+ } else {
+ callbacks.onError?.(e.message ?? 'Unknown error');
+ }
+ }
+ }
+
+ return () => {};
+}
+
export async function disconnect() {
let instance = getAdbInstance();
if (instance) {
@@ -526,12 +613,32 @@ async function connectToAndroidServer(maxRetries = 5) {
if (writer) {
addNotification('INFO:Enable Brewing Mode T on machine');
+ const textDecoder = new TextDecoder();
+ let buffer = '';
+
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
- handleAdbPayload(new TextDecoder().decode(value));
+
+ // decode chunk
+ buffer += textDecoder.decode(value, { stream: true });
+
+ let lines = buffer.split('\n');
+
+ // save potential incomplete
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (line.trim() === '') continue;
+
+ GlobalEventBus.emit('adb:raw-payload', line);
+ handleAdbPayload(line);
+ }
+
+ // GlobalEventBus.emit('adb:raw-payload', new TextDecoder().decode(value));
+ // handleAdbPayload(new TextDecoder().decode(value));
}
} catch (e) {
console.error('read error', e);
@@ -637,6 +744,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
const trimmedMessage = message.trim();
if (trimmedMessage) {
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
+ GlobalEventBus.emit('adb:raw-payload', trimmedMessage);
handleAdbPayload(trimmedMessage);
}
}
@@ -644,6 +752,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
const remainingMessage = messageBuffer.trim();
if (remainingMessage) {
+ GlobalEventBus.emit('adb:raw-payload', remainingMessage);
handleAdbPayload(remainingMessage);
}
} catch (e) {
diff --git a/src/lib/core/adb/adbTerminal.ts b/src/lib/core/adb/adbTerminal.ts
new file mode 100644
index 0000000..d665f99
--- /dev/null
+++ b/src/lib/core/adb/adbTerminal.ts
@@ -0,0 +1,1151 @@
+import { get } from 'svelte/store';
+import { getAdbInstance, executeCmd, executeStreamingCmd } from './adb';
+import type { Terminal } from '@xterm/xterm';
+import { GlobalEventBus } from '../utils/eventBus';
+import { addNotification } from '../stores/noti';
+import { sendToAndroid, adbWriter } from '../stores/adbWriter';
+
+/**
+ * ADB Terminal Service
+ *
+ * Bridges an xterm.js Terminal with the ADB connection.
+ * Supports:
+ * - Running raw adb shell commands
+ * - Built-in commands (!help, !payload {filter}, !cls, !exit, send, !status)
+ * - Real-time payload log viewing via !payload
+ * - Interactive ADB TCP socket payload sending via send command
+ */
+
+export type LogEntry = {
+ timestamp: Date;
+ level: string;
+ message: string;
+ raw: string;
+};
+
+export type PayloadSubscriptionCallback = (payload: any) => void;
+
+/**
+ * A recorded command output for the output-blocks display.
+ */
+export type CommandOutput = {
+ id: number;
+ command: string;
+ fullOutput: string;
+ lines: number;
+ level: 'info' | 'error' | 'success';
+ timestamp: number;
+};
+
+/**
+ * Callback invoked when a new command output is available.
+ * Set by terminal-drawer.svelte to reactively show output blocks.
+ */
+let _onCommandOutput: ((output: CommandOutput) => void) | null = null;
+let _onExpandOutputRequest: ((id: number) => void) | null = null;
+
+let _outputIdCounter = 0;
+let _commandOutputs: CommandOutput[] = [];
+
+/**
+ * Register a listener for new command outputs.
+ * Returns an unsubscribe function.
+ */
+export function onCommandOutput(cb: (output: CommandOutput) => void): () => void {
+ _onCommandOutput = cb;
+ return () => {
+ _onCommandOutput = null;
+ };
+}
+
+/**
+ * Register a listener for expand-output click requests from the terminal.
+ * Returns an unsubscribe function.
+ */
+export function onExpandOutputRequest(cb: (id: number) => void): () => void {
+ _onExpandOutputRequest = cb;
+ return () => {
+ _onExpandOutputRequest = null;
+ };
+}
+
+/**
+ * Get the current list of command outputs.
+ */
+export function getCommandOutputs(): CommandOutput[] {
+ return _commandOutputs;
+}
+
+/**
+ * Clear all stored command outputs.
+ */
+export function clearCommandOutputs() {
+ _commandOutputs = [];
+}
+
+/**
+ * Record a command output and notify listeners.
+ * Returns the recorded output.
+ */
+export function recordCommandOutput(cmd: string, fullOutput: string, level: CommandOutput['level'] = 'info') {
+ const id = ++_outputIdCounter;
+ const lines = fullOutput ? fullOutput.split('\n').filter(l => l.trim()).length : 0;
+ const output: CommandOutput = {
+ id,
+ command: cmd,
+ fullOutput,
+ lines,
+ level,
+ timestamp: Date.now()
+ };
+ _lastOutputId = id;
+ _commandOutputs.push(output);
+ if (_commandOutputs.length > MAX_OUTPUT_BLOCKS) {
+ _commandOutputs.shift();
+ }
+ _onCommandOutput?.(output);
+ return output;
+}
+
+let _lastOutputId = 0;
+
+/**
+ * Get the most recent command output ID (for terminal link click expansion).
+ */
+export function getLastOutputId(): number {
+ return _lastOutputId;
+}
+
+/**
+ * Trigger expand of a specific output (called from terminal-drawer link handler).
+ */
+export function requestExpandOutput(id: number) {
+ _onExpandOutputRequest?.(id);
+}
+
+const COMMAND_HISTORY_KEY = 'supra:adb_terminal_history';
+const MAX_HISTORY = 200;
+
+let _terminal: Terminal | null = null;
+let _commandHistory: string[] = [];
+let _historyIndex = -1;
+let _isPayloadMode = false;
+let _payloadFilter = '';
+let _payloadUnsubscribe: (() => void) | null = null;
+let _payloadLogBuffer: string[] = [];
+
+let _isLogcatMode = false;
+let _logcatCleanup: (() => void) | null = null;
+let _logcatBuffer: string[] = [];
+const LOGCAT_MAX_LINES = 5000; // keep last 5000 lines in buffer
+const LOGCAT_VISIBLE_LINES = 3; // show only 3 lines to terminal
+const MAX_OUTPUT_BLOCKS = 50; // keep last 50 command outputs
+const VISIBLE_OUTPUT_LINES = 3; // lines shown per command in terminal
+
+/**
+ * Load command history from localStorage
+ */
+function loadHistory(): string[] {
+ try {
+ const saved = localStorage.getItem(COMMAND_HISTORY_KEY);
+ return saved ? JSON.parse(saved) : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Save a command to history
+ */
+function saveToHistory(cmd: string) {
+ if (!cmd.trim()) return;
+ _commandHistory = _commandHistory.filter((c) => c !== cmd);
+ _commandHistory.push(cmd);
+ if (_commandHistory.length > MAX_HISTORY) {
+ _commandHistory = _commandHistory.slice(-MAX_HISTORY);
+ }
+ try {
+ localStorage.setItem(COMMAND_HISTORY_KEY, JSON.stringify(_commandHistory));
+ } catch {
+ // ignore storage errors
+ }
+ _historyIndex = _commandHistory.length;
+}
+
+/**
+ * Initialize the terminal session
+ */
+export function initTerminalSession(terminal: Terminal) {
+ _terminal = terminal;
+ _commandHistory = loadHistory();
+ _historyIndex = _commandHistory.length;
+ _isPayloadMode = false;
+
+ // Write welcome banner
+ const instance = getAdbInstance();
+ const deviceStatus = instance ? 'Connected' : 'Disconnected';
+
+ terminal.writeln('\x1b[36m╔══════════════════════════════════════════════════════╗\x1b[0m');
+ terminal.writeln(
+ '\x1b[36m║ \x1b[1mSupra ADB Terminal v1.0\x1b[22m ║\x1b[0m'
+ );
+ terminal.writeln('\x1b[36m╠══════════════════════════════════════════════════════╣\x1b[0m');
+ terminal.writeln(`\x1b[36m║ Device: \x1b[33m${deviceStatus.padEnd(43)}\x1b[36m║\x1b[0m`);
+ terminal.writeln(
+ `\x1b[36m║ Type \x1b[32m!help\x1b[0m\x1b[36m for available commands ║\x1b[0m`
+ );
+ terminal.writeln('\x1b[36m╚══════════════════════════════════════════════════════╝\x1b[0m');
+ terminal.write('\r\n');
+
+ prompt(terminal);
+}
+
+/**
+ * Re-initialize terminal after drawer reopen (terminal element stays mounted,
+ * but we need to re-fit and re-attach event handlers).
+ */
+export function reinitTerminalSession(terminal: Terminal) {
+ if (_terminal !== terminal) {
+ // If it's a completely new terminal instance, do full init
+ initTerminalSession(terminal);
+ return;
+ }
+ // Terminal is same instance — just show appropriate prompt
+ if (_isLogcatMode) {
+ terminal.writeln('\x1b[90m[Logcat streaming active]\x1b[0m');
+ prompt(terminal);
+ } else if (_isPayloadMode) {
+ // Re-show payload header
+ const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : '';
+ terminal.writeln(`\x1b[90m[Payload monitoring active${filterInfo}]\x1b[0m`);
+ prompt(terminal);
+ } else {
+ prompt(terminal);
+ }
+}
+
+/**
+ * Write a prompt to the terminal
+ */
+function prompt(terminal: Terminal) {
+ if (_isLogcatMode) {
+ terminal.write(`\r\n\x1b[33mlogcat\x1b[0m $ `);
+ } else if (_isPayloadMode) {
+ const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : '';
+ terminal.write(`\r\n\x1b[32mpayload${filterInfo}\x1b[0m $ `);
+ } else {
+ terminal.write(`\r\n\x1b[31m$\x1b[0m `);
+ }
+}
+
+/**
+ * Handle terminal data input (keypresses/input from xterm onData)
+ */
+export function handleTerminalData(data: string) {
+ if (!_terminal) return;
+
+ const terminal = _terminal;
+
+ // Handle special keys
+ switch (data) {
+ case '\r': // Enter
+ handleEnter(terminal);
+ return;
+ case '\u007f': // Backspace
+ handleBackspace(terminal);
+ return;
+ case '\u001b[A': // Up arrow
+ handleHistoryUp(terminal);
+ return;
+ case '\u001b[B': // Down arrow
+ handleHistoryDown(terminal);
+ return;
+ case '\u0003': // Ctrl+C
+ handleCtrlC(terminal);
+ return;
+ case '\u0001': // Ctrl+A — beginning of line
+ terminal.write(`\x1b[${_cursorPos}D`);
+ _cursorPos = 0;
+ return;
+ case '\u0005': // Ctrl+E — end of line
+ {
+ const charsToEnd = _currentInput.length - _cursorPos;
+ if (charsToEnd > 0) {
+ terminal.write(`\x1b[${charsToEnd}C`);
+ _cursorPos = _currentInput.length;
+ }
+ }
+ return;
+ case '\u001b[D': // Left arrow
+ handleCursorLeft(terminal);
+ return;
+ case '\u001b[C': // Right arrow
+ handleCursorRight(terminal);
+ return;
+ case '\t': // Tab
+ // Ignore tab
+ return;
+ default:
+ // Only write printable characters
+ if (data.length === 1 && data >= ' ') {
+ // If in payload mode or logcat mode, stop it when user types a new command
+ if (_isPayloadMode && data.trim()) {
+ stopPayloadMode(terminal);
+ }
+ if (_isLogcatMode && data.trim()) {
+ stopLogcatMode(terminal);
+ }
+ // Insert character at cursor position and redraw
+ _currentInput = _currentInput.slice(0, _cursorPos) + data + _currentInput.slice(_cursorPos);
+ _cursorPos++;
+ redrawInputLine(terminal);
+ }
+ return;
+ }
+}
+
+let _currentInput = '';
+let _cursorPos = 0;
+
+/**
+ * Redraw the full input line (prompt + current input) and position the cursor.
+ */
+function redrawInputLine(terminal: Terminal) {
+ terminal.write('\r\x1b[K');
+ if (_isLogcatMode) {
+ terminal.write('\x1b[33mlogcat\x1b[0m $ ');
+ } else if (_isPayloadMode) {
+ const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : '';
+ terminal.write(`\x1b[32mpayload${filterInfo}\x1b[0m $ `);
+ } else {
+ terminal.write('\x1b[31m$\x1b[0m ');
+ }
+ terminal.write(_currentInput);
+ const back = _currentInput.length - _cursorPos;
+ if (back > 0) {
+ terminal.write(`\x1b[${back}D`);
+ }
+}
+
+function handleCursorLeft(terminal: Terminal) {
+ if (_cursorPos > 0) {
+ _cursorPos--;
+ terminal.write('\x1b[D');
+ }
+}
+
+function handleCursorRight(terminal: Terminal) {
+ if (_cursorPos < _currentInput.length) {
+ _cursorPos++;
+ terminal.write('\x1b[C');
+ }
+}
+
+function handleEnter(terminal: Terminal) {
+ // Get the current line content from xterm
+ const line = _currentInput.trim();
+ _currentInput = '';
+ _cursorPos = 0;
+
+ terminal.write('\r\n');
+
+ if (_isLogcatMode) {
+ // In logcat mode, empty Enter = stop logcat, text = execute command
+ if (!line) {
+ stopLogcatMode(terminal);
+ } else {
+ // Stop logcat, then execute the command
+ stopLogcatMode(terminal);
+ executeLine(terminal, line);
+ }
+ return;
+ }
+
+ if (_isPayloadMode) {
+ // In payload mode, treat Enter as continuing to watch
+ // If user typed something, exit payload mode and execute
+ if (line) {
+ stopPayloadMode(terminal);
+ executeLine(terminal, line);
+ } else {
+ prompt(terminal);
+ }
+ return;
+ }
+
+ executeLine(terminal, line);
+}
+
+function handleBackspace(terminal: Terminal) {
+ // Delete character before cursor
+ if (_cursorPos > 0 && _currentInput.length > 0) {
+ _currentInput = _currentInput.slice(0, _cursorPos - 1) + _currentInput.slice(_cursorPos);
+ _cursorPos--;
+ redrawInputLine(terminal);
+ }
+}
+
+function handleHistoryUp(terminal: Terminal) {
+ if (_commandHistory.length === 0) return;
+ if (_historyIndex <= 0) return;
+
+ _historyIndex--;
+ _currentInput = _commandHistory[_historyIndex];
+ _cursorPos = _currentInput.length;
+ redrawInputLine(terminal);
+}
+
+function handleHistoryDown(terminal: Terminal) {
+ if (_historyIndex >= _commandHistory.length - 1) {
+ _historyIndex = _commandHistory.length;
+ _currentInput = '';
+ _cursorPos = 0;
+ redrawInputLine(terminal);
+ return;
+ }
+
+ _historyIndex++;
+ _currentInput = _commandHistory[_historyIndex];
+ _cursorPos = _currentInput.length;
+ redrawInputLine(terminal);
+}
+
+function handleCtrlC(terminal: Terminal) {
+ terminal.write('^C\r\n');
+ if (_isLogcatMode) {
+ stopLogcatMode(terminal);
+ return;
+ }
+ if (_isPayloadMode) {
+ stopPayloadMode(terminal);
+ }
+ _currentInput = '';
+ _cursorPos = 0;
+ prompt(terminal);
+}
+
+function clearCurrentLine(terminal: Terminal) {
+ // Move to beginning of line and clear
+ terminal.write('\r\x1b[K');
+}
+
+/**
+ * Update the current input buffer (called from onKey)
+ *
+ * @deprecated This function does not support cursor position. Prefer handleTerminalData.
+ */
+export function updateCurrentInput(key: string) {
+ if (key.length === 1 && key >= ' ') {
+ _currentInput += key;
+ _cursorPos = _currentInput.length;
+ }
+}
+
+/**
+ * Execute a command line
+ */
+async function executeLine(terminal: Terminal, line: string) {
+ if (!line.trim()) {
+ prompt(terminal);
+ return;
+ }
+
+ saveToHistory(line);
+
+ // Check for built-in commands (prefixed with !)
+ if (line.startsWith('!')) {
+ await handleBuiltInCommand(terminal, line);
+ return;
+ }
+
+ // Check for non-prefixed built-in commands
+ const firstWord = line.split(' ')[0].toLowerCase();
+ if (firstWord === 'send') {
+ await handleSendCommand(terminal, line);
+ return;
+ }
+
+ // Regular adb shell command
+ await executeAdbCommand(terminal, line);
+}
+
+/**
+ * Handle built-in commands (prefixed with !)
+ */
+async function handleBuiltInCommand(terminal: Terminal, cmd: string) {
+ const parts = cmd.split(' ');
+ const command = parts[0].toLowerCase();
+ const args = parts.slice(1);
+
+ switch (command) {
+ case '!help':
+ showHelp(terminal);
+ prompt(terminal);
+ break;
+
+ case '!cls':
+ case '!clear':
+ terminal.clear();
+ prompt(terminal);
+ break;
+
+ case '!exit':
+ terminal.writeln('\x1b[33mClosing terminal...\x1b[0m');
+ if (_isPayloadMode) stopPayloadMode(terminal);
+ // Close the drawer
+ const { closeTerminalDrawer } = await import('../stores/terminalDrawer');
+ closeTerminalDrawer();
+ break;
+
+ case '!payload':
+ case '!logs':
+ startPayloadMode(terminal, args.join(' '));
+ break;
+
+ case '!status':
+ case '!info':
+ showDeviceStatus(terminal);
+ prompt(terminal);
+ break;
+
+ case '!history':
+ showHistory(terminal);
+ prompt(terminal);
+ break;
+
+ case '!logcat':
+ await handleLogcatCommand(terminal, cmd);
+ // Don't call prompt here — handleLogcatCommand manages its own prompt
+ break;
+
+ default:
+ terminal.writeln(`\x1b[31mUnknown command: ${command}\x1b[0m`);
+ terminal.writeln(`Type \x1b[32m!help\x1b[0m for available commands`);
+ prompt(terminal);
+ }
+}
+
+/**
+ * Format ADB shell command output for readable display.
+ *
+ * 1. Trims ALL leading and trailing whitespace from each line — raw ADB
+ * output often contains varying leading tabs that accumulate per line
+ * and make the display unreadable.
+ * 2. Replaces internal tabs with a single space (cleaner than 2 spaces).
+ * 3. Prepends a consistent 2-space indent for visual separation from
+ * prompts/commands.
+ * 4. Strips trailing blank lines.
+ */
+function formatAdbOutput(output: string): string {
+ const lines = output.split('\n');
+ // Trim trailing empty lines
+ let end = lines.length;
+ while (end > 0 && lines[end - 1].trim() === '') end--;
+ const trimmed = lines.slice(0, end);
+ if (trimmed.length === 0) return '';
+
+ // Trim both leading and trailing whitespace, replace internal tabs with
+ // single space, then add consistent 2-space indent.
+ const cleaned = trimmed.map((line) => {
+ const stripped = line.trim().replace(/\t+/g, ' ');
+ return stripped ? ` ${stripped}` : '';
+ });
+
+ return cleaned.join('\r\n');
+}
+
+/**
+ * Show help text
+ */
+function showHelp(terminal: Terminal) {
+ terminal.writeln('');
+ terminal.writeln('\x1b[1;36m── Supra ADB Terminal Commands ──\x1b[0m');
+ terminal.writeln('');
+ terminal.writeln('\x1b[33m Built-in:\x1b[0m');
+ terminal.writeln(' \x1b[32m!help\x1b[0m Show this help message');
+ terminal.writeln(' \x1b[32m!cls\x1b[0m Clear the terminal screen');
+ terminal.writeln(' \x1b[32m!clear\x1b[0m Clear the terminal screen');
+ terminal.writeln(' \x1b[32m!status\x1b[0m Show device connection status');
+ terminal.writeln(' \x1b[32m!history\x1b[0m Show command history');
+ terminal.writeln(' \x1b[32m!exit\x1b[0m Close the terminal drawer');
+ terminal.writeln('');
+ terminal.writeln('\x1b[33m Payload & Send:\x1b[0m');
+ terminal.writeln(' \x1b[32m!payload\x1b[0m Start watching ADB payload logs');
+ terminal.writeln(' \x1b[32m!payload \x1b[0m Watch logs filtered by type');
+ terminal.writeln(' \x1b[32m!logs\x1b[0m Alias for !payload');
+ terminal.writeln(
+ " \x1b[32msend -t -p '' [-w ]\x1b[0m Send a TCP payload to the Android device"
+ );
+ terminal.writeln(' \x1b[90m Example: send -t test -p \'{"p1": "test12"}\'\x1b[0m');
+ terminal.writeln(
+ ' \x1b[90m Flags: -t type -p payload -w timeout in seconds (0=∞, default=30)\x1b[0m'
+ );
+ terminal.writeln('');
+ terminal.writeln('\x1b[33m Logcat:\x1b[0m');
+ terminal.writeln(' \x1b[32m!logcat\x1b[0m Stream ADB logcat from device');
+ terminal.writeln(' \x1b[32m!logcat \x1b[0m Filter logcat output');
+ terminal.writeln(' \x1b[90m e.g. !logcat -s MyTag, !logcat *:E\x1b[0m');
+ terminal.writeln('');
+ terminal.writeln('\x1b[33m ADB Shell:\x1b[0m');
+ terminal.writeln(
+ ' Any command not starting with \x1b[32m!\x1b[0m is sent as adb shell command'
+ );
+ terminal.writeln(' \x1b[90m e.g. "echo hello", "ls -la", "dumpsys battery"\x1b[0m');
+ terminal.writeln('');
+ terminal.writeln('\x1b[33m Tips:\x1b[0m');
+ terminal.writeln(' \x1b[90m ↑/↓ - Navigate command history\x1b[0m');
+ terminal.writeln(' \x1b[90m Ctrl+C - Cancel current command / exit payload mode\x1b[0m');
+ terminal.writeln('');
+}
+
+/**
+ * Show device connection status
+ */
+function showDeviceStatus(terminal: Terminal) {
+ const instance = getAdbInstance();
+ terminal.writeln('');
+ terminal.writeln('\x1b[1;36m── Device Status ──\x1b[0m');
+ if (instance) {
+ terminal.writeln(` \x1b[32m●\x1b[0m Connected`);
+ terminal.writeln(` Transport: ${instance.transport.constructor.name}`);
+ try {
+ terminal.writeln(` Serial: ${(instance.transport as any).serial ?? 'unknown'}`);
+ } catch {
+ // serial may not be accessible
+ }
+ // Show ADB writer status
+ const writer = get(adbWriter);
+ terminal.writeln(
+ ` TCP Socket: ${writer ? '\x1b[32mActive\x1b[0m' : '\x1b[90mNot connected\x1b[0m'}`
+ );
+ } else {
+ terminal.writeln(` \x1b[31m●\x1b[0m Disconnected`);
+ terminal.writeln(
+ ` \x1b[33m → Use the "Connect" button in the dashboard to connect a device\x1b[0m`
+ );
+ }
+ terminal.writeln('');
+}
+
+/**
+ * Show command history
+ */
+function showHistory(terminal: Terminal) {
+ terminal.writeln('');
+ terminal.writeln('\x1b[1;36m── Command History ──\x1b[0m');
+ if (_commandHistory.length === 0) {
+ terminal.writeln(' \x1b[90m(no commands yet)\x1b[0m');
+ } else {
+ const start = Math.max(0, _commandHistory.length - 50);
+ for (let i = start; i < _commandHistory.length; i++) {
+ const num = (i + 1).toString().padStart(4, ' ');
+ terminal.writeln(` \x1b[90m${num}\x1b[0m ${_commandHistory[i]}`);
+ }
+ }
+ terminal.writeln('');
+}
+
+/**
+ * Parsed send command arguments.
+ */
+type SendArgs = {
+ type: string;
+ payload: string;
+ rawPayload: string;
+ timeoutMs: number; // 0 = no timeout
+};
+
+const DEFAULT_SEND_TIMEOUT_MS = 30_000;
+
+/**
+ * Parse argument string with -t -p [-w ] support.
+ * Handles quoted strings for JSON payloads.
+ */
+function parseSendArgs(argStr: string): SendArgs {
+ const result: SendArgs = {
+ type: '',
+ payload: '',
+ rawPayload: '',
+ timeoutMs: DEFAULT_SEND_TIMEOUT_MS
+ };
+
+ // Tokenize respecting single and double quotes
+ const tokens: string[] = [];
+ let current = '';
+ let inSingle = false;
+ let inDouble = false;
+
+ for (let i = 0; i < argStr.length; i++) {
+ const ch = argStr[i];
+ if (ch === "'" && !inDouble) {
+ inSingle = !inSingle;
+ if (!inSingle) {
+ tokens.push(current);
+ current = '';
+ continue;
+ }
+ continue;
+ }
+ if (ch === '"' && !inSingle) {
+ inDouble = !inDouble;
+ if (!inDouble) {
+ tokens.push(current);
+ current = '';
+ continue;
+ }
+ continue;
+ }
+ if (ch === ' ' && !inSingle && !inDouble) {
+ if (current) {
+ tokens.push(current);
+ current = '';
+ }
+ continue;
+ }
+ current += ch;
+ }
+ if (current) tokens.push(current);
+
+ // Parse tokens for -t, -p, -w flags
+ for (let i = 0; i < tokens.length; i++) {
+ if (tokens[i] === '-t' && i + 1 < tokens.length) {
+ result.type = tokens[++i];
+ } else if (tokens[i] === '-p' && i + 1 < tokens.length) {
+ result.rawPayload = tokens[++i];
+ } else if (tokens[i] === '-w' && i + 1 < tokens.length) {
+ const val = parseInt(tokens[++i], 10);
+ if (!isNaN(val) && val >= 0) {
+ result.timeoutMs = val === 0 ? 0 : val * 1000;
+ }
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Execute the interactive send command.
+ *
+ * Syntax: send -t -p ''
+ *
+ * Sends a JSON message via the ADB TCP socket and waits for a response event.
+ */
+async function handleSendCommand(terminal: Terminal, line: string) {
+ // Extract args after "send"
+ const args = line.slice('send'.length).trim();
+ const parsed = parseSendArgs(args);
+
+ if (!parsed.type) {
+ terminal.writeln("\x1b[33mUsage: send -t -p '' [-w ]\x1b[0m");
+ terminal.writeln('\x1b[90m Example: send -t test -p \'{"p1": "test12"}\' -w 60\x1b[0m');
+ prompt(terminal);
+ return;
+ }
+
+ if (!parsed.rawPayload) {
+ terminal.writeln('\x1b[31mError: Missing payload (-p).\x1b[0m');
+ terminal.writeln("\x1b[33mUsage: send -t -p '' [-w ]\x1b[0m");
+ prompt(terminal);
+ return;
+ }
+
+ // Parse the JSON payload
+ let payloadObj: any;
+ try {
+ payloadObj = JSON.parse(parsed.rawPayload);
+ } catch {
+ terminal.writeln(`\x1b[31mError: Invalid JSON payload\x1b[0m`);
+ terminal.writeln(`\x1b[90m Received: ${parsed.rawPayload}\x1b[0m`);
+ prompt(terminal);
+ return;
+ }
+
+ // Build the message
+ const message = { type: parsed.type, payload: payloadObj };
+
+ // Show sent confirmation
+ terminal.writeln(`\x1b[90m→ Sending: ${JSON.stringify(message)}\x1b[0m`);
+
+ // Send via the writer
+ try {
+ const sent = await sendToAndroid(message);
+ if (!sent) {
+ terminal.writeln(`\x1b[31mError: Failed to send message — no ADB socket connection.\x1b[0m`);
+ terminal.writeln(
+ `\x1b[33m Make sure the device is connected and the TCP socket is active.\x1b[0m`
+ );
+ prompt(terminal);
+ return;
+ }
+ } catch (e: any) {
+ terminal.writeln(`\x1b[31mError sending message: ${e.message ?? 'Unknown error'}\x1b[0m`);
+ prompt(terminal);
+ return;
+ }
+
+ // Wait for a response via the event bus (configurable timeout)
+ const timeoutSeconds = parsed.timeoutMs === 0 ? '∞' : `${parsed.timeoutMs / 1000}`;
+ terminal.writeln(`\x1b[90m← Waiting for response (timeout: ${timeoutSeconds}s)...\x1b[0m`);
+
+ const response = await waitForPayloadResponse(parsed.timeoutMs);
+
+ if (response === null) {
+ terminal.writeln(`\x1b[33m⚠ No response received within timeout.\x1b[0m`);
+ } else {
+ terminal.writeln(`\x1b[32m✓ Response received:\x1b[0m`);
+ terminal.writeln(`\x1b[2m${formatAdbOutput(JSON.stringify(response, null, 2))}\x1b[0m`);
+ }
+
+ prompt(terminal);
+}
+
+/**
+ * Wait for a payload response via GlobalEventBus.
+ * Resolves with the first adb:payload event emitted, or null on timeout.
+ * Pass 0 for timeoutMs to wait indefinitely.
+ */
+function waitForPayloadResponse(timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ let timer: ReturnType | undefined;
+
+ if (timeoutMs > 0) {
+ timer = setTimeout(() => {
+ unsub();
+ resolve(null);
+ }, timeoutMs);
+ }
+
+ const unsub = GlobalEventBus.on('adb:payload', (payload: any) => {
+ if (timer) clearTimeout(timer);
+ unsub();
+ resolve(payload);
+ });
+ });
+}
+
+/**
+ * Execute an ADB shell command and display formatted output.
+ *
+ * Display layout — user already sees their typed command via xterm local echo,
+ * so we only show indented output for visual separation:
+ *
+ * ← blank line separator
+ * ← 2-space indent, dim gray
+ * ← red, if present
+ * ← yellow, if non-zero
+ * ← blank line before prompt
+ * $ ← next prompt
+ */
+async function executeAdbCommand(terminal: Terminal, cmd: string) {
+ const instance = getAdbInstance();
+ if (!instance) {
+ const err = 'No ADB device connected.';
+ terminal.writeln(`\x1b[31mError: ${err}\x1b[0m`);
+ terminal.writeln('\x1b[33mPlease connect a device first via the dashboard.\x1b[0m');
+ recordCommandOutput(cmd, `Error: ${err}\nPlease connect a device first via the dashboard.`, 'error');
+ prompt(terminal);
+ return;
+ }
+
+ try {
+ const result: Record = (await executeCmd(cmd)) ?? {};
+
+ terminal.writeln('');
+
+ let fullOutput = '';
+ const level: CommandOutput['level'] = result.exitCode && result.exitCode !== 0 ? 'error' : 'info';
+
+ if (result.output) {
+ const formatted = formatAdbOutput(String(result.output));
+ if (formatted) {
+ fullOutput = formatted;
+ const outputLines = formatted.split('\r\n');
+ // Show a brief summary in the terminal
+ terminal.writeln(`\x1b[90m ✓ Result stored (${outputLines.length} line${outputLines.length > 1 ? 's' : ''})\x1b[0m`);
+ }
+ }
+
+ if (result.error) {
+ const errText = String(result.error);
+ terminal.writeln(`\x1b[31m${errText}\x1b[0m`);
+ if (fullOutput) fullOutput += '\n' + errText;
+ else fullOutput = errText;
+ }
+
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
+ terminal.writeln(`\x1b[33mExit code: ${result.exitCode}\x1b[0m`);
+ }
+
+ // Record for output blocks (even if empty)
+ recordCommandOutput(cmd, fullOutput || '(no output)', level);
+
+ // Auto-popup the output dialog immediately
+ requestExpandOutput(getLastOutputId());
+ } catch (e: any) {
+ const errMsg = e.message ?? 'Unknown error';
+ terminal.writeln(`\x1b[31mError: ${errMsg}\x1b[0m`);
+ recordCommandOutput(cmd, `Error: ${errMsg}`, 'error');
+ requestExpandOutput(getLastOutputId());
+ }
+
+ // Blank line before prompt for visual breathing room
+ terminal.writeln('');
+
+ prompt(terminal);
+}
+
+/**
+ * Start payload monitoring mode - shows incoming ADB payloads in real-time
+ */
+function startPayloadMode(terminal: Terminal, filter: string) {
+ _isPayloadMode = true;
+ _payloadFilter = filter;
+ _payloadLogBuffer = [];
+
+ terminal.writeln('');
+ terminal.writeln(`\x1b[1;36m── ADB Payload Monitor ──\x1b[0m`);
+ if (filter) {
+ terminal.writeln(`\x1b[33m Filter: ${filter}\x1b[0m`);
+ }
+ terminal.writeln('\x1b[90m Watching for incoming ADB payloads...\x1b[0m');
+ terminal.writeln('\x1b[90m Press Ctrl+C or type a command to exit\x1b[0m');
+ terminal.writeln('');
+
+ // Subscribe to payload events
+ _payloadUnsubscribe = GlobalEventBus.on('adb:payload', (payload: any) => {
+ if (!_terminal) return;
+
+ // Apply filter if set
+ if (filter && payload.type) {
+ const filterLower = filter.toLowerCase();
+ const typeLower = payload.type.toLowerCase();
+ if (
+ !typeLower.includes(filterLower) &&
+ !JSON.stringify(payload).toLowerCase().includes(filterLower)
+ ) {
+ return;
+ }
+ }
+
+ const timestamp = new Date().toLocaleTimeString();
+ const type = payload.type ?? 'unknown';
+ const payloadStr = payload.payload ? JSON.stringify(payload.payload).slice(0, 500) : '';
+
+ // Color code by type
+ let colorPrefix = '';
+ switch (type) {
+ case 'log':
+ colorPrefix = '\x1b[36m'; // cyan
+ break;
+ case 'error':
+ colorPrefix = '\x1b[31m'; // red
+ break;
+ case 'machine':
+ colorPrefix = '\x1b[33m'; // yellow
+ break;
+ case 'response':
+ colorPrefix = '\x1b[32m'; // green
+ break;
+ case 'brew-finish':
+ colorPrefix = '\x1b[35m'; // magenta
+ break;
+ default:
+ colorPrefix = '\x1b[90m'; // gray
+ }
+
+ terminal.writeln(`${colorPrefix}[${timestamp}] ${type}:\x1b[0m ${payloadStr}`);
+
+ // Keep buffer bounded
+ _payloadLogBuffer.push(`[${timestamp}] ${type}: ${payloadStr}`);
+ if (_payloadLogBuffer.length > 1000) {
+ _payloadLogBuffer.shift();
+ }
+ });
+
+ prompt(terminal);
+}
+
+/**
+ * Stop payload monitoring mode
+ */
+function stopPayloadMode(terminal: Terminal) {
+ _isPayloadMode = false;
+ _payloadFilter = '';
+
+ if (_payloadUnsubscribe) {
+ _payloadUnsubscribe();
+ _payloadUnsubscribe = null;
+ }
+
+ terminal.writeln('');
+ terminal.writeln('\x1b[33mPayload monitoring stopped.\x1b[0m');
+}
+
+/**
+ * Start logcat mode — streams adb logcat output in real-time.
+ * Syntax: !logcat [filters]
+ * Filters follow Android logcat syntax:
+ * !logcat → all logs
+ * !logcat -s MyTag → only MyTag (silent others)
+ * !logcat *:E → only errors
+ * !logcat ActivityManager:I *:S → ActivityManager info+ only
+ * !logcat -v color → colored output
+ */
+async function handleLogcatCommand(terminal: Terminal, cmd: string) {
+ const instance = getAdbInstance();
+ if (!instance) {
+ terminal.writeln('\x1b[31mError: No ADB device connected.\x1b[0m');
+ terminal.writeln('\x1b[33mPlease connect a device first via the dashboard.\x1b[0m');
+ prompt(terminal);
+ return;
+ }
+
+ // Parse filter args from "!logcat" command
+ // "!logcat -s MyTag" → args = "-s MyTag"
+ const args = cmd.slice('!logcat'.length).trim();
+ const logcatCommand = `logcat -v brief ${args}`;
+
+ _isLogcatMode = true;
+ _logcatBuffer = [];
+
+ terminal.writeln('');
+ terminal.writeln(`\x1b[1;36m── ADB Logcat ──\x1b[0m`);
+ if (args) {
+ terminal.writeln(`\x1b[33m Filter: ${args}\x1b[0m`);
+ }
+ terminal.writeln('\x1b[90m Streaming from device...\x1b[0m');
+ terminal.writeln('\x1b[90m Press Ctrl+C or type Enter (empty) to stop\x1b[0m');
+ terminal.writeln('');
+
+ let lineCount = 0;
+
+ _logcatCleanup = await executeStreamingCmd(logcatCommand, {
+ onData: (chunk: string) => {
+ if (!_terminal || !_isLogcatMode) return;
+
+ // Split chunk into lines
+ const lines = chunk.split('\n');
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ lineCount++;
+
+ // Prepend timestamp to both buffer and terminal output
+ // so the history dialog shows timestamps too
+ const ts = new Date().toLocaleTimeString();
+ const tsLine = `[${ts}] ${line}`;
+
+ // Store in buffer (with timestamp)
+ _logcatBuffer.push(tsLine);
+ if (_logcatBuffer.length > LOGCAT_MAX_LINES) {
+ _logcatBuffer.shift();
+ }
+
+ // Write to terminal: color the line content (not timestamp)
+ // Lines look like: "V/MyTag: message" or "E/MyTag: message"
+ let coloredLine = tsLine;
+ if (line.includes('/')) {
+ const level = line.charAt(0).toUpperCase();
+ switch (level) {
+ case 'E': coloredLine = `\x1b[31m${tsLine}\x1b[0m`; break; // red
+ case 'W': coloredLine = `\x1b[33m${tsLine}\x1b[0m`; break; // yellow
+ case 'I': coloredLine = `\x1b[36m${tsLine}\x1b[0m`; break; // cyan
+ case 'D': coloredLine = `\x1b[90m${tsLine}\x1b[0m`; break; // gray
+ case 'V': coloredLine = `\x1b[2m${tsLine}\x1b[0m`; break; // dim
+ case 'F': coloredLine = `\x1b[35m${tsLine}\x1b[0m`; break; // magenta (fatal)
+ }
+ }
+
+ terminal.writeln(coloredLine);
+ }
+ },
+ onError: (error: string) => {
+ if (!_terminal || !_isLogcatMode) return;
+ terminal.writeln(`\x1b[31mLogcat error: ${error}\x1b[0m`);
+ },
+ onExit: (exitCode: number | undefined) => {
+ if (!_terminal || !_isLogcatMode) return;
+ terminal.writeln('');
+ terminal.writeln(`\x1b[33mLogcat exited (code: ${exitCode ?? 'unknown'}).\x1b[0m`);
+ _isLogcatMode = false;
+ _logcatCleanup = null;
+ // Record logcat history as a command output and auto-show dialog
+ if (_logcatBuffer.length > 0) {
+ const fullOutput = _logcatBuffer.join('\n');
+ recordCommandOutput('!logcat', fullOutput, 'info');
+ requestExpandOutput(getLastOutputId());
+ }
+ _logcatBuffer = [];
+ prompt(terminal);
+ }
+ });
+
+ // We're in streaming mode — prompt will show when logcat ends or user stops it
+}
+
+/**
+ * Stop logcat streaming
+ */
+function stopLogcatMode(terminal: Terminal) {
+ _isLogcatMode = false;
+
+ if (_logcatCleanup) {
+ _logcatCleanup();
+ _logcatCleanup = null;
+ }
+
+ terminal.writeln('');
+ terminal.writeln(`\x1b[33mLogcat stopped. Captured \x1b[1m${_logcatBuffer.length}\x1b[22m lines.\x1b[0m`);
+
+ // Record logcat history as a command output and auto-show dialog
+ if (_logcatBuffer.length > 0) {
+ const fullOutput = _logcatBuffer.join('\n');
+ recordCommandOutput('!logcat', fullOutput, 'info');
+ requestExpandOutput(getLastOutputId());
+ }
+
+ _logcatBuffer = [];
+ prompt(terminal);
+}
+
+/**
+ * Clean up terminal session resources.
+ * Called when the terminal component is destroyed (page navigation, not drawer close).
+ */
+export function cleanupTerminalSession() {
+ if (_logcatCleanup) {
+ _logcatCleanup();
+ _logcatCleanup = null;
+ }
+ if (_payloadUnsubscribe) {
+ _payloadUnsubscribe();
+ _payloadUnsubscribe = null;
+ }
+ _isLogcatMode = false;
+ _isPayloadMode = false;
+ _payloadFilter = '';
+ _terminal = null;
+ _currentInput = '';
+ _cursorPos = 0;
+ _logcatBuffer = [];
+}
+
+/**
+ * Save the terminal buffer content so it can be restored on reopen.
+ * Called when the drawer is minimized to preserve session state.
+ * With the current "keep-mounted" approach, this is a lightweight save
+ * of the cursor / input state.
+ */
+export function onTerminalMinimized() {
+ _currentInput = '';
+ _cursorPos = 0;
+}
+
+/**
+ * Check if the user has admin permission based on their role
+ */
+export function isAdminUser(user: { role?: string } | null): boolean {
+ return user?.role === 'admin';
+}
+
+// Re-export for use in components
+export { _isPayloadMode as isPayloadMode };
diff --git a/src/lib/core/client/server.ts b/src/lib/core/client/server.ts
index 98e2ea9..979c021 100644
--- a/src/lib/core/client/server.ts
+++ b/src/lib/core/client/server.ts
@@ -37,7 +37,7 @@ export async function getRecipes() {
recipeData.set([]);
recipeOverviewData.set([]);
- sendMessage({
+ await sendMessage({
type: 'recipe',
payload: {
auth: idToken ?? '',
@@ -82,7 +82,7 @@ export async function getRecipeWithVersion(version: string) {
// NOTE: although version is provided, actual version field is still need to be latest
// Just in case version is not found
- sendMessage({
+ await sendMessage({
type: 'recipe',
payload: {
auth: idToken ?? '',
diff --git a/src/lib/core/handlers/adbPayloadHandler.ts b/src/lib/core/handlers/adbPayloadHandler.ts
index fbc3f2b..450b202 100644
--- a/src/lib/core/handlers/adbPayloadHandler.ts
+++ b/src/lib/core/handlers/adbPayloadHandler.ts
@@ -7,16 +7,40 @@ import {
} from '../services/androidRecipeExportService';
import { handleIncomingMessages } from './messageHandler';
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
-import { recipeFromMachineQuery } from '../stores/recipeStore';
+import { recipeFromMachine, recipeFromMachineQuery } from '../stores/recipeStore';
+import { buildTags, getMenuStatus } from '$lib/data/recipeService';
+import * as semver from 'semver';
+import { env } from '$env/dynamic/public';
+import { getContext } from 'svelte';
+import { GlobalEventBus, useEventBus } from '../utils/eventBus';
type AdbPayload = { type: string; payload: any };
+let queuedPromises = new Array>();
+
async function handleAdbPayload(raw_payload: string) {
- console.log('[ADB] Received payload:', raw_payload.slice(0, 300));
+ // console.log('[ADB] Received payload:', raw_payload.slice(0, 300));
+ const APP_VERSION = env.PUBLIC_APP_SEMVER;
+ // const bus = useEventBus();
+
try {
const payload: AdbPayload = JSON.parse(raw_payload);
- console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
- switch (payload.type) {
+ // console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
+
+ // Emit payload event for terminal drawer payload viewer
+ GlobalEventBus.emit('adb:payload', payload);
+
+ let payload_type = payload.type;
+ let sub_type = null;
+ // sub type handler
+ if (payload_type.includes('/')) {
+ //
+ let tspl = payload_type.split('/');
+ payload_type = tspl[0];
+ sub_type = tspl[1];
+ }
+
+ switch (payload_type) {
case 'log':
let log_level = payload.payload['level'] ?? 'INFO';
let log_message = payload.payload['msg'] ?? '';
@@ -59,16 +83,16 @@ async function handleAdbPayload(raw_payload: string) {
}
if (raw_payload.startsWith('save_recipe_machine')) {
- handleIncomingMessages(
- JSON.stringify({
- type: 'ui_action',
- payload: {
- action: uiAction,
- from: 'brew',
- ref: `${pd}.${action}`
- }
- })
- );
+ // handleIncomingMessages(
+ // JSON.stringify({
+ // type: 'ui_action',
+ // payload: {
+ // action: uiAction,
+ // from: 'brew',
+ // ref: `${pd}.${action}`
+ // }
+ // })
+ // );
}
} else if (raw_payload.startsWith('state')) {
let res = raw_payload.split('/');
@@ -162,10 +186,177 @@ async function handleAdbPayload(raw_payload: string) {
addNotification(`ERR:${error?.message ?? 'Unable to load recipe export from Android'}`)
);
break;
+ case 'get-recipe-response':
+ // only update recipe from memory of brew app
+ if (!semver.satisfies(APP_VERSION, '^0.0.3')) {
+ // reject
+ console.log('unsupported version');
+ break;
+ }
+
+ let stream_recipe_idx = -1;
+
+ try {
+ stream_recipe_idx = parseInt(sub_type ?? '-1');
+ } catch (_) {}
+
+ if (stream_recipe_idx > -1) {
+ // TODO: update both recipeFromMachine and recipeFromMachineQuery
+ //
+
+ queuedPromises.push(
+ new Promise((resolve, reject) => {
+ let recipeRawFromMachine = get(recipeFromMachine);
+ let recipeMachineQ = get(recipeFromMachineQuery);
+
+ if (!Object.keys(recipeMachineQ).includes('recipe')) {
+ recipeMachineQ = {
+ ...recipeMachineQ,
+ recipe: {}
+ };
+ }
+
+ let current_r01_query = recipeMachineQ.recipe;
+
+ let rp_from_payload = JSON.parse(payload.payload);
+
+ try {
+ if (
+ recipeRawFromMachine != null &&
+ Object.keys(recipeRawFromMachine).includes('Recipe01')
+ ) {
+ let is_update = false;
+ // update, assume that we already fetch
+ for (let rp of recipeRawFromMachine['Recipe01']) {
+ if (rp['productCode'] == rp_from_payload['productCode']) {
+ rp = rp_from_payload;
+ is_update = true;
+ break;
+ }
+ }
+
+ // new menu
+ if (!is_update) recipeRawFromMachine['Recipe01'].push(rp_from_payload);
+ } else {
+ // not initialize
+ recipeRawFromMachine = {
+ Recipe01: [rp_from_payload]
+ };
+ }
+
+ // build as overview style compatible
+
+ let overview_menu = {
+ productCode: rp_from_payload['productCode'] ?? '',
+ name: rp_from_payload['name']
+ ? rp_from_payload['name']
+ : (rp_from_payload['otherName'] ?? ''),
+ description: rp_from_payload['desciption']
+ ? rp_from_payload['desciption']
+ : (rp_from_payload['otherDescription'] ?? ''),
+ tags: buildTags(rp_from_payload),
+ status: getMenuStatus(rp_from_payload['MenuStatus'])
+ };
+
+ current_r01_query[overview_menu.productCode] = overview_menu;
+
+ recipeMachineQ = {
+ ...recipeMachineQ,
+ recipe: current_r01_query
+ };
+
+ recipeFromMachineQuery.set(recipeMachineQ);
+ recipeFromMachine.set(recipeRawFromMachine);
+
+ if (stream_recipe_idx == 0) {
+ addNotification(`INFO:Loading recipes ...`);
+ }
+
+ resolve();
+ } catch (e) {
+ reject(e);
+ }
+ })
+ );
+ } else if (sub_type == 'end' && payload.payload.includes('finish')) {
+ let force_reload = payload.payload.includes('reload');
+
+ console.log('queued recipes: ', queuedPromises.length);
+ if (queuedPromises.length > 0) {
+ try {
+ await Promise.all(queuedPromises);
+ console.log('clear all recipe promises');
+ queuedPromises = new Array();
+
+ GlobalEventBus.emitUntilConsumed('recipe-event', {
+ type: 'load-recipe',
+ status: 'end',
+ reload: force_reload
+ });
+ } catch (e) {
+ console.error('some promise failed: ', e);
+
+ GlobalEventBus.emitUntilConsumed('recipe-event', {
+ type: 'load-recipe-fail',
+ status: 'end',
+ reload: force_reload
+ });
+ }
+ }
+ } else if (sub_type?.startsWith('mat')) {
+ let recipeRawFromMachine = get(recipeFromMachine);
+
+ if (sub_type == 'mat-0') {
+ addNotification('INFO:Loading materials ...');
+ }
+
+ if (
+ recipeRawFromMachine != null &&
+ Object.keys(recipeRawFromMachine).includes('MaterialSetting')
+ ) {
+ recipeRawFromMachine.MaterialSetting.push(JSON.parse(payload.payload));
+ } else {
+ // not has field material yet
+ recipeRawFromMachine = {
+ ...recipeRawFromMachine,
+ MaterialSetting: [JSON.parse(payload.payload)]
+ };
+ }
+
+ console.log('checking add mat', recipeRawFromMachine);
+ recipeFromMachine.set(recipeRawFromMachine);
+ } else if (sub_type == 'toppings') {
+ let recipeRawFromMachine = get(recipeFromMachine);
+
+ let topping_raw = JSON.parse(payload.payload);
+ console.log('receive topping', topping_raw);
+
+ //
+ recipeRawFromMachine = {
+ ...recipeRawFromMachine,
+ Topping: topping_raw
+ };
+
+ // materialFromMachineQuery
+
+ addNotification('INFO:Loading toppings ...');
+
+ recipeFromMachine.set(recipeRawFromMachine);
+ } else {
+ // unhandled sub type
+ console.log('unhandled sub type', payload);
+ }
+
+ break;
default:
}
} catch (error: any) {
- // invalid format
+ // invalid format — emit error event so listeners (e.g. terminal) can react
+ GlobalEventBus.emit('adb:payload-error', {
+ raw_payload,
+ error: error?.message ?? 'Unknown parse error',
+ timestamp: new Date().toISOString()
+ });
}
}
diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts
index 61cd6a3..e00612e 100644
--- a/src/lib/core/handlers/messageHandler.ts
+++ b/src/lib/core/handlers/messageHandler.ts
@@ -21,11 +21,14 @@ import {
handleSheetStreamEnd,
handleSheetStreamError,
handleCatalogsResponse,
+ handlePriceSlotsResponse,
+ isPriceSlotsPayload,
handleListMenuResponse,
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
- handleRawStreamEnd
+ handleRawStreamEnd,
+ handleSheetPriceResponse
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
@@ -37,15 +40,24 @@ import { buildOverviewFromServer } from '$lib/data/recipeService';
import { auth } from '../client/firebase';
import { type RecipeVersion } from '$lib/models/recipe_version.model';
import { goto } from '$app/navigation';
-import { socketAlreadySendHeartbeat, socketConnectionOfflineCount } from '../stores/websocketStore';
+import {
+ sharedKey as sharedKey,
+ socketAlreadySendHeartbeat,
+ socketConnectionOfflineCount
+} from '../stores/websocketStore';
import type { RecipePrice } from '$lib/models/price.model';
import { sendCommandRequest, sendMessage } from './ws_messageSender';
import { auth as authStore } from '../stores/auth';
import { v4 as uuidv4 } from 'uuid';
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
+import { env } from '$env/dynamic/public';
+import { WebCryptoHelper } from '../utils/crypto';
+import { GlobalEventBus } from '../utils/eventBus';
+import * as semver from 'semver';
export const messages = writable([]);
+type HandshakeAck = { server_public_key: string; status: string };
type WSMessage = { type: string; payload: any };
// MAXIMUM LIMIT = 1814355
@@ -131,7 +143,7 @@ const handlers: Record void> = {
}
}
},
- stream_data_end: (p) => {
+ stream_data_end: async (p) => {
recipeLoading.set(false);
// build overview for recipe from server
@@ -154,7 +166,7 @@ const handlers: Record void> = {
}
// send next chain message
- sendMessage({
+ await sendMessage({
type: 'price',
payload: {
action: {
@@ -283,26 +295,76 @@ const handlers: Record void> = {
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
+ const content = p.content ?? p.value ?? p.payload;
+ const ref = p.ref ?? '';
- if (target && currentUid && target === currentUid) {
- if (!msg && p.content?.catalogs) {
- handleCatalogsResponse(p.content);
- addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
+ console.log('[Sheet] Notify content received:', {
+ msg,
+ target,
+ currentUid,
+ contentKeys: content && typeof content === 'object' ? Object.keys(content) : [],
+ contentItems: Array.isArray(content) ? content.length : undefined
+ });
+
+ if (!target || (currentUid && target === currentUid)) {
+ if (!msg && content?.catalogs) {
+ handleCatalogsResponse(content);
+ addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`);
+ return;
+ }
+
+ if (
+ !msg &&
+ (content?.priceSlots ||
+ content?.priceslots ||
+ content?.price_slots ||
+ content?.slots ||
+ content?.param === 'priceslot' ||
+ content?.option === 'PriceSlot' ||
+ isPriceSlotsPayload(content))
+ ) {
+ handlePriceSlotsResponse(content);
+ addNotification('INFO:Loaded PriceSlot data');
+ return;
+ }
+
+ if (!msg && ref === 'price') {
+ handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
+ addNotification('INFO:Loaded sheet price data');
return;
}
// Handle streaming messages (with msg field)
switch (msg) {
+ case 'priceslot':
+ case 'price_slot':
+ handlePriceSlotsResponse(content);
+ addNotification('INFO:Loaded PriceSlot data');
+ break;
case 'start':
- handleSheetStreamStart(p);
- addNotification('INFO:Sheet data streaming started');
+ if (ref === 'price') {
+ addNotification('INFO:Sheet price streaming started');
+ } else {
+ handleSheetStreamStart(p);
+ addNotification('INFO:Sheet data streaming started');
+ }
break;
case 'chunk':
- handleSheetStreamChunk(p);
+ if (isPriceSlotsPayload(content)) {
+ handlePriceSlotsResponse(content);
+ } else if (ref === 'price') {
+ handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
+ } else {
+ handleSheetStreamChunk(p);
+ }
break;
case 'end':
- handleSheetStreamEnd(p);
- addNotification('INFO:Sheet data streaming complete');
+ if (ref === 'price') {
+ addNotification('INFO:Sheet price streaming complete');
+ } else {
+ handleSheetStreamEnd(p);
+ addNotification('INFO:Sheet data streaming complete');
+ }
break;
case 'error':
handleSheetStreamError(p);
@@ -310,8 +372,17 @@ const handlers: Record void> = {
break;
default:
// Handle other content notifications from sheet-service
- console.log('[Sheet] Received content:', p.content);
+ console.log('[Sheet] Received content:', {
+ contentItems: Array.isArray(content) ? content.length : undefined
+ });
}
+ } else {
+ console.warn('[Sheet] Ignored content because target does not match current user:', {
+ target,
+ currentUid,
+ msg,
+ contentItems: Array.isArray(content) ? content.length : undefined
+ });
}
return;
}
@@ -352,7 +423,7 @@ const handlers: Record void> = {
currentRecipeVersionsSelector.set(result);
}
},
- price: (p) => {
+ price: async (p) => {
let req_action = p.req_action;
let status = p.status;
let to = p.to;
@@ -385,10 +456,11 @@ const handlers: Record void> = {
current_streaming_instance[request_id] = '';
streamingRawData.set(current_streaming_instance);
- sendCommandRequest('sheet', {
+ await sendCommandRequest('sheet', {
country: current_meta?.country ?? '',
content: saved_product_code_to_get_from_sheet,
param: 'price',
+ option: 'price',
stream: true,
request_id
});
@@ -466,32 +538,79 @@ const handlers: Record void> = {
// Header for price stream
handleRawStreamHeader('price', p);
},
+ raw_stream_priceslot: (p) => {
+ handleRawStreamHeader('priceslot', p);
+ },
raw_stream_chunk_price: (p) => {
// Chunk for price stream
handleRawStreamChunk('price', p);
},
+ raw_stream_chunk_priceslot: (p) => {
+ handleRawStreamChunk('priceslot', p);
+ },
raw_stream_end_price: (p) => {
// End for price stream
handleRawStreamEnd('price', p);
+ },
+ raw_stream_end_priceslot: (p) => {
+ handleRawStreamEnd('priceslot', p);
+ },
+ announce: (p) => {
+ // Server-pushed announcement (e.g., closing maintenance)
+ GlobalEventBus.emit('announce', p);
}
};
-export function handleIncomingMessages(raw: string) {
- const msg: WSMessage = JSON.parse(raw);
- // console.log(`[WS MSG] type=${msg.type}`, msg.payload);
- if (msg == null) {
- // error response
- addNotification('ERR:No response from server');
+export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
+ const APP_VERSION = env.PUBLIC_APP_SEMVER;
+ const parsedMessage = JSON.parse(raw);
+
+ const ack: HandshakeAck = parsedMessage;
+ if (ack != null && ack.status === 'authenticated') {
+ // has server response
+ if (!clientPrivateKey) return;
+
+ sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
+
+ addNotification('INFO:Secured Connection');
+
return;
}
+ if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
+ // secured message decryption
+ let sharedKeyStore = get(sharedKey);
+ if (sharedKeyStore) {
+ let decrypted_string = await WebCryptoHelper.decryptMessage(
+ sharedKeyStore,
+ parsedMessage.ciphertext,
+ parsedMessage.iv
+ );
+ let actual_message: WSMessage = JSON.parse(decrypted_string);
+ if (actual_message.type !== 'heartbeat') {
+ // console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
+ }
- // raw streaming type
- // if (msg.type.startsWith('raw_stream')) {
- // // convert
- // let sub_type = msg.type.replace('raw_stream_', '');
- // msg.payload.sub_type = sub_type;
- // msg.type = 'raw_stream';
- // }
+ handlers[actual_message.type]?.(actual_message.payload);
+ }
+ } else {
+ const msg: WSMessage = parsedMessage;
+ if (msg.type !== 'heartbeat') {
+ // console.log(`[WS MSG] type=${msg.type}`, msg.payload);
+ }
+ if (msg == null) {
+ // error response
+ addNotification('ERR:No response from server');
+ return;
+ }
- handlers[msg.type]?.(msg.payload);
+ // raw streaming type
+ // if (msg.type.startsWith('raw_stream')) {
+ // // convert
+ // let sub_type = msg.type.replace('raw_stream_', '');
+ // msg.payload.sub_type = sub_type;
+ // msg.type = 'raw_stream';
+ // }
+
+ handlers[msg.type]?.(msg.payload);
+ }
}
diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts
index 5d3dd01..1590409 100644
--- a/src/lib/core/handlers/ws_messageSender.ts
+++ b/src/lib/core/handlers/ws_messageSender.ts
@@ -1,12 +1,15 @@
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
-import { socketStore } from '../stores/websocketStore';
+import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
import { auth } from '../stores/auth';
+import { WebCryptoHelper } from '../utils/crypto';
+import { env } from '$env/dynamic/public';
+import * as semver from 'semver';
export const queue = writable([]);
-type CommandRequest = 'sheet' | 'command';
+type CommandRequest = 'sheet' | 'command' | 'upload-log';
function getServiceName(cmdReq: CommandRequest) {
switch (cmdReq) {
@@ -14,11 +17,45 @@ function getServiceName(cmdReq: CommandRequest) {
return 'sheet-service';
case 'command':
return 'command';
+ case 'upload-log':
+ return 'upload-log';
}
}
+function waitForWsAuthReady(timeoutMs = 10000): Promise {
+ if (get(wsAuthReady)) return Promise.resolve(true);
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let unsubscribe = () => {};
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ unsubscribe();
+ resolve(false);
+ }, timeoutMs);
+
+ unsubscribe = wsAuthReady.subscribe((ready) => {
+ if (!ready || settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ unsubscribe();
+ resolve(true);
+ });
+ });
+}
+
// Websocket message wrapper for commands like `sheet`, `command`
-export function sendCommandRequest(target: CommandRequest, values: any): boolean {
+export async function sendCommandRequest(target: CommandRequest, values: any): Promise {
+ const authReady = await waitForWsAuthReady();
+ if (!authReady) {
+ console.warn('[WS Send] Skip command request because websocket auth is not ready', {
+ target,
+ param: values?.param
+ });
+ return false;
+ }
+
let srv_name = getServiceName(target);
let curr_user = get(auth);
@@ -31,7 +68,7 @@ export function sendCommandRequest(target: CommandRequest, values: any): boolean
};
}
- return sendMessage({
+ return await sendMessage({
type: target,
payload: {
user_info: user_info ?? {},
@@ -41,9 +78,13 @@ export function sendCommandRequest(target: CommandRequest, values: any): boolean
});
}
-export function sendMessage(msg: OutMessage, ignore_queue_request: boolean = true): boolean {
+export async function sendMessage(
+ msg: OutMessage,
+ ignore_queue_request: boolean = true
+): Promise {
+ const APP_VERSION = env.PUBLIC_APP_SEMVER;
const socket = get(socketStore);
- const data = JSON.stringify(msg);
+ let data = JSON.stringify(msg);
// console.log('try sending ', data);
@@ -64,6 +105,24 @@ export function sendMessage(msg: OutMessage, ignore_queue_request: boolean = tru
return false;
}
+ // console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION));
+
+ if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
+ // console.log('sending secured');
+ let sharedKeyRes = get(sharedKey);
+
+ // do encrypt
+ if (sharedKeyRes != null)
+ data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
+ }
+
+ // console.log('[WS Send]', {
+ // type: logMessage.type,
+ // service: logMessage.payload?.srv_name,
+ // param: logMessage.payload?.values?.param,
+ // bytes: data.length,
+ // secured: isSecuredAppVersion(APP_VERSION)
+ // });
socket.send(data);
return true;
}
diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts
index 8fb6c39..9b89acd 100644
--- a/src/lib/core/services/sheetService.ts
+++ b/src/lib/core/services/sheetService.ts
@@ -7,74 +7,238 @@ import {
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
- setPendingProductCodesCountry
+ setPendingProductCodesCountry,
+ setPendingPriceSlotsCountry,
+ priceSlotsLoading,
+ resetPriceSlotsCountry
} from '../stores/sheetStore';
+import type { PriceSlot } from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
-export function requestCatalogs(country: string): boolean {
- return sendCommandRequest('sheet', {
+type SheetCellUpdate = { value: string; coord: { row: number; col: number } };
+type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] };
+type SheetRowCreate = { header?: string[]; cells: string[] };
+
+export async function requestCatalogs(country: string): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
param: 'catalogs'
});
}
-export function requestPriceSlots(country: string): boolean {
- return sendCommandRequest('sheet', {
+/**
+ * Register a newly created catalog as a Grist table so it shows in the overview
+ * and menus can be added to it. `catalog` is the .skt filename produced by
+ * /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt").
+ */
+export async function addCatalog(
+ country: string,
+ catalogName: string,
+ catalog: string
+): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
- param: 'priceslot'
+ catalog: catalog,
+ catalog_name: catalogName,
+ param: 'add/catalog'
});
}
-export function updatePriceSlot(
- country: string,
- content: {
- slot: number;
- name: string;
- description: string;
- products: { product_code: string; price: number | null; row_index?: number }[];
+export async function requestPriceSlots(country: string): Promise {
+ setPendingPriceSlotsCountry(country);
+ resetPriceSlotsCountry(country);
+ return requestPriceSlotOption(country, 'PriceSlot');
+}
+
+export async function requestPriceSlot(country: string, slotNumber: number): Promise {
+ setPendingPriceSlotsCountry(country);
+ return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
+}
+
+async function requestPriceSlotOption(country: string, option: string): Promise {
+ const request_id = crypto.randomUUID();
+
+ streamingRawData.update((data) => ({
+ ...data,
+ priceslot: {
+ request_id,
+ country,
+ chunks: [],
+ rawParts: []
+ }
+ }));
+ priceSlotsLoading.set(true);
+
+ const values = {
+ country: country,
+ param: 'price',
+ option,
+ stream: true,
+ request_id
+ };
+ console.log('[sheetService] Sending PriceSlot request:', values);
+ const sent = await sendCommandRequest('sheet', values);
+ console.log('[sheetService] PriceSlot request sent:', sent);
+ if (!sent) {
+ priceSlotsLoading.set(false);
}
-): boolean {
- return sendCommandRequest('sheet', {
+ return sent;
+}
+
+export async function refreshPriceSlotList(country: string): Promise {
+ return requestPriceSlotOption(country, 'PriceSlot');
+}
+
+export async function updatePriceSlot(
+ country: string,
+ slot: PriceSlot,
+ content: SheetRowUpdate[]
+): Promise {
+ // console.log('[sheetService] Sending PriceSlot update:', {
+ // country,
+ // slot: slot.slot,
+ // name: slot.name,
+ // description: slot.description,
+ // kind: slot.kind,
+ // rows: content.length,
+ // param: 'update/price',
+ // option: `PriceSlot${slot.slot}`
+ // });
+
+ const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
- param: 'update/priceslot'
+ param: 'update/price',
+ option: `PriceSlot${slot.slot}`
});
+
+ console.log('[sheetService] PriceSlot update sent:', {
+ country,
+ slot: slot.slot,
+ sent
+ });
+
+ return sent;
}
-export function enterRoom(country: string, catalog: string): boolean {
- return sendCommandRequest('sheet', {
+export async function addPriceSlot(
+ country: string,
+ slot: PriceSlot,
+ content: SheetRowCreate[]
+): Promise {
+ console.log('[sheetService] Sending PriceSlot create:', {
+ country,
+ slot: slot.slot,
+ name: slot.name,
+ description: slot.description,
+ kind: slot.kind,
+ rows: content.length,
+ param: 'add/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ const sent = await sendCommandRequest('sheet', {
+ country: country,
+ content: content,
+ param: 'add/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ console.log('[sheetService] PriceSlot create sent:', {
+ country,
+ slot: slot.slot,
+ sent
+ });
+
+ return sent;
+}
+
+export async function addPriceSlotRows(
+ country: string,
+ slot: PriceSlot,
+ content: SheetRowCreate[]
+): Promise {
+ if (!content || content.length === 0) return true;
+
+ const sent = await sendCommandRequest('sheet', {
+ country: country,
+ content: content,
+ param: 'add/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ console.log('[sheetService] PriceSlot rows add sent:', {
+ country,
+ slot: slot.slot,
+ rows: content.length,
+ sent
+ });
+
+ return sent;
+}
+
+export async function deletePriceSlotRows(
+ country: string,
+ slot: PriceSlot,
+ rowIds: number[]
+): Promise {
+ if (!rowIds || rowIds.length === 0) return true;
+
+ const sent = await sendCommandRequest('sheet', {
+ country: country,
+ content: rowIds.map((target_id) => ({ target_id })),
+ param: 'delete/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ console.log('[sheetService] PriceSlot rows delete sent:', {
+ country,
+ slot: slot.slot,
+ rows: rowIds.length,
+ sent
+ });
+
+ return sent;
+}
+
+export async function enterRoom(country: string, catalog: string): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'enter'
});
}
-export function sendHeartbeat(country: string, catalog: string): boolean {
- return sendCommandRequest('sheet', {
+export async function sendHeartbeat(country: string, catalog: string): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'heartbeat'
});
}
-export function exitRoom(country: string, catalog: string): boolean {
- return sendCommandRequest('sheet', {
+export async function exitRoom(country: string, catalog: string): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'exit'
});
}
-export function requestCatalogMenu(country: string, catalog: string): boolean {
- return sendCommandRequest('sheet', {
+export async function requestCatalogMenu(country: string, catalog: string): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
param: 'catalog/menu'
});
}
-export function updateMenu(country: string, catalog: string, content: any[]): boolean {
- return sendCommandRequest('sheet', {
+export async function updateMenu(
+ country: string,
+ catalog: string,
+ content: any[]
+): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@@ -82,9 +246,9 @@ export function updateMenu(country: string, catalog: string, content: any[]): bo
});
}
-export function addMenu(country: string, catalog: string, content: any[]): boolean {
+export async function addMenu(country: string, catalog: string, content: any[]): Promise {
console.log('[sheetService] Adding menu:', { country, catalog, content });
- const sent = sendCommandRequest('sheet', {
+ const sent = await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@@ -94,9 +258,13 @@ export function addMenu(country: string, catalog: string, content: any[]): boole
return sent;
}
-export function deleteMenu(country: string, catalog: string, targetIds: number[]): boolean {
+export async function deleteMenu(
+ country: string,
+ catalog: string,
+ targetIds: number[]
+): Promise {
const content = targetIds.map((id) => ({ target_id: id }));
- return sendCommandRequest('sheet', {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: content,
@@ -104,12 +272,12 @@ export function deleteMenu(country: string, catalog: string, targetIds: number[]
});
}
-export function swapMenu(
+export async function swapMenu(
country: string,
catalog: string,
swaps: { source_id: number; target_id: number }[]
-): boolean {
- return sendCommandRequest('sheet', {
+): Promise {
+ return await sendCommandRequest('sheet', {
country: country,
catalog: catalog,
content: swaps,
@@ -117,7 +285,7 @@ export function swapMenu(
});
}
-export function requestListMenu(country: string, boxid?: string): boolean {
+export async function requestListMenu(country: string, boxid?: string): Promise {
const curr_user = get(auth);
let user_info: any = {};
@@ -134,7 +302,7 @@ export function requestListMenu(country: string, boxid?: string): boolean {
console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid);
- return sendMessage({
+ return await sendMessage({
type: 'list_menu',
payload: {
user_info,
@@ -144,7 +312,7 @@ export function requestListMenu(country: string, boxid?: string): boolean {
});
}
-export function requestGenLayout(country: string): boolean {
+export async function requestGenLayout(country: string): Promise {
const curr_user = get(auth);
let user_info: any = {};
@@ -160,7 +328,7 @@ export function requestGenLayout(country: string): boolean {
console.log('[sheetService] Sending gen-layout request for country:', country);
- return sendMessage({
+ return await sendMessage({
type: 'command',
payload: {
user_info,
@@ -179,9 +347,13 @@ export function requestGenLayout(country: string): boolean {
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
-export function requestSheetPrice(country: string, productCodes: string[]): boolean {
+export async function requestSheetPrice(
+ country: string,
+ productCodes: string[],
+ force = false
+): Promise {
// Check if already sent
- if (hasSheetPriceBeenSent('price')) {
+ if (!force && hasSheetPriceBeenSent('price')) {
console.warn('[sheetService] Price request already sent, skipping');
return false;
}
@@ -210,12 +382,61 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool
// Convert to array of objects (backend expects objects, not strings)
const content = productCodes.map((code) => ({ product_code: code }));
- console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id);
+ console.log(
+ '[sheetService] Sending sheet price request for country:',
+ country,
+ 'codes:',
+ productCodes.length,
+ 'request_id:',
+ request_id
+ );
- const sent = sendCommandRequest('sheet', {
+ const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'price',
+ option: 'price',
+ stream: true,
+ request_id
+ });
+ console.log('[sheetService] Sheet price request sent:', { country, request_id, sent });
+
+ if (sent) {
+ markSheetPriceAsSent('price');
+ } else {
+ sheetPriceLoading.set(false);
+ }
+
+ return sent;
+}
+
+export async function requestAllSheetPrice(country: string, force = false): Promise {
+ if (!force && hasSheetPriceBeenSent('price')) {
+ console.warn('[sheetService] Price request already sent, skipping');
+ return false;
+ }
+
+ const request_id = crypto.randomUUID();
+
+ streamingRawData.update((data) => ({
+ ...data,
+ price: {
+ request_id,
+ country,
+ chunks: [],
+ rawParts: []
+ }
+ }));
+
+ sheetPriceLoading.set(true);
+
+ console.log('[sheetService] Sending all sheet price request:', { country, request_id });
+
+ const sent = await sendCommandRequest('sheet', {
+ country,
+ content: [],
+ param: 'price',
+ option: 'price',
stream: true,
request_id
});
@@ -233,18 +454,23 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool
* Update price data in sheet
* content: [{ row_index: number, cells: [{ value: string, coord: { row: number, col: number } }] }]
*/
-export function updateSheetPrice(
+export async function updateSheetPrice(
country: string,
content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[]
-): boolean {
+): Promise {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to update');
return false;
}
- console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length);
+ console.log(
+ '[sheetService] Updating sheet price for country:',
+ country,
+ 'items:',
+ content.length
+ );
- return sendCommandRequest('sheet', {
+ return await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'update/price'
@@ -255,18 +481,24 @@ export function updateSheetPrice(
* Add new price rows to sheet (for product codes that don't exist in price sheet)
* content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }]
*/
-export function addSheetPrice(
+export async function addSheetPrice(
country: string,
content: { cells: string[] }[]
-): boolean {
+): Promise {
if (!content || content.length === 0) {
console.warn('[sheetService] No content to add');
return false;
}
- console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content);
+ console.log(
+ '[sheetService] Adding price rows for country:',
+ country,
+ 'items:',
+ content.length,
+ content
+ );
- return sendCommandRequest('sheet', {
+ return await sendCommandRequest('sheet', {
country: country,
content: content,
param: 'add/price'
diff --git a/src/lib/core/stores/recipeStore.ts b/src/lib/core/stores/recipeStore.ts
index ac21a3a..7af3a35 100644
--- a/src/lib/core/stores/recipeStore.ts
+++ b/src/lib/core/stores/recipeStore.ts
@@ -51,6 +51,7 @@ export const toppingGroupFromServerQuery = writable([]);
export const latestRecipeToppingData = writable([]);
// edit data update
+/// NOTE: Will be obsolete in future, and replace with `EventBus` style.
export const recipeDataEvent = writable<{
event_type: string;
payload: any;
diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts
index eb2d626..cecd26f 100644
--- a/src/lib/core/stores/sheetStore.ts
+++ b/src/lib/core/stores/sheetStore.ts
@@ -24,16 +24,304 @@ export interface PriceSlotProduct {
row_index?: number;
}
+export interface PriceSlotServiceRow {
+ row_index?: number;
+ cells: Record;
+}
+
export interface PriceSlot {
slot: number;
name: string;
description: string;
+ kind?: 'price' | 'service';
+ header?: string[];
products: PriceSlotProduct[];
+ serviceRows?: PriceSlotServiceRow[];
}
export const priceSlots = writable>({});
+export const priceSlotNamespaces = writable>({});
export const priceSlotsLoading = writable(false);
export const priceSlotsError = writable(null);
+let pendingPriceSlotsCountry = '';
+
+export function setPendingPriceSlotsCountry(country: string) {
+ pendingPriceSlotsCountry = country.toLowerCase();
+}
+
+export function resetPriceSlotsCountry(country: string) {
+ const key = country.toLowerCase();
+ priceSlots.update((data) => ({
+ ...data,
+ [key]: []
+ }));
+ priceSlotNamespaces.update((data) => ({
+ ...data,
+ [key]: []
+ }));
+ priceSlotsError.set(null);
+}
+
+function normalizePriceSlotProduct(product: any): PriceSlotProduct | null {
+ const cells = Array.isArray(product?.cells) ? product.cells : [];
+ const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value;
+ const productCode =
+ product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1);
+
+ if (!productCode) return null;
+
+ const priceValue =
+ product?.price ??
+ product?.Price ??
+ product?.value ??
+ product?.cash_price ??
+ product?.CashPrice ??
+ cellValue(5);
+ const price =
+ priceValue === '' || priceValue === undefined || priceValue === null
+ ? null
+ : Number(priceValue);
+
+ return {
+ product_code: String(productCode),
+ name: String(
+ product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? ''
+ ),
+ price: Number.isNaN(price) ? null : price,
+ row_index: product?.row_index ?? product?.row
+ };
+}
+
+function getPriceSlotHeader(slot: any): string[] {
+ const header = Array.isArray(slot?.header) ? slot.header : [];
+ return header.map((value: any) => String(value ?? '').trim());
+}
+
+function isServicePriceSlotHeader(header: string[]): boolean {
+ return header.some((value) => value.toLowerCase() === 'servicetype');
+}
+
+function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null {
+ const cells = Array.isArray(row?.cells) ? row.cells : [];
+ const mappedCells = header.reduce>((result, columnName, index) => {
+ if (!columnName) return result;
+ const value =
+ row?.[columnName] ??
+ row?.[columnName.replace(/\s+/g, '')] ??
+ cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ??
+ '';
+ result[columnName] = String(value ?? '');
+ return result;
+ }, {});
+
+ if (Object.values(mappedCells).every((value) => value === '')) return null;
+
+ return {
+ row_index: row?.row_index ?? row?.row,
+ cells: mappedCells
+ };
+}
+
+function normalizePriceSlot(slot: any, index: number): PriceSlot {
+ const sheetName = slot?.sheet ?? slot?.Sheet;
+ const displayName = slot?.name ?? slot?.title ?? sheetName;
+ const slotNumber = Number(
+ slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1
+ );
+ const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? [];
+ const header = getPriceSlotHeader(slot);
+ const isServiceSlot = isServicePriceSlotHeader(header);
+ const headerName = isServiceSlot ? header[12] : header[10];
+ const headerDescription = isServiceSlot ? header[13] : header[11];
+ const products = (Array.isArray(productsSource) ? productsSource : [])
+ .map(normalizePriceSlotProduct)
+ .filter((product): product is PriceSlotProduct => product !== null);
+ const serviceRows = isServiceSlot
+ ? (Array.isArray(productsSource) ? productsSource : [])
+ .map((row) => normalizePriceSlotServiceRow(row, header))
+ .filter((row): row is PriceSlotServiceRow => row !== null)
+ : [];
+
+ return {
+ slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber,
+ name: String(
+ headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}`
+ ),
+ description: String(headerDescription ?? ''),
+ kind: isServiceSlot ? 'service' : 'price',
+ header,
+ products: isServiceSlot ? [] : products,
+ serviceRows
+ };
+}
+
+function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot {
+ const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1);
+ const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber;
+
+ return {
+ slot,
+ name: sheetName || `PriceSlot${slot}`,
+ description: '',
+ kind: 'price',
+ header: [],
+ products: []
+ };
+}
+
+function getPriceSlotSource(content: any) {
+ return (
+ content?.priceSlots ??
+ content?.priceslots ??
+ content?.price_slots ??
+ content?.slots ??
+ content?.data ??
+ content?.value ??
+ content?.content ??
+ content
+ );
+}
+
+function getPriceSlotItems(content: any): any[] {
+ const source = getPriceSlotSource(content);
+
+ if (Array.isArray(source)) {
+ return source.flatMap((item) => {
+ if (Array.isArray(item?.sheet)) {
+ return item.sheet.map((sheetName: any, index: number) =>
+ normalizePriceSlotNamespace(String(sheetName ?? ''), index)
+ );
+ }
+ return [item];
+ });
+ }
+ if (Array.isArray(source?.sheet)) {
+ return source.sheet.map((sheetName: any, index: number) =>
+ normalizePriceSlotNamespace(String(sheetName ?? ''), index)
+ );
+ }
+ if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source];
+ if (typeof source === 'object' && source) {
+ return Object.entries(source).map(([key, value]) => ({
+ ...(typeof value === 'object' && value ? value : {}),
+ name: (value as any)?.name ?? key
+ }));
+ }
+
+ return [];
+}
+
+export function handlePriceSlotsResponse(content: any) {
+ console.log('[PriceSlot] Raw backend response:', {
+ items: Array.isArray(content) ? content.length : undefined,
+ keys:
+ content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : []
+ });
+ const country = String(
+ content?.country ?? content?.Country ?? pendingPriceSlotsCountry
+ ).toLowerCase();
+ const source = getPriceSlotSource(content);
+ const slotList = getPriceSlotItems(content);
+
+ if (!country || slotList.length === 0) {
+ console.warn('[PriceSlot] No slot list found:', {
+ country,
+ sourceItems: Array.isArray(source) ? source.length : undefined
+ });
+ priceSlotsError.set('No PriceSlot data found in backend response');
+ priceSlotsLoading.set(false);
+ return;
+ }
+
+ const normalizedSlots = slotList.map((slot, index) =>
+ isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index)
+ );
+
+ if (normalizedSlots.length === 0) {
+ console.warn('[PriceSlot] Response did not include usable rows:', {
+ country,
+ slotListItems: slotList.length
+ });
+ return;
+ }
+
+ console.log('[PriceSlot] Normalized slots:', {
+ country,
+ slots: normalizedSlots.length,
+ firstSlot: normalizedSlots[0]
+ ? {
+ slot: normalizedSlots[0].slot,
+ name: normalizedSlots[0].name,
+ kind: normalizedSlots[0].kind,
+ products: normalizedSlots[0].products.length,
+ serviceRows: normalizedSlots[0].serviceRows?.length ?? 0
+ }
+ : undefined
+ });
+
+ const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any));
+
+ if (loadedSlots.length > 0) {
+ priceSlots.update((data) => {
+ const merged = new Map();
+ for (const slot of data[country] ?? []) {
+ merged.set(slot.slot, slot);
+ }
+ for (const slot of loadedSlots) {
+ merged.set(slot.slot, slot);
+ }
+
+ return {
+ ...data,
+ [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
+ };
+ });
+ }
+ priceSlotNamespaces.update((data) => {
+ const merged = new Map();
+ for (const slot of data[country] ?? []) {
+ merged.set(slot.slot, slot);
+ }
+ for (const slot of normalizedSlots) {
+ merged.set(slot.slot, slot);
+ }
+
+ return {
+ ...data,
+ [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
+ };
+ });
+ priceSlotsError.set(null);
+ priceSlotsLoading.set(false);
+}
+
+export function isPriceSlotsPayload(content: any): boolean {
+ const source = getPriceSlotSource(content);
+
+ if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true;
+ if (Array.isArray(source?.sheet)) {
+ return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'));
+ }
+ if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot');
+ if (!Array.isArray(source)) return false;
+ return source.some(
+ (item) =>
+ String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
+ (Array.isArray(item?.sheet) &&
+ item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')))
+ );
+}
+
+function isPriceSlotNamespace(slot: any): slot is PriceSlot {
+ return (
+ typeof slot?.slot === 'number' &&
+ Array.isArray(slot?.products) &&
+ slot.products.length === 0 &&
+ Array.isArray(slot?.header) &&
+ slot.header.length === 0 &&
+ slot.name?.startsWith?.('PriceSlot')
+ );
+}
export const countryPrimaryLanguageMap: Record = {
THAI: 'Thai',
@@ -78,11 +366,19 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
// Sheet column configuration by country for new_layout_v2
// Maps language keys to column indices and product code columns
-export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record;
- productCode: { hot: number; cold: number; blend: number };
- primaryLanguage: string;
-}> = {
+export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
+ string,
+ {
+ // Column→language map for the new-layout-v2 sheet (menu name/desc rows).
+ language: Record;
+ // Column→language map for the name-desc-v2 sheet (Translations). Different
+ // namespace/sheet so the columns can differ from new-layout-v2; falls back
+ // to `language` when not set (countries where the two are identical).
+ nameDescLanguage?: Record;
+ productCode: { hot: number; cold: number; blend: number };
+ primaryLanguage: string;
+ }
+> = {
tha: {
language: { en: 3, th: 4, zh: 5, my: 8 },
productCode: { hot: 9, cold: 10, blend: 11 },
@@ -90,6 +386,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record = {
+export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
+ string,
+ {
+ cash_price: string[]; // Possible header names for cash price
+ non_cash_price: string[]; // Possible header names for non-cash price
+ }
+> = {
tha: {
cash_price: ['Price'],
non_cash_price: ['MainPrice']
@@ -366,7 +671,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record h.toLowerCase() === name.toLowerCase());
+ const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase());
if (idx !== -1) {
// Return col index (header index + 1 because cells start from col 1)
return idx + 1;
@@ -382,7 +687,9 @@ export const lastRequestSheetPrice = writable>({});
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
-export const sheetPriceAllRows = writable>>({});
+export const sheetPriceAllRows = writable<
+ Record>
+>({});
// Helper function to get price value from cells using dynamic header lookup
export function getPriceFromCells(
@@ -390,29 +697,43 @@ export function getPriceFromCells(
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
+ const colIdx = getPriceColumnIndex(country, priceType);
+ if (colIdx < 0) return null;
+
+ // Find the cell with matching column index
+ const priceCell = cells.find((c) => c.coord?.col === colIdx);
+ return priceCell?.value ?? null;
+}
+
+export function getPriceColumnIndex(
+ country: string,
+ priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
+): number {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
console.warn(`[getPriceFromCells] No header found for country: ${country}`);
- return null;
+ return -1;
}
// Get possible header names for this country
- const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
- const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
+ const headerNames =
+ PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
+ const possibleNames =
+ priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
// Find the column index for this price type
const colIdx = findHeaderIndex(headers, possibleNames);
//console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
if (colIdx < 0) {
- console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
- return null;
+ console.warn(
+ `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
+ possibleNames
+ );
+ return -1;
}
- // Find the cell with matching column index
- const priceCell = cells.find((c) => c.coord?.col === colIdx);
- //console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
- return priceCell?.value ?? null;
+ return colIdx;
}
// Store for tracking streaming state
@@ -444,15 +765,20 @@ export const streamingRawData = writable<
// Handler: raw_stream header (e.g., raw_stream_price)
export function handleRawStreamHeader(subtype: string, payload: any) {
- console.log(`[RawStream] Header for ${subtype}:`, payload);
+ let targetSubtype = subtype;
+ const currentData = get(streamingRawData);
+ if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
+ targetSubtype = 'priceslot';
+ }
+
+ console.log(`[RawStream] Header for ${targetSubtype}:`, payload);
// Get existing stream data to preserve country from request
- const currentData = get(streamingRawData);
- const existingData = currentData[subtype];
+ const existingData = currentData[targetSubtype];
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
request_id: payload.request_id,
header: payload.header || payload.headers,
country: payload.country || existingData?.country || '',
@@ -461,7 +787,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
}
}));
- if (subtype === 'price') {
+ if (targetSubtype === 'price') {
sheetPriceStreamMeta.set({
request_id: payload.request_id,
country: payload.country || existingData?.country || '',
@@ -473,13 +799,21 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
export function handleRawStreamChunk(subtype: string, payload: any) {
- console.log(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length);
-
const currentData = get(streamingRawData);
- const streamData = currentData[subtype];
+ let targetSubtype = subtype;
+ if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
+ targetSubtype = 'priceslot';
+ }
+
+ console.log(
+ `[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
+ payload.raw?.length
+ );
+
+ const streamData = currentData[targetSubtype];
if (!streamData || streamData.request_id !== payload.request_id) {
- console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
+ console.warn(`[RawStream] Chunk received for unknown stream: ${targetSubtype}`);
return;
}
@@ -488,13 +822,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
rawParts: [...(streamData.rawParts || []), payload.raw]
}
}));
- console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`);
+ console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${targetSubtype}`);
return;
}
@@ -504,25 +838,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
chunks: [...streamData.chunks, ...contentArray]
}
}));
- console.log(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`);
+ console.log(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`);
}
// Handler: raw_stream end (e.g., raw_stream_end_price)
export function handleRawStreamEnd(subtype: string, payload: any) {
- console.log(`[RawStream] End payload for ${subtype}:`, payload);
-
const currentData = get(streamingRawData);
- const streamData = currentData[subtype];
+ let targetSubtype = subtype;
+ if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
+ targetSubtype = 'priceslot';
+ }
+
+ console.log(`[RawStream] End payload for ${targetSubtype}:`, payload);
+
+ const streamData = currentData[targetSubtype];
if (!streamData || streamData.request_id !== payload.request_id) {
- console.warn(`[RawStream] End received for unknown stream: ${subtype}`);
+ console.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
return;
}
@@ -554,18 +893,41 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
}
}
- console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
+ console.log(
+ `[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}`
+ );
- if (subtype === 'price') {
- processSheetPriceData(country, streamData.header || [], chunks);
- sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
- sheetPriceLoading.set(false);
+ if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
+ handlePriceSlotsResponse({ country, slots: chunks });
+ }
+ if (targetSubtype === 'priceslot') {
+ priceSlotsLoading.set(false);
+ }
+
+ if (targetSubtype === 'price') {
+ const looksLikePriceSlot = chunks.some((item) => {
+ return (
+ String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
+ (Array.isArray(item?.sheet) &&
+ item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) ||
+ item?.option === 'PriceSlot' ||
+ item?.param === 'priceslot'
+ );
+ });
+
+ if (looksLikePriceSlot) {
+ handlePriceSlotsResponse({ country, slots: chunks });
+ } else {
+ processSheetPriceData(country, streamData.header || [], chunks);
+ sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
+ sheetPriceLoading.set(false);
+ }
}
// Clear the streaming data
streamingRawData.update((data) => {
const newData = { ...data };
- delete newData[subtype];
+ delete newData[targetSubtype];
return newData;
});
}
@@ -600,8 +962,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Find column indices dynamically from header
// product_code header is typically "ProductCode" or similar
- const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
- console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
+ const productCodeIdx = findHeaderIndex(effectiveHeader, [
+ 'ProductCode',
+ 'Product_Code',
+ 'product_code',
+ 'Code'
+ ]);
+ console.log(
+ `[SheetPrice] productCodeIdx from header:`,
+ productCodeIdx,
+ 'header:',
+ effectiveHeader
+ );
const priceByProductCode: Record = {};
// Track ALL rows per product code (for duplicates)
@@ -702,7 +1074,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Log duplicates info
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
if (duplicates.length > 0) {
- console.log(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3));
+ console.log(
+ `[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`,
+ duplicates.slice(0, 3)
+ );
}
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
const sampleKey = Object.keys(priceByProductCode)[0];
@@ -710,6 +1085,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
}
}
+export function handleSheetPriceResponse(country: string, content: any) {
+ const resolvedCountry = country || get(streamingRawData).price?.country || '';
+ const chunks = Array.isArray(content) ? content : [content];
+ processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
+ sheetPriceLoading.set(false);
+}
+
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);
@@ -769,14 +1151,24 @@ export function loadProductCodesFromCache(country?: string): boolean {
// Only load if country matches (or no country filter specified)
if (data.codes && Array.isArray(data.codes)) {
if (country && data.country && data.country !== country) {
- console.log('[sheetStore] Cache is for different country:', data.country, '!= requested:', country);
+ console.log(
+ '[sheetStore] Cache is for different country:',
+ data.country,
+ '!= requested:',
+ country
+ );
// Clear the store for different country
existingProductCodes.set(new Set());
return false;
}
existingProductCodes.set(new Set(data.codes));
currentProductCodesCountry = data.country || '';
- console.log('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown');
+ console.log(
+ '[sheetStore] Loaded',
+ data.codes.length,
+ 'product codes from cache for',
+ data.country || 'unknown'
+ );
return true;
}
}
@@ -798,7 +1190,13 @@ export function clearProductCodes() {
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
// Use pending country if not in payload
const country = payload.country || pendingProductCodesCountry;
- console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
+ console.log(
+ '[sheetStore] Received list_menu_response for',
+ country,
+ ':',
+ payload.codes?.length,
+ 'codes'
+ );
if (payload && payload.codes) {
existingProductCodes.set(new Set(payload.codes));
@@ -814,7 +1212,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
timestamp: Date.now()
})
);
- console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
+ console.log(
+ '[sheetStore] Saved',
+ payload.codes.length,
+ 'product codes to cache for',
+ country
+ );
} catch (e) {
console.warn('[sheetStore] Failed to save to cache:', e);
}
diff --git a/src/lib/core/stores/terminalDrawer.ts b/src/lib/core/stores/terminalDrawer.ts
new file mode 100644
index 0000000..a41044b
--- /dev/null
+++ b/src/lib/core/stores/terminalDrawer.ts
@@ -0,0 +1,28 @@
+import { writable } from 'svelte/store';
+
+/**
+ * Store for managing the terminal drawer open/closed state.
+ * Also holds command history and connection status.
+ */
+export const terminalDrawerOpen = writable(false);
+
+/**
+ * Toggle the terminal drawer open/closed
+ */
+export function toggleTerminalDrawer() {
+ terminalDrawerOpen.update((v) => !v);
+}
+
+/**
+ * Open the terminal drawer
+ */
+export function openTerminalDrawer() {
+ terminalDrawerOpen.set(true);
+}
+
+/**
+ * Close the terminal drawer
+ */
+export function closeTerminalDrawer() {
+ terminalDrawerOpen.set(false);
+}
diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts
index 720fb7e..a824f8b 100644
--- a/src/lib/core/stores/websocketStore.ts
+++ b/src/lib/core/stores/websocketStore.ts
@@ -7,15 +7,20 @@ import { auth } from '../client/firebase';
import { auth as authStore } from '$lib/core/stores/auth';
import { addNotification } from './noti';
import { permission } from './permissions';
+import { WebCryptoHelper } from '../utils/crypto';
let socket: WebSocket | null = null;
let reconnectTimeout: any;
let socketCheck: any;
+let sendAuthInfoInterval: any;
const ENABLE_WS_DEBUG: boolean = false;
export const socketConnectionOfflineCount = writable(0);
export const socketAlreadySendHeartbeat = writable(0);
export const socketStore = writable(null);
+export const wsAuthReady = writable(false);
+
+export const sharedKey = writable(null);
export function waitForOpenSocket(timeoutMs = 8000): Promise {
const currentSocket = get(socketStore);
@@ -49,7 +54,32 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise {
});
}
-export function connectToWebsocket(id_token?: string) {
+export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise {
+ const openSocket = await waitForOpenSocket(timeoutMs);
+ if (!openSocket) return null;
+ if (get(wsAuthReady)) return openSocket;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let unsubscribe = () => {};
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ unsubscribe();
+ resolve(null);
+ }, timeoutMs);
+
+ unsubscribe = wsAuthReady.subscribe((ready) => {
+ if (!ready || settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ unsubscribe();
+ resolve(openSocket);
+ });
+ });
+}
+
+export async function connectToWebsocket(id_token?: string) {
if (browser) {
// console.log('connecting to ', env.PUBLIC_WSS);
try {
@@ -57,12 +87,13 @@ export function connectToWebsocket(id_token?: string) {
return;
}
- let productionMode = env.PUBLIC_WSS.startsWith('wss');
-
- let ws_url = productionMode ? `${env.PUBLIC_WSS}?token=${id_token}` : `${env.PUBLIC_WSS}`;
+ let ws_url = env.PUBLIC_WSS;
socket = new WebSocket(ws_url);
+ wsAuthReady.set(false);
+ sharedKey.set(null);
+ const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
- socket.addEventListener('open', () => {
+ socket.addEventListener('open', async () => {
socketStore.set(socket);
addNotification('INFO:Connected!');
@@ -74,29 +105,45 @@ export function connectToWebsocket(id_token?: string) {
let auth_data = get(authStore);
let perms = get(permission);
- // Debug: check if auth_data has uid
- console.log('[WS Auth] Sending auth with:', {
- uid: auth_data?.uid,
- name: auth_data?.displayName,
- email: auth_data?.email
- });
+ socket.send(
+ JSON.stringify({
+ token: id_token ?? '',
+ client_public_key: publicKeyBase64,
+ client_version: env.PUBLIC_APP_SEMVER
+ })
+ );
- sendMessage({
- type: 'auth',
- payload: {
- user: {
- uid: auth_data?.uid ?? '',
- name: auth_data?.displayName ?? '',
- email: auth_data?.email ?? '',
- permissions: perms.join(',')
- }
+ sendAuthInfoInterval = setInterval(async () => {
+ if (get(sharedKey)) {
+ auth_data = get(authStore);
+ perms = get(permission);
+ // Debug: check if auth_data has uid
+ console.log('[WS Auth] Sending auth info with:', {
+ uid: auth_data?.uid,
+ name: auth_data?.displayName,
+ email: auth_data?.email,
+ date: new Date()
+ });
+ const sent = await sendMessage({
+ type: 'auth',
+ payload: {
+ user: {
+ uid: auth_data?.uid ?? '',
+ name: auth_data?.displayName ?? '',
+ email: auth_data?.email ?? '',
+ permissions: perms.join(',')
+ }
+ }
+ });
+ wsAuthReady.set(sent);
+ clearInterval(sendAuthInfoInterval);
}
- });
+ }, 2000);
}
console.log(socket);
// heartbeat 10s
- socketCheck = setInterval(() => {
+ socketCheck = setInterval(async () => {
if (get(socketAlreadySendHeartbeat) > 0) {
let heartbeat_may_offline_count = get(socketConnectionOfflineCount);
@@ -108,13 +155,14 @@ export function connectToWebsocket(id_token?: string) {
socketConnectionOfflineCount.set(0);
socketAlreadySendHeartbeat.set(0);
- connectToWebsocket(id_token);
+ id_token = await auth.currentUser?.getIdToken(true);
+ await connectToWebsocket(id_token);
return;
}
if (socket != null) {
- sendMessage({
+ await sendMessage({
type: 'heartbeat',
payload: {}
});
@@ -130,32 +178,41 @@ export function connectToWebsocket(id_token?: string) {
if (auth.currentUser && socket == null) {
console.log('try reconnect websocket ...');
// retry again
- reconnectTimeout = setTimeout(() => {
- connectToWebsocket(id_token);
+ reconnectTimeout = setTimeout(async () => {
+ id_token = await auth.currentUser?.getIdToken(true);
+ await connectToWebsocket(id_token);
}, 5000);
}
});
- socket.addEventListener('message', (event) => {
- handleIncomingMessages(event.data);
+ socket.addEventListener('message', async (event) => {
+ await handleIncomingMessages(event.data, privateKey);
});
socket.addEventListener('close', () => {
socketStore.set(null);
+ wsAuthReady.set(false);
+ sharedKey.set(null);
socket = null;
clearInterval(socketCheck);
+ clearInterval(sendAuthInfoInterval);
if (auth.currentUser && !socket) {
console.log('try reconnect websocket ...');
// retry again
- reconnectTimeout = setTimeout(() => connectToWebsocket(id_token), 5000);
+ reconnectTimeout = setTimeout(async () => {
+ id_token = await auth.currentUser?.getIdToken(true);
+ await connectToWebsocket(id_token);
+ }, 5000);
}
});
socket.addEventListener('error', (e) => {
// console.log('WebSocket error: ', e);
socketStore.set(null);
+ wsAuthReady.set(false);
+ sharedKey.set(null);
});
} catch (socket_error: any) {
if (ENABLE_WS_DEBUG) {
diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts
index f51bd46..13dff0e 100644
--- a/src/lib/core/types/outMessage.ts
+++ b/src/lib/core/types/outMessage.ts
@@ -1,4 +1,5 @@
export type OutMessage =
+ | { token: any; client_public_key: any }
| { type: 'chat'; payload: string }
| { type: 'ping' }
| { type: 'lock'; payload: { field: string } }
@@ -47,14 +48,14 @@ export type OutMessage =
payload: {};
}
| {
- type: 'sheet' | 'command';
+ type: 'sheet' | 'command' | 'upload-log';
payload: {
user_info: any;
srv_name: string;
values: any;
};
}
- | {
+ | {
type: 'list_menu';
payload: {
user_info: any;
@@ -62,7 +63,6 @@ export type OutMessage =
boxid?: string;
};
}
-
| {
type: 'price';
payload: {
diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts
new file mode 100644
index 0000000..7cbe33f
--- /dev/null
+++ b/src/lib/core/utils/crypto.ts
@@ -0,0 +1,78 @@
+export class WebCryptoHelper {
+ private static bytesToBase64(bytes: Uint8Array) {
+ const chunkSize = 0x8000;
+ let binary = '';
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+ }
+
+ return btoa(binary);
+ }
+
+ static async generateKeyPair() {
+ const keyPair = await window.crypto.subtle.generateKey(
+ {
+ name: 'ECDH',
+ namedCurve: 'P-256'
+ },
+ true,
+ ['deriveKey', 'deriveBits']
+ );
+
+ const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
+ const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
+
+ return { privateKey: keyPair.privateKey, publicKeyBase64 };
+ }
+
+ static async deriveSharedKey(clientPrivateKey: any, serverPublicKeyBase64: any) {
+ const binarySign = atob(serverPublicKeyBase64);
+ const bytes = new Uint8Array(binarySign.length);
+ for (let i = 0; i < binarySign.length; i++) {
+ bytes[i] = binarySign.charCodeAt(i);
+ }
+
+ const importedServerPublic = await window.crypto.subtle.importKey(
+ 'raw',
+ bytes,
+ { name: 'ECDH', namedCurve: 'P-256' },
+ true,
+ []
+ );
+ return await window.crypto.subtle.deriveKey(
+ { name: 'ECDH', public: importedServerPublic },
+ clientPrivateKey,
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['encrypt', 'decrypt']
+ );
+ }
+
+ static async decryptMessage(aesKey: any, ciphertextBase64: any, ivBase64: any) {
+ const rawCipher = Uint8Array.from(atob(ciphertextBase64), (c) => c.charCodeAt(0));
+ const rawIv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0));
+ const decryptedBuffer = await window.crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: rawIv },
+ aesKey,
+ rawCipher
+ );
+ return new TextDecoder().decode(decryptedBuffer);
+ }
+
+ // Encrypt outgoing messages before sending them to your Axum backend
+ static async encryptMessage(aesKey: any, plainText: any) {
+ const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12-byte nonce
+ const encodedText = new TextEncoder().encode(plainText);
+
+ const ciphertextBuffer = await window.crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: iv },
+ aesKey,
+ encodedText
+ );
+
+ const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
+ const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
+
+ return { ciphertext: ciphertextBase64, iv: ivBase64 };
+ }
+}
diff --git a/src/lib/core/utils/eventBus.ts b/src/lib/core/utils/eventBus.ts
new file mode 100644
index 0000000..ce7e6c5
--- /dev/null
+++ b/src/lib/core/utils/eventBus.ts
@@ -0,0 +1,117 @@
+import { getContext, setContext } from 'svelte';
+
+const COMMON_BUS = Symbol('g-event');
+
+class EventBus {
+ #listeners = new Map();
+
+ /**
+ * Register event with callback on this channel
+ * @param event
+ * @param callback
+ * @returns unsubscribe function of this event, remove callback out of this event
+ */
+ on(event: string, callback: any) {
+ if (!this.#listeners.has(event)) {
+ this.#listeners.set(event, new Set());
+ }
+
+ this.#listeners.get(event).add(callback);
+
+ // return unsubscribe
+ return () => this.#listeners.get(event).delete(callback);
+ }
+
+ /**
+ * Emit data to this event, call every registered callbacks
+ * @param event
+ * @param data
+ */
+ emit(event: string, data: any) {
+ if (this.#listeners.has(event)) {
+ this.#listeners.get(event).forEach((cb: any) => cb(data));
+ }
+ }
+
+ emitUntilConsumed(event: string, data: any, timeout?: number) {
+ if (this.#listeners.has(event)) {
+ let listener_count = this.#listeners.get(event).length;
+
+ if (listener_count == 0) {
+ setTimeout(
+ () => {
+ this.emitUntilConsumed(event, data);
+ },
+ (timeout ?? 1) * 1000
+ );
+ } else {
+ this.emit(event, data);
+ }
+ }
+ }
+
+ /**
+ * Clear all listeners
+ */
+ clear() {
+ this.#listeners.clear();
+ }
+
+ /**
+ * Clear all callbacks on this event
+ * @param event
+ */
+ resetCallbackOnEvent(event: string) {
+ if (this.#listeners.has(event)) {
+ this.#listeners.set(event, new Set());
+ }
+ }
+}
+
+/**
+ * Initialize the common channel event bus
+ * @returns EventBus | undefined
+ */
+function setEventBus(): EventBus | undefined {
+ return setContext(COMMON_BUS, new EventBus());
+}
+
+/**
+ * Get common channel event bus, cannot be used in non-component
+ * @returns EventBus | undefined
+ */
+function useEventBus(): EventBus | undefined {
+ return getContext(COMMON_BUS);
+}
+
+/**
+ * Initialize the channel with name event bus
+ * @param name channel name
+ * @returns EventBus | undefined
+ */
+function setEventBusWithName(name: string): EventBus | undefined {
+ return setContext(name, new EventBus());
+}
+
+/**
+ * Get a specific channel event bus, cannot be used in non-component
+ * @param name channel name
+ * @returns EventBus | undefined
+ */
+function useEventBusWithName(name: string): EventBus | undefined {
+ return getContext(name);
+}
+
+/**
+ * Global type event bus, allow use without Svelte context
+ */
+const GlobalEventBus = new EventBus();
+
+export {
+ setEventBus,
+ useEventBus,
+ setEventBusWithName,
+ useEventBusWithName,
+ COMMON_BUS,
+ GlobalEventBus
+};
diff --git a/src/lib/data/recipeService.ts b/src/lib/data/recipeService.ts
index 778698d..7791fdf 100644
--- a/src/lib/data/recipeService.ts
+++ b/src/lib/data/recipeService.ts
@@ -272,5 +272,7 @@ export {
getMaterialType,
getCategories,
isNonMaterial,
- extractMaterialIdFromDisplay
+ extractMaterialIdFromDisplay,
+ getMenuStatus,
+ buildTags
};
diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte
index 926c2d3..0d38883 100644
--- a/src/routes/(authed)/+layout.svelte
+++ b/src/routes/(authed)/+layout.svelte
@@ -18,10 +18,22 @@
} from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
+ import { browser } from '$app/environment';
+ import { onMount } from 'svelte';
let { children } = $props();
let websocketConnectedForUid = $state('');
let adbReconnectTriedForUid = $state('');
+ let TerminalDrawerComponent: any = $state(null);
+
+ // Dynamic import: TerminalDrawer depends on @xterm/xterm which references
+ // browser globals (self, window). Static import would crash SSR evaluation.
+ onMount(async () => {
+ if (browser) {
+ const mod = await import('$lib/components/terminal-drawer.svelte');
+ TerminalDrawerComponent = mod.default;
+ }
+ });
function getAutoConnectChannel(pathname: string) {
if (pathname.startsWith('/tools/create-menu')) {
@@ -97,8 +109,8 @@
websocketConnectedForUid = currentUser.uid;
console.log('connect ws after auth ready');
- void currentUser.getIdToken().then((idToken) => {
- connectToWebsocket(idToken);
+ void currentUser.getIdToken(true).then(async (idToken) => {
+ await connectToWebsocket(idToken);
});
}
@@ -125,4 +137,8 @@
{@render children()}
+
+ {#if TerminalDrawerComponent}
+
+ {/if}
diff --git a/src/routes/(authed)/departments/+page.svelte b/src/routes/(authed)/departments/+page.svelte
index 77ce3d9..31a295c 100644
--- a/src/routes/(authed)/departments/+page.svelte
+++ b/src/routes/(authed)/departments/+page.svelte
@@ -27,6 +27,8 @@
if (refPage === 'priceslot') {
await goto(`/sheet/priceslot/${cnt}`);
+ } else if (refPage === 'price') {
+ await goto(`/sheet/price/${cnt}`);
} else if (refPage === 'sheet') {
await goto(`/sheet/overview/${cnt}`);
} else {
@@ -37,7 +39,7 @@
// read or write permission
let userCurrentPerms = get(currentPerms).filter((x) => {
- if (refPage === 'sheet') {
+ if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');
@@ -50,7 +52,7 @@
setTimeout(() => {
// read or write permission
let userCurrentPerms = get(currentPerms).filter((x) => {
- if (refPage === 'sheet') {
+ if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') {
return x.startsWith('document.write');
}
return x.startsWith('document.read');
diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte
index e7beba8..a91f747 100644
--- a/src/routes/(authed)/recipe/material/+page.svelte
+++ b/src/routes/(authed)/recipe/material/+page.svelte
@@ -1,5 +1,5 @@
@@ -459,21 +939,93 @@
{/if}
-
-
+
+
+ Recipe Source
+
+
+
+
+
+
+
+
+
+ Load Recipe From Server
+ Select a country to load material data from server.
+
+
+
+ {#each serverCountries as country}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+ Machine Not Connected
+
+ Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
+
+
+
+
+
+
+
+
+
@@ -499,31 +1051,60 @@
- {existingMaterial ? 'Edit Material' : 'Add Material'}
+ {isEditingMaterial ? 'Edit Material' : 'Add Material'}
- Create or update one MaterialSetting entry. The JSON preview shows the payload
- before saving to Android.
+ Create or update one MaterialSetting entry. Server-loaded data is read-only until
+ ADB is connected.
+ Material ID {form.id} already exists. Choose another ID before saving.
+
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting.
+ Editing Material ID {editingMaterialId}.
{/if}
+
+
+
+
+
-
+
+
+ Generated from the highest existing ID in this type + 1.
+
@@ -546,11 +1127,11 @@
-
+
@@ -582,23 +1163,7 @@
-
-
-
-
-
+
@@ -618,21 +1183,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -686,12 +1236,18 @@
- Cancel
-
@@ -713,14 +1269,46 @@
+
+
+
+ Select Material Type
+
+ Choose the type for the new material. Material ID will be generated from existing IDs in
+ that type + 1.
+
+
+
+
+ {#each selectableMaterialTypeOptions as option}
+ selectMaterialType(option.value)}
+ >
+
@@ -955,7 +1334,10 @@
{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}
- Manage one group inside ToppingGroup.
+
+ Manage one group inside ToppingGroup. Server-loaded data is read-only until
+ ADB is connected.
+