diff --git a/src/lib/components/app-account-select.svelte b/src/lib/components/app-account-select.svelte index 7c107ba..83d8428 100644 --- a/src/lib/components/app-account-select.svelte +++ b/src/lib/components/app-account-select.svelte @@ -15,6 +15,7 @@ import { browser } from '$app/environment'; import { deleteCookiesOnNonBrowser } from '$lib/helpers/cookie'; import { socketStore } from '$lib/core/stores/websocketStore'; + import { GlobalEventBus } from '$lib/core/utils/eventBus'; const sidebar = useSidebar(); @@ -37,7 +38,7 @@ if (instance) { try { await adb.executeCmd('rm /sdcard/coffeevending/ignore_pass'); - await adb.executeCmd('reboot'); + // await adb.executeCmd('reboot'); await adb.disconnect(); } catch (e) { console.error('error disconnect device while logging out', e); @@ -45,6 +46,7 @@ } authStore.set(null); + GlobalEventBus.clear(); let socket = get(socketStore); diff --git a/src/lib/components/recipe-editor-dialog.svelte b/src/lib/components/recipe-editor-dialog.svelte index 3fac5c8..cd8b52f 100644 --- a/src/lib/components/recipe-editor-dialog.svelte +++ b/src/lib/components/recipe-editor-dialog.svelte @@ -362,11 +362,11 @@ updateMachineStatus(''); } - console.log( - 'machine status pinging recipe editor dialog', - getMachineStatus(), - $machineInfoStore?.status - ); + // console.log( + // 'machine status pinging recipe editor dialog', + // getMachineStatus(), + // $machineInfoStore?.status + // ); // update machine status // check-connection diff --git a/src/lib/components/terminal-drawer.svelte b/src/lib/components/terminal-drawer.svelte new file mode 100644 index 0000000..195a065 --- /dev/null +++ b/src/lib/components/terminal-drawer.svelte @@ -0,0 +1,705 @@ + + + + + + +{#if isOpen} + +{/if} + + + + + + +{#if expandedOutput} + +
(expandedOutput = null)} + role="presentation" + > + +
e.stopPropagation()} + role="dialog" + aria-label="Command full output" + > + +
+
+ Output for: + $ {expandedOutput.command} +
+
+ {expandedOutput.lines} line{expandedOutput.lines !== 1 ? 's' : ''} + +
+
+ + +
+ {#if expandedOutput.fullOutput} +
{expandedOutput.fullOutput}
+ {:else} +

(no output)

+ {/if} +
+
+
+{/if} + + +{#if historyOpen} + +
{ + historyOpen = false; + selectedHistoryOutput = null; + }} + role="presentation" + > + +
e.stopPropagation()} + role="dialog" + aria-label="Command history" + > + +
+ +
+
+ + History + (10) +
+
+ + +
+ {#each getCommandOutputs().slice(-10).reverse() as output} + + {:else} +
+ +

No command history yet

+
+ {/each} +
+
+ + +
+ +
+
+ {#if selectedHistoryOutput} + Output for: + $ {selectedHistoryOutput.command} + {selectedHistoryOutput.lines} line{selectedHistoryOutput.lines !== 1 + ? 's' + : ''} + {:else} + Select a command to view output + {/if} +
+
+ {#if selectedHistoryOutput} + + {/if} + +
+
+ + +
+ {#if selectedHistoryOutput} + {#if selectedHistoryOutput.fullOutput} +
{selectedHistoryOutput.fullOutput}
+ {:else} +
+ (no output) +
+ {/if} + {:else} +
+ + + +

Select a command from the left panel

+

to view its full output here

+
+ {/if} +
+
+
+
+{/if} diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 2cab7d5..c4d45e5 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'; @@ -362,6 +363,83 @@ export async function executeCmd(command: string) { } } +/** + * 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 +604,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 +735,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 +743,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/handlers/adbPayloadHandler.ts b/src/lib/core/handlers/adbPayloadHandler.ts index a27f993..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'] ?? ''; @@ -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/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 852dfcb..7d38d38 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -9,7 +9,7 @@ import { env } from '$env/dynamic/public'; export const queue = writable([]); -type CommandRequest = 'sheet' | 'command'; +type CommandRequest = 'sheet' | 'command' | 'upload-log'; function getServiceName(cmdReq: CommandRequest) { switch (cmdReq) { @@ -17,6 +17,8 @@ function getServiceName(cmdReq: CommandRequest) { return 'sheet-service'; case 'command': return 'command'; + case 'upload-log': + return 'upload-log'; } } 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/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 4fa779c..5cb0301 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -81,7 +81,8 @@ export async function connectToWebsocket(id_token?: string) { socket.send( JSON.stringify({ token: id_token ?? '', - client_public_key: publicKeyBase64 + client_public_key: publicKeyBase64, + client_version: env.PUBLIC_APP_SEMVER }) ); diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts index 5795c9f..13dff0e 100644 --- a/src/lib/core/types/outMessage.ts +++ b/src/lib/core/types/outMessage.ts @@ -48,7 +48,7 @@ export type OutMessage = payload: {}; } | { - type: 'sheet' | 'command'; + type: 'sheet' | 'command' | 'upload-log'; payload: { user_info: any; srv_name: string; 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 910c93e..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')) { @@ -125,4 +137,8 @@ {@render children()} + + {#if TerminalDrawerComponent} + + {/if} diff --git a/src/routes/(authed)/tools/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte index 58edf22..219ed16 100644 --- a/src/routes/(authed)/tools/brew/+page.svelte +++ b/src/routes/(authed)/tools/brew/+page.svelte @@ -37,11 +37,16 @@ clearMenuSaveState } from '$lib/core/stores/menuSaveStore'; + import * as semver from 'semver'; + import { GlobalEventBus } from '$lib/core/utils/eventBus'; + const sourceDir = '/sdcard/coffeevending'; const stagedMenuStorageKey = 'brew.create-menu.drafts.v1'; const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`; const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`; + const APP_VERSION = env.PUBLIC_APP_SEMVER; + // fetched recipe let devRecipe: any | undefined = $state(); @@ -63,6 +68,32 @@ let isAndroidSocketConnected = $derived(Boolean($adbWriter)); let isRecipeLoaded = $derived(Boolean(devRecipe)); + // clear out event + + GlobalEventBus.on('recipe-event', (d: any) => { + console.log('[recipe-ev] get event: ', d); + if (d?.type == 'load-recipe' && d?.status == 'end') { + addNotification('INFO:Get data, waiting for reloading ...'); + // load finish + //\ + let recipeRaw = get(recipeFromMachine); + + if (recipeRaw) { + devRecipe = recipeRaw; + // update material & topping + console.log('check dev recipe', devRecipe); + } + // data.recipes = r01Q.recipe; + + buildOverviewForBrewing(); + + console.log('refresh by m2 mem recipe data done'); + recipeLoading = false; + + addNotification('INFO:Load recipe from memories success!'); + } + }); + async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) { for (let attempt = 1; attempt <= attempts; attempt++) { const content = await adb.pull(path, timeoutMs); @@ -82,35 +113,51 @@ console.log('check instance', instance); if (instance) { recipeLoading = true; - try { - console.log('instance passed!'); - const recipePaths = [ - `${sourceDir}/cfg/recipe_branch_dev.json`, - `${sourceDir}/coffeethai02.json` - ]; - for (const recipePath of recipePaths) { - const dev_recipe = await pullTextWithRetry(recipePath); - console.log('dev recipe pull result', { - recipePath, - loaded: dev_recipe != undefined, - size: dev_recipe?.length ?? 0 + if (semver.satisfies(APP_VERSION, '^0.0.3')) { + try { + addNotification('WARN:Load recipe from app memories ...'); + + sendToAndroid({ + type: 'get_recipe', + payload: {} }); - if (!dev_recipe || dev_recipe.trim().length == 0) continue; - try { - devRecipe = JSON.parse(dev_recipe); - buildOverviewForBrewing(); - return; - } catch (error) { - console.error('failed to parse recipe json', recipePath, error); - addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`); - } + // GlobalEventBus.emit('recipe-event', 'wait-finish'); + } finally { + recipeLoading = false; } + } else { + try { + console.log('instance passed!'); + const recipePaths = [ + `${sourceDir}/cfg/recipe_branch_dev.json`, + `${sourceDir}/coffeethai02.json` + ]; - addNotification('ERROR:Cannot fetch recipe from machine'); - } finally { - recipeLoading = false; + for (const recipePath of recipePaths) { + const dev_recipe = await pullTextWithRetry(recipePath); + console.log('dev recipe pull result', { + recipePath, + loaded: dev_recipe != undefined, + size: dev_recipe?.length ?? 0 + }); + if (!dev_recipe || dev_recipe.trim().length == 0) continue; + + try { + devRecipe = JSON.parse(dev_recipe); + buildOverviewForBrewing(); + return; + } catch (error) { + console.error('failed to parse recipe json', recipePath, error); + addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`); + } + } + + addNotification('ERROR:Cannot fetch recipe from machine'); + } finally { + recipeLoading = false; + } } } else { addNotification('ERROR:Cannot connect to machine'); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f8e97fe..6057787 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import { AdbInstance } from './state.svelte'; import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js'; - import { onMount } from 'svelte'; + import { onMount, setContext } from 'svelte'; import { onAuthStateChanged } from 'firebase/auth'; import { auth as authStore, authInitialized } from '$lib/core/stores/auth'; import { auth } from '$lib/core/client/firebase'; @@ -26,9 +26,19 @@ setCookieOnNonBrowser } from '$lib/helpers/cookie'; import { connectToWebsocket } from '$lib/core/stores/websocketStore'; + import { GlobalEventBus } from '$lib/core/utils/eventBus'; + import * as semver from 'semver'; + import { env } from '$env/dynamic/public'; let { children } = $props(); + const APP_VERSION = env.PUBLIC_APP_SEMVER; + + if (semver.satisfies(APP_VERSION, '^0.0.3')) { + // clean event bus + GlobalEventBus.clear(); + } + onMount(() => { console.log('base url', window.location.origin, document.cookie); diff --git a/vite.config.ts b/vite.config.ts index 0b49238..7f3223c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ noExternal: ['@dnd-kit/core', '@dnd-kit/sortable'] }, optimizeDeps: { - include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search'] + include: ['@xterm/xterm', 'xterm-addon-fit', 'xterm-addon-search'] }, test: { expect: { requireAssertions: true },