diff --git a/bun.lock b/bun.lock
index b36186e..979b130 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
"@tanstack/match-sorter-utils": "^8.19.4",
+ "@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
"@yume-chan/adb": "^2.6.0",
"@yume-chan/adb-credential-web": "^2.1.0",
@@ -22,6 +23,7 @@
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
+ "semver": "^7.8.4",
"usb": "^2.17.0",
"uuid": "^13.0.0",
"xterm-addon-fit": "^0.8.0",
@@ -54,7 +56,7 @@
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"storybook": "^10.3.5",
- "svelte": "^5.55.2",
+ "svelte": "^5.56.3",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
@@ -504,6 +506,8 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
+ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
+
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="],
@@ -834,7 +838,7 @@
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
- "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
@@ -862,7 +866,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
- "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
+ "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
@@ -1040,9 +1044,15 @@
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
+ "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
+
+ "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
+
+ "svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
"svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],
diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte
new file mode 100644
index 0000000..691cfca
--- /dev/null
+++ b/src/lib/components/AnnouncementDialog.svelte
@@ -0,0 +1,174 @@
+
+
+{#if visible}
+
+
{
+ // Close on backdrop click
+ if (e.target === e.currentTarget) dismiss();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if payload?.type}
+
+ {payload.type}
+
+ {/if}
+
+
+ {#if payload?.title}
+
+ {payload.title}
+
+ {/if}
+
+
+ {#if payload?.subtitle}
+
+ {payload.subtitle}
+
+ {/if}
+
+
+ {#if payload?.message}
+
+ {payload.message}
+
+ {/if}
+
+
+ {#if payload?.buttonText !== undefined}
+
+
+
+ {/if}
+
+
+
+{/if}
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}
+
+
+
+
+
+
+
+
+
+
e.key === 'Enter' && handleClose()}
+ role="button"
+ tabindex="0"
+ aria-label="Close"
+ >
+
e.key === 'Enter' && handleMinimize()}
+ role="button"
+ tabindex="0"
+ aria-label="Minimize"
+ >
+
e.key === 'Enter' && toggleFullScreen()}
+ role="button"
+ tabindex="0"
+ aria-label="Toggle fullscreen"
+ >
+
+
+
+
+ ADB Terminal
+
+
+
+
+
+ {connStatus === 'connected' ? 'Device Connected' : 'Disconnected'}
+
+
+
+
+
+ {#if isCheckingAdmin}
+ Checking permissions...
+ {:else if isAdminMode}
+
+
+ Admin
+
+ {:else}
+
+
+ Read Only
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ {#if !isAdminMode && !isCheckingAdmin}
+
+
+
+
Admin permissions required
+
Terminal commands are restricted to admin users
+
+ {/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"
+ >
+
+
+
+
+
+
+
+ {#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 c5b0576..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';
@@ -371,6 +372,83 @@ export async function goToMachineHome() {
}
}
+/**
+ * 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) {
@@ -535,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);
@@ -646,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);
}
}
@@ -653,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/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/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts
index 9e7b8f6..e00612e 100644
--- a/src/lib/core/handlers/messageHandler.ts
+++ b/src/lib/core/handlers/messageHandler.ts
@@ -52,6 +52,8 @@ 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([]);
@@ -552,13 +554,13 @@ const handlers: Record void> = {
},
raw_stream_end_priceslot: (p) => {
handleRawStreamEnd('priceslot', p);
+ },
+ announce: (p) => {
+ // Server-pushed announcement (e.g., closing maintenance)
+ GlobalEventBus.emit('announce', p);
}
};
-function isSecuredAppVersion(version: string | undefined) {
- return version?.startsWith('0.0.2') ?? false;
-}
-
export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
const APP_VERSION = env.PUBLIC_APP_SEMVER;
const parsedMessage = JSON.parse(raw);
@@ -574,7 +576,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry
return;
}
- if (isSecuredAppVersion(APP_VERSION) && parsedMessage.ciphertext && parsedMessage.iv) {
+ if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
// secured message decryption
let sharedKeyStore = get(sharedKey);
if (sharedKeyStore) {
diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts
index 7c68cdd..1590409 100644
--- a/src/lib/core/handlers/ws_messageSender.ts
+++ b/src/lib/core/handlers/ws_messageSender.ts
@@ -5,14 +5,11 @@ 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([]);
-function isSecuredAppVersion(version: string | undefined) {
- return version?.startsWith('0.0.2') ?? false;
-}
-
-type CommandRequest = 'sheet' | 'command';
+type CommandRequest = 'sheet' | 'command' | 'upload-log';
function getServiceName(cmdReq: CommandRequest) {
switch (cmdReq) {
@@ -20,6 +17,8 @@ function getServiceName(cmdReq: CommandRequest) {
return 'sheet-service';
case 'command':
return 'command';
+ case 'upload-log':
+ return 'upload-log';
}
}
@@ -108,8 +107,8 @@ export async function sendMessage(
// console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION));
- if (isSecuredAppVersion(APP_VERSION)) {
- console.log('sending secured');
+ if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
+ // console.log('sending secured');
let sharedKeyRes = get(sharedKey);
// do encrypt
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 ca540da..a824f8b 100644
--- a/src/lib/core/stores/websocketStore.ts
+++ b/src/lib/core/stores/websocketStore.ts
@@ -108,7 +108,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 68dfbf8..da536e3 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..caf81ce 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,20 @@
setCookieOnNonBrowser
} from '$lib/helpers/cookie';
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
+ import { GlobalEventBus } from '$lib/core/utils/eventBus';
+ import AnnouncementDialog from '$lib/components/AnnouncementDialog.svelte';
+ 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);
@@ -81,4 +92,5 @@
+
{@render children()}
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 },