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:
parent
ea7ec00b4b
commit
d4eb3be886
17 changed files with 2415 additions and 42 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
705
src/lib/components/terminal-drawer.svelte
Normal file
705
src/lib/components/terminal-drawer.svelte
Normal 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}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
1151
src/lib/core/adb/adbTerminal.ts
Normal file
1151
src/lib/core/adb/adbTerminal.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
28
src/lib/core/stores/terminalDrawer.ts
Normal file
28
src/lib/core/stores/terminalDrawer.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export type OutMessage =
|
|||
payload: {};
|
||||
}
|
||||
| {
|
||||
type: 'sheet' | 'command';
|
||||
type: 'sheet' | 'command' | 'upload-log';
|
||||
payload: {
|
||||
user_info: any;
|
||||
srv_name: string;
|
||||
|
|
|
|||
117
src/lib/core/utils/eventBus.ts
Normal file
117
src/lib/core/utils/eventBus.ts
Normal 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
|
||||
};
|
||||
|
|
@ -272,5 +272,7 @@ export {
|
|||
getMaterialType,
|
||||
getCategories,
|
||||
isNonMaterial,
|
||||
extractMaterialIdFromDisplay
|
||||
extractMaterialIdFromDisplay,
|
||||
getMenuStatus,
|
||||
buildTags
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue