feat: terminal, eventbus, load recipe

- change: disable reboot android when logged out
- change: load recipe from android app's memory (requires ^0.0.3)
- fix: adb payload handler may get incomplete message

Signed-off-by: pakintada@gmail.com <Pakin>
This commit is contained in:
pakintada@gmail.com 2026-06-19 10:41:17 +07:00
parent ea7ec00b4b
commit d4eb3be886
17 changed files with 2415 additions and 42 deletions

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,705 @@
<script lang="ts">
import '@xterm/xterm/css/xterm.css';
import { Xterm } from '@battlefieldduck/xterm-svelte';
import { FitAddon } from 'xterm-addon-fit';
import type { Terminal } from '@xterm/xterm';
import { onMount, onDestroy } from 'svelte';
import { get } from 'svelte/store';
import {
terminalDrawerOpen,
toggleTerminalDrawer,
closeTerminalDrawer
} from '$lib/core/stores/terminalDrawer';
import { getAdbInstance } from '$lib/core/adb/adb';
import {
initTerminalSession,
reinitTerminalSession,
handleTerminalData,
cleanupTerminalSession,
onTerminalMinimized,
onCommandOutput,
onExpandOutputRequest,
getCommandOutputs,
getLastOutputId,
requestExpandOutput,
type CommandOutput
} from '$lib/core/adb/adbTerminal';
import { auth } from '$lib/core/stores/auth';
import { permission as permissionStore } from '$lib/core/stores/permissions';
import { getUserByUid } from '$lib/core/admin/adminService';
import type { AdminUser } from '$lib/core/admin/adminTypes';
import {
TerminalIcon,
XIcon,
Minimize2Icon,
Maximize2Icon,
ShieldAlertIcon,
LockIcon,
ClockIcon
} from '@lucide/svelte/icons';
import { sendCommandRequest, sendMessage } from '$lib/core/handlers/ws_messageSender';
import { addNotification } from '$lib/core/stores/noti';
let xtermElement = $state<Terminal | undefined>(undefined);
let fitAddon: FitAddon | undefined = $state(undefined);
let isOpen = $state(false);
let isFullScreen = $state(false);
let userRole = $state<string>('');
let isAdminMode = $state(false);
let isCheckingAdmin = $state(true);
let connStatus = $state<'connected' | 'disconnected'>('disconnected');
let expandedOutput = $state<CommandOutput | null>(null);
let historyOpen = $state(false);
let selectedHistoryOutput = $state<CommandOutput | null>(null);
let uploadingId = $state<number | null>(null);
let outputBlocksEnabled = $state(true);
// Check permission on mount
onMount(() => {
// Watch auth for admin status
const unsub = auth.subscribe(async (user) => {
if (user) {
try {
const adminUser = await getUserByUid(user.uid);
userRole = adminUser?.role ?? '';
isAdminMode = adminUser?.role === 'admin';
} catch {
userRole = '';
isAdminMode = false;
}
} else {
userRole = '';
isAdminMode = false;
}
isCheckingAdmin = false;
});
// Watch terminal drawer state
const unsubDrawer = terminalDrawerOpen.subscribe((open) => {
const wasClosed = isOpen && !open;
isOpen = open;
// Just opened — fit the terminal to new container dimensions
if (open && fitAddon) {
// Wait for layout to settle after display:flex kicks in
requestAnimationFrame(() => {
try {
fitAddon?.fit();
} catch {
// fit may fail if container isn't visible yet
}
});
}
// Just closed (minimized) — save state
if (wasClosed) {
onTerminalMinimized();
}
});
// Listen for expand-output requests from terminal link clicks
const unsubExpand = onExpandOutputRequest((id: number) => {
const outputs = getCommandOutputs();
const output = outputs.find((o) => o.id === id);
if (output) {
expandedOutput = output;
}
});
// Keyboard shortcut: Ctrl+` (backtick) to toggle
function handleKeydown(e: KeyboardEvent) {
// Ctrl+` (backtick) to toggle
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
toggleTerminalDrawer();
}
// Ctrl+Shift+` for fullscreen
if (e.ctrlKey && e.shiftKey && e.key === '`') {
e.preventDefault();
if (isOpen) {
isFullScreen = !isFullScreen;
}
}
// Escape to close expanded output dialog
if (e.key === 'Escape' && expandedOutput) {
expandedOutput = null;
}
// Escape to close history dialog
if (e.key === 'Escape' && historyOpen) {
historyOpen = false;
}
}
window.addEventListener('keydown', handleKeydown);
return () => {
unsub();
unsubDrawer();
unsubExpand();
window.removeEventListener('keydown', handleKeydown);
};
});
onDestroy(() => {
cleanupTerminalSession();
});
function onTerminalLoad(terminal: Terminal) {
xtermElement = terminal;
// Add FitAddon for responsive sizing
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
// Handle input — onData is the only way xterm.js reports user keystrokes.
// This must be registered BEFORE initTerminalSession so no keystrokes are lost.
// NOTE: All terminal visual options (theme, font, cursor, etc.) are passed
// via the <Xterm options={...}> prop and set at construction time.
// DO NOT assign terminal.options here — xterm.js throws for readonly
// constructor-only options like 'cols' and 'rows'.
terminal.onData((data: string) => {
if (!isAdminMode) return; // Block input if not admin
handleTerminalData(data);
});
// Register link provider for "click to expand" markers in output
// This makes the "[⤵ N more lines]" text clickable to open the output dialog.
//
// IMPORTANT: xterm.js link ranges use CELL positions (1-based columns),
// NOT JavaScript character indices. Unicode characters like ⤵ (U+2935)
// may occupy 2 cells (double-width). Using string indexOf() for
// positioning would be off by 1 for such characters — the link range
// would not match the mouse hover position and the click would never fire.
//
// Fix: iterate xterm cells directly to find the correct cell position
// of the expand marker, skipping empty trailing cells of double-wide chars.
terminal.registerLinkProvider({
provideLinks(lineNumber: number, callback: (links: any[]) => void) {
const line = terminal.buffer.active.getLine(lineNumber);
if (!line) {
callback([]);
return;
}
// Build line text AND find the cell position of the ⤵ marker
// in a single pass. We iterate xterm cells (not string chars)
// so double-width characters are handled correctly.
let lineText = '';
let foundCellPos = -1;
for (let i = 0; i < line.length; i++) {
const cell = line.getCell(i);
if (!cell) continue;
const chars = cell.getChars();
if (!chars) continue; // skip trailing cell of double-width char
lineText += chars;
if (foundCellPos < 0 && chars.includes('⤵')) {
foundCellPos = i;
}
}
if (foundCellPos >= 0 && lineText.includes('click to expand')) {
const link = {
range: {
// foundCellPos is 0-based cell index → +1 for 1-based column
start: { x: foundCellPos + 1, y: lineNumber + 1 },
// line.length is the cell count → last cell index is length-1,
// so 1-based last column = line.length (inclusive end)
end: { x: line.length, y: lineNumber + 1 }
},
text: lineText,
activate(_event: MouseEvent, _uri: string) {
const lastId = getLastOutputId();
if (lastId > 0) {
requestExpandOutput(lastId);
}
}
};
callback([link]);
return;
}
callback([]);
}
});
// Initialize session (writes welcome banner, sets up prompt)
initTerminalSession(terminal);
}
async function handleUpload(output: CommandOutput | null) {
if (!output) return;
uploadingId = output.id;
const success = await sendCommandRequest('upload-log', {
command: output.command,
output: output.fullOutput,
lines: output.lines,
timestamp: output.timestamp,
message: 'run command',
log_level: output.level
});
uploadingId = null;
if (success) {
addNotification('INFO:Command output uploaded');
} else {
addNotification('ERR:Failed to upload — WebSocket not connected');
}
}
function handleToggle() {
toggleTerminalDrawer();
}
function handleClose() {
onTerminalMinimized();
closeTerminalDrawer();
}
function handleMinimize() {
onTerminalMinimized();
closeTerminalDrawer();
}
function toggleFullScreen() {
isFullScreen = !isFullScreen;
// Re-fit after fullscreen toggle
if (fitAddon && isOpen) {
requestAnimationFrame(() => {
try {
fitAddon?.fit();
} catch {
// ignore
}
});
}
}
// Check connection status periodically
let _connInterval: ReturnType<typeof setInterval> | undefined;
onMount(() => {
_connInterval = setInterval(() => {
const instance = getAdbInstance();
connStatus = instance ? 'connected' : 'disconnected';
}, 2000);
});
onDestroy(() => {
if (_connInterval) clearInterval(_connInterval);
});
</script>
<!-- Floating Terminal Button - Always visible -->
<button
onclick={handleToggle}
class="fixed right-4 bottom-4 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-[#0d1117] text-green-400 shadow-lg ring-1 ring-gray-700 transition-all hover:bg-[#161b22] hover:ring-green-500"
title={isOpen ? 'Close Terminal (Ctrl+`)' : 'Open Terminal (Ctrl+`)'}
>
{#if isOpen}
<XIcon size={18} />
{:else}
<TerminalIcon size={18} />
{/if}
</button>
<!-- Overlay - Only when open -->
{#if isOpen}
<div
class="fixed inset-0 z-40 bg-black/50 transition-opacity"
onclick={handleClose}
role="presentation"
></div>
{/if}
<!-- Drawer Panel - Always mounted, hidden via CSS when minimized -->
<!-- This keeps the Xterm instance alive so terminal buffer is preserved -->
<div
class="fixed bottom-0 z-50 flex flex-col border-t border-gray-700 bg-[#0d1117] shadow-2xl transition-all duration-300 ease-in-out"
class:right-0={!isFullScreen}
class:inset-x-0={!isFullScreen}
class:inset-0={isFullScreen}
style="display: {isOpen ? 'flex' : 'none'}; height: {isFullScreen
? '100%'
: '40vh'}; min-height: {isFullScreen ? '100%' : '250px'}; max-height: {isFullScreen
? '100%'
: '70vh'};"
role="dialog"
aria-label="ADB Terminal"
>
<!-- Title Bar -->
<div
class="flex shrink-0 cursor-default items-center justify-between border-b border-gray-700 bg-[#161b22] px-4 py-2 select-none"
>
<div class="flex items-center gap-3">
<!-- Traffic light dots -->
<div class="flex items-center gap-1.5">
<div
class="h-3 w-3 rounded-full bg-red-500"
onclick={handleClose}
onkeydown={(e) => e.key === 'Enter' && handleClose()}
role="button"
tabindex="0"
aria-label="Close"
></div>
<div
class="h-3 w-3 rounded-full bg-yellow-500"
onclick={handleMinimize}
onkeydown={(e) => e.key === 'Enter' && handleMinimize()}
role="button"
tabindex="0"
aria-label="Minimize"
></div>
<div
class="h-3 w-3 rounded-full bg-green-500"
onclick={toggleFullScreen}
onkeydown={(e) => e.key === 'Enter' && toggleFullScreen()}
role="button"
tabindex="0"
aria-label="Toggle fullscreen"
></div>
</div>
<span class="ml-2 text-sm font-semibold text-gray-300">
<TerminalIcon class="mr-1 inline-block align-text-bottom" size={14} />
ADB Terminal
</span>
<!-- Connection status badge -->
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium {connStatus ===
'connected'
? 'bg-green-900/50 text-green-400'
: 'bg-red-900/50 text-red-400'}"
>
<span
class="h-1.5 w-1.5 rounded-full"
class:bg-green-400={connStatus === 'connected'}
class:bg-red-400={connStatus === 'disconnected'}
></span>
{connStatus === 'connected' ? 'Device Connected' : 'Disconnected'}
</span>
</div>
<div class="flex items-center gap-2">
<!-- Admin badge -->
{#if isCheckingAdmin}
<span class="text-xs text-gray-500">Checking permissions...</span>
{:else if isAdminMode}
<span
class="inline-flex items-center gap-1 rounded bg-yellow-900/30 px-2 py-0.5 text-xs font-medium text-yellow-400"
>
<ShieldAlertIcon size={12} />
Admin
</span>
{:else}
<span
class="inline-flex items-center gap-1 rounded bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-500"
>
<LockIcon size={12} />
Read Only
</span>
{/if}
<!-- History button -->
<button
onclick={() => (historyOpen = !historyOpen)}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Command History"
>
<ClockIcon size={14} />
</button>
<!-- Fullscreen button -->
<button
onclick={toggleFullScreen}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title={isFullScreen ? 'Exit Fullscreen' : 'Fullscreen'}
>
{#if isFullScreen}
<Minimize2Icon size={14} />
{:else}
<Maximize2Icon size={14} />
{/if}
</button>
</div>
</div>
<!-- Terminal Content -->
<div class="relative flex min-h-0 flex-1">
{#if !isAdminMode && !isCheckingAdmin}
<!-- Read-only overlay for non-admin users -->
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[#0d1117]/90">
<LockIcon class="mb-2 text-gray-600" size={32} />
<p class="text-sm text-gray-500">Admin permissions required</p>
<p class="mt-1 text-xs text-gray-600">Terminal commands are restricted to admin users</p>
</div>
{/if}
<div class="h-full w-full overflow-hidden">
<Xterm
bind:terminal={xtermElement}
options={{
cursorBlink: true,
cursorStyle: 'block',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
fontSize: 13,
lineHeight: 1.2,
theme: {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selectionBackground: '#264f78',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc'
}
}}
onLoad={onTerminalLoad}
class="h-full w-full"
style="height:100%; width:100%;"
/>
</div>
</div>
</div>
<!-- Full Output Dialog -->
{#if expandedOutput}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onclick={() => (expandedOutput = null)}
role="presentation"
>
<!-- Dialog -->
<div
class="mx-4 max-h-[80vh] w-full max-w-3xl overflow-hidden rounded-lg border border-gray-700 bg-[#0d1117] shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command full output"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-700 bg-[#161b22] px-4 py-3"
>
<div class="flex items-center gap-2 overflow-hidden">
<span class="shrink-0 text-xs font-medium text-gray-400">Output for:</span>
<code class="truncate text-sm font-semibold text-green-400"
>$ {expandedOutput.command}</code
>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500"
>{expandedOutput.lines} line{expandedOutput.lines !== 1 ? 's' : ''}</span
>
<button
onclick={() => (expandedOutput = null)}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Close"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg
>
</button>
</div>
</div>
<!-- Output Content -->
<div class="overflow-auto p-4" style="max-height: calc(80vh - 52px);">
{#if expandedOutput.fullOutput}
<pre
class="font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-300">{expandedOutput.fullOutput}</pre>
{:else}
<p class="text-sm text-gray-500 italic">(no output)</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- History Dialog -->
{#if historyOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/70 pt-12"
onclick={() => {
historyOpen = false;
selectedHistoryOutput = null;
}}
role="presentation"
>
<!-- Dialog: side-by-side split layout -->
<div
class="mx-4 flex w-full max-w-5xl overflow-hidden rounded-lg border border-gray-700 bg-[#0d1117] shadow-2xl"
style="height: 70vh;"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command history"
>
<!-- Left Panel: List of commands (40%) -->
<div class="flex w-2/5 shrink-0 flex-col border-r border-gray-700" style="min-width: 220px;">
<!-- Header -->
<div
class="flex shrink-0 items-center justify-between border-b border-gray-700 bg-[#161b22] px-3 py-2.5"
>
<div class="flex items-center gap-2">
<ClockIcon size={15} class="text-gray-400" />
<span class="text-sm font-semibold text-gray-200">History</span>
<span class="text-xs text-gray-500">(10)</span>
</div>
</div>
<!-- Scrollable list -->
<div class="overflow-y-auto">
{#each getCommandOutputs().slice(-10).reverse() as output}
<button
onclick={() => {
selectedHistoryOutput = output;
}}
class="flex w-full items-start gap-2.5 border-b border-gray-800 px-3 py-2.5 text-left transition-colors
{selectedHistoryOutput?.id === output.id ? 'bg-blue-900/20' : 'hover:bg-[#161b22]'}"
>
<!-- Level dot -->
<div class="mt-1 shrink-0">
{#if output.level === 'error'}
<div class="h-2 w-2 rounded-full bg-red-500"></div>
{:else if output.level === 'success'}
<div class="h-2 w-2 rounded-full bg-green-500"></div>
{:else}
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
{/if}
</div>
<!-- Command info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<code class="truncate text-xs font-medium text-green-400">$ {output.command}</code
>
<span class="shrink-0 text-[11px] text-gray-500">{output.lines}</span>
</div>
<div class="mt-0.5 truncate text-[11px] text-gray-600">
{output.fullOutput
? output.fullOutput.split('\n')[0].slice(0, 80)
: '(no output)'}
</div>
<div class="mt-0.5 text-[10px] text-gray-700">
{new Date(output.timestamp).toLocaleTimeString()}
</div>
</div>
</button>
{:else}
<div class="flex flex-col items-center justify-center py-10 text-gray-500">
<ClockIcon size={24} class="mb-2 text-gray-700" />
<p class="text-xs">No command history yet</p>
</div>
{/each}
</div>
</div>
<!-- Right Panel: Output viewer (60%) -->
<div class="flex w-3/5 flex-col overflow-hidden">
<!-- Header with controls -->
<div
class="flex shrink-0 items-center justify-between border-b border-gray-700 bg-[#161b22] px-3 py-2.5"
>
<div class="flex items-center gap-2 overflow-hidden">
{#if selectedHistoryOutput}
<span class="shrink-0 text-xs font-medium text-gray-400">Output for:</span>
<code class="truncate text-sm font-semibold text-green-400"
>$ {selectedHistoryOutput.command}</code
>
<span class="shrink-0 text-xs text-gray-500"
>{selectedHistoryOutput.lines} line{selectedHistoryOutput.lines !== 1
? 's'
: ''}</span
>
{:else}
<span class="text-xs text-gray-500">Select a command to view output</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if selectedHistoryOutput}
<button
onclick={() => handleUpload(selectedHistoryOutput)}
disabled={uploadingId === selectedHistoryOutput.id}
class="rounded px-2 py-1 text-xs font-medium transition-colors
{uploadingId === selectedHistoryOutput.id
? 'cursor-not-allowed bg-blue-900/30 text-blue-500'
: 'bg-gray-800 text-gray-400 hover:bg-blue-900/50 hover:text-blue-400'}"
>
{uploadingId === selectedHistoryOutput.id ? 'Uploading...' : 'Upload'}
</button>
{/if}
<button
onclick={() => {
historyOpen = false;
selectedHistoryOutput = null;
}}
class="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-gray-300"
title="Close"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"><path d="M18 6L6 18M6 6l12 12" /></svg
>
</button>
</div>
</div>
<!-- Output content -->
<div class="flex-1 overflow-auto">
{#if selectedHistoryOutput}
{#if selectedHistoryOutput.fullOutput}
<pre
class="p-4 font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-300">{selectedHistoryOutput.fullOutput}</pre>
{:else}
<div class="flex items-center justify-center py-16 text-sm text-gray-500 italic">
(no output)
</div>
{/if}
{:else}
<div class="flex h-full flex-col items-center justify-center text-gray-600">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="mb-3 text-gray-700"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline
points="14 2 14 8 20 8"
/><line x1="16" y1="13" x2="8" y2="13" /><line
x1="16"
y1="17"
x2="8"
y2="17"
/><polyline points="10 9 9 9 8 9" />
</svg>
<p class="text-sm">Select a command from the left panel</p>
<p class="mt-1 text-xs text-gray-700">to view its full output here</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}

View file

@ -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) {

File diff suppressed because it is too large Load diff

View file

@ -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<Promise<void>>();
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'] ?? '<not set>',
name: rp_from_payload['name']
? rp_from_payload['name']
: (rp_from_payload['otherName'] ?? '<not set>'),
description: rp_from_payload['desciption']
? rp_from_payload['desciption']
: (rp_from_payload['otherDescription'] ?? '<not set>'),
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()
});
}
}

View file

@ -9,7 +9,7 @@ import { env } from '$env/dynamic/public';
export const queue = writable<string[]>([]);
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';
}
}

View file

@ -51,6 +51,7 @@ export const toppingGroupFromServerQuery = writable<any>([]);
export const latestRecipeToppingData = writable<any>([]);
// edit data update
/// NOTE: Will be obsolete in future, and replace with `EventBus` style.
export const recipeDataEvent = writable<{
event_type: string;
payload: any;

View file

@ -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<boolean>(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);
}

View file

@ -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
})
);

View file

@ -48,7 +48,7 @@ export type OutMessage =
payload: {};
}
| {
type: 'sheet' | 'command';
type: 'sheet' | 'command' | 'upload-log';
payload: {
user_info: any;
srv_name: string;

View file

@ -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
};

View file

@ -272,5 +272,7 @@ export {
getMaterialType,
getCategories,
isNonMaterial,
extractMaterialIdFromDisplay
extractMaterialIdFromDisplay,
getMenuStatus,
buildTags
};

View file

@ -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 @@
<Sidebar.Trigger />
{@render children()}
</main>
{#if TerminalDrawerComponent}
<svelte:component this={TerminalDrawerComponent} />
{/if}
</Sidebar.Provider>

View file

@ -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');

View file

@ -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);

View file

@ -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 },