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 { browser } from '$app/environment';
|
||||||
import { deleteCookiesOnNonBrowser } from '$lib/helpers/cookie';
|
import { deleteCookiesOnNonBrowser } from '$lib/helpers/cookie';
|
||||||
import { socketStore } from '$lib/core/stores/websocketStore';
|
import { socketStore } from '$lib/core/stores/websocketStore';
|
||||||
|
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@
|
||||||
if (instance) {
|
if (instance) {
|
||||||
try {
|
try {
|
||||||
await adb.executeCmd('rm /sdcard/coffeevending/ignore_pass');
|
await adb.executeCmd('rm /sdcard/coffeevending/ignore_pass');
|
||||||
await adb.executeCmd('reboot');
|
// await adb.executeCmd('reboot');
|
||||||
await adb.disconnect();
|
await adb.disconnect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('error disconnect device while logging out', e);
|
console.error('error disconnect device while logging out', e);
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
authStore.set(null);
|
authStore.set(null);
|
||||||
|
GlobalEventBus.clear();
|
||||||
|
|
||||||
let socket = get(socketStore);
|
let socket = get(socketStore);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -362,11 +362,11 @@
|
||||||
updateMachineStatus('');
|
updateMachineStatus('');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
'machine status pinging recipe editor dialog',
|
// 'machine status pinging recipe editor dialog',
|
||||||
getMachineStatus(),
|
// getMachineStatus(),
|
||||||
$machineInfoStore?.status
|
// $machineInfoStore?.status
|
||||||
);
|
// );
|
||||||
|
|
||||||
// update machine status
|
// update machine status
|
||||||
// check-connection
|
// 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 { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
|
||||||
import { addNotification } from '../stores/noti';
|
import { addNotification } from '../stores/noti';
|
||||||
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
|
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
|
||||||
|
import { GlobalEventBus } from '../utils/eventBus';
|
||||||
import { adbWriter } from '../stores/adbWriter';
|
import { adbWriter } from '../stores/adbWriter';
|
||||||
import { WritableStream } from '@yume-chan/stream-extra';
|
import { WritableStream } from '@yume-chan/stream-extra';
|
||||||
import { env } from '$env/dynamic/public';
|
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() {
|
export async function disconnect() {
|
||||||
let instance = getAdbInstance();
|
let instance = getAdbInstance();
|
||||||
if (instance) {
|
if (instance) {
|
||||||
|
|
@ -526,12 +604,32 @@ async function connectToAndroidServer(maxRetries = 5) {
|
||||||
if (writer) {
|
if (writer) {
|
||||||
addNotification('INFO:Enable Brewing Mode T on machine');
|
addNotification('INFO:Enable Brewing Mode T on machine');
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
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) {
|
} catch (e) {
|
||||||
console.error('read error', e);
|
console.error('read error', e);
|
||||||
|
|
@ -637,6 +735,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
||||||
const trimmedMessage = message.trim();
|
const trimmedMessage = message.trim();
|
||||||
if (trimmedMessage) {
|
if (trimmedMessage) {
|
||||||
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
|
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
|
||||||
|
GlobalEventBus.emit('adb:raw-payload', trimmedMessage);
|
||||||
handleAdbPayload(trimmedMessage);
|
handleAdbPayload(trimmedMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,6 +743,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
||||||
|
|
||||||
const remainingMessage = messageBuffer.trim();
|
const remainingMessage = messageBuffer.trim();
|
||||||
if (remainingMessage) {
|
if (remainingMessage) {
|
||||||
|
GlobalEventBus.emit('adb:raw-payload', remainingMessage);
|
||||||
handleAdbPayload(remainingMessage);
|
handleAdbPayload(remainingMessage);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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';
|
} from '../services/androidRecipeExportService';
|
||||||
import { handleIncomingMessages } from './messageHandler';
|
import { handleIncomingMessages } from './messageHandler';
|
||||||
import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore';
|
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 };
|
type AdbPayload = { type: string; payload: any };
|
||||||
|
|
||||||
|
let queuedPromises = new Array<Promise<void>>();
|
||||||
|
|
||||||
async function handleAdbPayload(raw_payload: string) {
|
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 {
|
try {
|
||||||
const payload: AdbPayload = JSON.parse(raw_payload);
|
const payload: AdbPayload = JSON.parse(raw_payload);
|
||||||
console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
|
// console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload);
|
||||||
switch (payload.type) {
|
|
||||||
|
// 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':
|
case 'log':
|
||||||
let log_level = payload.payload['level'] ?? 'INFO';
|
let log_level = payload.payload['level'] ?? 'INFO';
|
||||||
let log_message = payload.payload['msg'] ?? '';
|
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'}`)
|
addNotification(`ERR:${error?.message ?? 'Unable to load recipe export from Android'}`)
|
||||||
);
|
);
|
||||||
break;
|
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:
|
default:
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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[]>([]);
|
export const queue = writable<string[]>([]);
|
||||||
|
|
||||||
type CommandRequest = 'sheet' | 'command';
|
type CommandRequest = 'sheet' | 'command' | 'upload-log';
|
||||||
|
|
||||||
function getServiceName(cmdReq: CommandRequest) {
|
function getServiceName(cmdReq: CommandRequest) {
|
||||||
switch (cmdReq) {
|
switch (cmdReq) {
|
||||||
|
|
@ -17,6 +17,8 @@ function getServiceName(cmdReq: CommandRequest) {
|
||||||
return 'sheet-service';
|
return 'sheet-service';
|
||||||
case 'command':
|
case 'command':
|
||||||
return 'command';
|
return 'command';
|
||||||
|
case 'upload-log':
|
||||||
|
return 'upload-log';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export const toppingGroupFromServerQuery = writable<any>([]);
|
||||||
export const latestRecipeToppingData = writable<any>([]);
|
export const latestRecipeToppingData = writable<any>([]);
|
||||||
|
|
||||||
// edit data update
|
// edit data update
|
||||||
|
/// NOTE: Will be obsolete in future, and replace with `EventBus` style.
|
||||||
export const recipeDataEvent = writable<{
|
export const recipeDataEvent = writable<{
|
||||||
event_type: string;
|
event_type: string;
|
||||||
payload: any;
|
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(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
token: id_token ?? '',
|
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: {};
|
payload: {};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'sheet' | 'command';
|
type: 'sheet' | 'command' | 'upload-log';
|
||||||
payload: {
|
payload: {
|
||||||
user_info: any;
|
user_info: any;
|
||||||
srv_name: string;
|
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,
|
getMaterialType,
|
||||||
getCategories,
|
getCategories,
|
||||||
isNonMaterial,
|
isNonMaterial,
|
||||||
extractMaterialIdFromDisplay
|
extractMaterialIdFromDisplay,
|
||||||
|
getMenuStatus,
|
||||||
|
buildTags
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,22 @@
|
||||||
} from '@yume-chan/adb-daemon-webusb';
|
} from '@yume-chan/adb-daemon-webusb';
|
||||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
||||||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let websocketConnectedForUid = $state('');
|
let websocketConnectedForUid = $state('');
|
||||||
let adbReconnectTriedForUid = $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) {
|
function getAutoConnectChannel(pathname: string) {
|
||||||
if (pathname.startsWith('/tools/create-menu')) {
|
if (pathname.startsWith('/tools/create-menu')) {
|
||||||
|
|
@ -125,4 +137,8 @@
|
||||||
<Sidebar.Trigger />
|
<Sidebar.Trigger />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{#if TerminalDrawerComponent}
|
||||||
|
<svelte:component this={TerminalDrawerComponent} />
|
||||||
|
{/if}
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,16 @@
|
||||||
clearMenuSaveState
|
clearMenuSaveState
|
||||||
} from '$lib/core/stores/menuSaveStore';
|
} from '$lib/core/stores/menuSaveStore';
|
||||||
|
|
||||||
|
import * as semver from 'semver';
|
||||||
|
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||||
|
|
||||||
const sourceDir = '/sdcard/coffeevending';
|
const sourceDir = '/sdcard/coffeevending';
|
||||||
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
||||||
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
||||||
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
||||||
|
|
||||||
|
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||||
|
|
||||||
// fetched recipe
|
// fetched recipe
|
||||||
let devRecipe: any | undefined = $state();
|
let devRecipe: any | undefined = $state();
|
||||||
|
|
||||||
|
|
@ -63,6 +68,32 @@
|
||||||
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
|
let isAndroidSocketConnected = $derived(Boolean($adbWriter));
|
||||||
let isRecipeLoaded = $derived(Boolean(devRecipe));
|
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) {
|
async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) {
|
||||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
const content = await adb.pull(path, timeoutMs);
|
const content = await adb.pull(path, timeoutMs);
|
||||||
|
|
@ -82,35 +113,51 @@
|
||||||
console.log('check instance', instance);
|
console.log('check instance', instance);
|
||||||
if (instance) {
|
if (instance) {
|
||||||
recipeLoading = true;
|
recipeLoading = true;
|
||||||
try {
|
|
||||||
console.log('instance passed!');
|
|
||||||
const recipePaths = [
|
|
||||||
`${sourceDir}/cfg/recipe_branch_dev.json`,
|
|
||||||
`${sourceDir}/coffeethai02.json`
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const recipePath of recipePaths) {
|
if (semver.satisfies(APP_VERSION, '^0.0.3')) {
|
||||||
const dev_recipe = await pullTextWithRetry(recipePath);
|
try {
|
||||||
console.log('dev recipe pull result', {
|
addNotification('WARN:Load recipe from app memories ...');
|
||||||
recipePath,
|
|
||||||
loaded: dev_recipe != undefined,
|
sendToAndroid({
|
||||||
size: dev_recipe?.length ?? 0
|
type: 'get_recipe',
|
||||||
|
payload: {}
|
||||||
});
|
});
|
||||||
if (!dev_recipe || dev_recipe.trim().length == 0) continue;
|
|
||||||
|
|
||||||
try {
|
// GlobalEventBus.emit('recipe-event', 'wait-finish');
|
||||||
devRecipe = JSON.parse(dev_recipe);
|
} finally {
|
||||||
buildOverviewForBrewing();
|
recipeLoading = false;
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('failed to parse recipe json', recipePath, error);
|
|
||||||
addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
console.log('instance passed!');
|
||||||
|
const recipePaths = [
|
||||||
|
`${sourceDir}/cfg/recipe_branch_dev.json`,
|
||||||
|
`${sourceDir}/coffeethai02.json`
|
||||||
|
];
|
||||||
|
|
||||||
addNotification('ERROR:Cannot fetch recipe from machine');
|
for (const recipePath of recipePaths) {
|
||||||
} finally {
|
const dev_recipe = await pullTextWithRetry(recipePath);
|
||||||
recipeLoading = false;
|
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 {
|
} else {
|
||||||
addNotification('ERROR:Cannot connect to machine');
|
addNotification('ERROR:Cannot connect to machine');
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { AdbInstance } from './state.svelte';
|
import { AdbInstance } from './state.svelte';
|
||||||
|
|
||||||
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
|
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 { onAuthStateChanged } from 'firebase/auth';
|
||||||
import { auth as authStore, authInitialized } from '$lib/core/stores/auth';
|
import { auth as authStore, authInitialized } from '$lib/core/stores/auth';
|
||||||
import { auth } from '$lib/core/client/firebase';
|
import { auth } from '$lib/core/client/firebase';
|
||||||
|
|
@ -26,9 +26,19 @@
|
||||||
setCookieOnNonBrowser
|
setCookieOnNonBrowser
|
||||||
} from '$lib/helpers/cookie';
|
} from '$lib/helpers/cookie';
|
||||||
import { connectToWebsocket } from '$lib/core/stores/websocketStore';
|
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();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||||
|
|
||||||
|
if (semver.satisfies(APP_VERSION, '^0.0.3')) {
|
||||||
|
// clean event bus
|
||||||
|
GlobalEventBus.clear();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
console.log('base url', window.location.origin, document.cookie);
|
console.log('base url', window.location.origin, document.cookie);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
||||||
noExternal: ['@dnd-kit/core', '@dnd-kit/sortable']
|
noExternal: ['@dnd-kit/core', '@dnd-kit/sortable']
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search']
|
include: ['@xterm/xterm', 'xterm-addon-fit', 'xterm-addon-search']
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue