From 2a0841a798a9ee4059c172a0738ae93f444ecbee Mon Sep 17 00:00:00 2001 From: "pakintada@gmail.com" Date: Tue, 16 Jun 2026 10:34:29 +0700 Subject: [PATCH 1/7] feature: add secured message - encrypt/decrypt every message (require ^0.0.2) Signed-off-by: pakintada@gmail.com --- bun.lockb | Bin 197439 -> 198147 bytes package.json | 2 + .../recipe-details/recipe-detail.svelte | 4 +- .../components/recipe-details/value_event.ts | 4 +- .../components/recipe-editor-dialog.svelte | 4 +- src/lib/core/client/server.ts | 4 +- src/lib/core/handlers/messageHandler.ts | 166 +++++++++++------- src/lib/core/handlers/ws_messageSender.ts | 28 ++- src/lib/core/services/sheetService.ts | 94 ++++++---- src/lib/core/stores/websocketStore.ts | 75 +++++--- src/lib/core/types/outMessage.ts | 4 +- src/lib/core/utils/crypto.ts | 68 +++++++ src/routes/(authed)/+layout.svelte | 4 +- .../(authed)/recipe/overview/+page.svelte | 4 +- 14 files changed, 314 insertions(+), 147 deletions(-) create mode 100644 src/lib/core/utils/crypto.ts diff --git a/bun.lockb b/bun.lockb index 9851d42d89cdb58214e184f379e021b0bcb0471e..ce79e9c42e9864bf4221857039a6f68caab2fcf4 100755 GIT binary patch delta 35049 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 delta 34955 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 TP2`eo`l)f7KKMrM6!w1rFcScI diff --git a/package.json b/package.json index eb20ce4..4d63e16 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@dnd-kit/abstract": "^0.2.4", "@dnd-kit/helpers": "^0.2.4", "@tanstack/match-sorter-utils": "^8.19.4", + "@types/semver": "^7.7.1", "@xterm/xterm": "^5.5.0", "@yume-chan/adb": "^2.6.0", "@yume-chan/adb-credential-web": "^2.1.0", @@ -77,6 +78,7 @@ "firebase": "^12.14.0", "idb": "^8.0.3", "mode-watcher": "^1.1.0", + "semver": "^7.8.4", "usb": "^2.17.0", "uuid": "^13.0.0", "xterm-addon-fit": "^0.8.0", diff --git a/src/lib/components/recipe-details/recipe-detail.svelte b/src/lib/components/recipe-details/recipe-detail.svelte index 455dcfd..0c817cd 100644 --- a/src/lib/components/recipe-details/recipe-detail.svelte +++ b/src/lib/components/recipe-details/recipe-detail.svelte @@ -183,9 +183,9 @@ } } - function saveSheetPrice() { + async function saveSheetPrice() { if (!canEditSheetPrice || sheetPriceValue === null) return; - sendCommandRequest('sheet', { + await sendCommandRequest('sheet', { country: get(departmentStore), content: [ { diff --git a/src/lib/components/recipe-details/value_event.ts b/src/lib/components/recipe-details/value_event.ts index 81653d5..61a9231 100644 --- a/src/lib/components/recipe-details/value_event.ts +++ b/src/lib/components/recipe-details/value_event.ts @@ -11,7 +11,7 @@ enum ValueEvent { SAVED } -function actionReport(action_name: string, values: any, currentRef: string) { +async function actionReport(action_name: string, values: any, currentRef: string) { let country = get(departmentStore) ?? 'unknown dep'; if (currentRef === 'brew') { @@ -27,7 +27,7 @@ function actionReport(action_name: string, values: any, currentRef: string) { } } - sendMessage({ + await sendMessage({ type: 'log_report', payload: { user: get(auth)?.email ?? 'unknown', diff --git a/src/lib/components/recipe-editor-dialog.svelte b/src/lib/components/recipe-editor-dialog.svelte index e593d4b..3fac5c8 100644 --- a/src/lib/components/recipe-editor-dialog.svelte +++ b/src/lib/components/recipe-editor-dialog.svelte @@ -185,7 +185,7 @@ let formatted = formatCustomDate(date); ready_to_send_brew[0].LastChange = formatted; - sendMessage({ + await sendMessage({ type: 'save_recipe', payload: { user_info, @@ -194,7 +194,7 @@ } }); } else if (get(referenceFromPage) == 'overview') { - sendMessage({ + await sendMessage({ type: 'save_recipe', payload: { user_info, diff --git a/src/lib/core/client/server.ts b/src/lib/core/client/server.ts index 98e2ea9..979c021 100644 --- a/src/lib/core/client/server.ts +++ b/src/lib/core/client/server.ts @@ -37,7 +37,7 @@ export async function getRecipes() { recipeData.set([]); recipeOverviewData.set([]); - sendMessage({ + await sendMessage({ type: 'recipe', payload: { auth: idToken ?? '', @@ -82,7 +82,7 @@ export async function getRecipeWithVersion(version: string) { // NOTE: although version is provided, actual version field is still need to be latest // Just in case version is not found - sendMessage({ + await sendMessage({ type: 'recipe', payload: { auth: idToken ?? '', diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index b875029..d1d94cf 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -37,15 +37,23 @@ import { buildOverviewFromServer } from '$lib/data/recipeService'; import { auth } from '../client/firebase'; import { type RecipeVersion } from '$lib/models/recipe_version.model'; import { goto } from '$app/navigation'; -import { socketAlreadySendHeartbeat, socketConnectionOfflineCount } from '../stores/websocketStore'; +import { + sharedKey as sharedKey, + socketAlreadySendHeartbeat, + socketConnectionOfflineCount +} from '../stores/websocketStore'; import type { RecipePrice } from '$lib/models/price.model'; import { sendCommandRequest, sendMessage } from './ws_messageSender'; import { auth as authStore } from '../stores/auth'; import { v4 as uuidv4 } from 'uuid'; import { handleSheetResponseFromNoti } from './sheetNotiHandler'; +import { env } from '$env/dynamic/public'; +import * as semver from 'semver'; +import { WebCryptoHelper } from '../utils/crypto'; export const messages = writable([]); +type HandshakeAck = { server_public_key: string; status: string }; type WSMessage = { type: string; payload: any }; // MAXIMUM LIMIT = 1814355 @@ -131,7 +139,7 @@ const handlers: Record void> = { } } }, - stream_data_end: (p) => { + stream_data_end: async (p) => { recipeLoading.set(false); // build overview for recipe from server @@ -154,7 +162,7 @@ const handlers: Record void> = { } // send next chain message - sendMessage({ + await sendMessage({ type: 'price', payload: { action: { @@ -352,7 +360,7 @@ const handlers: Record void> = { currentRecipeVersionsSelector.set(result); } }, - price: (p) => { + price: async (p) => { let req_action = p.req_action; let status = p.status; let to = p.to; @@ -385,7 +393,7 @@ const handlers: Record void> = { current_streaming_instance[request_id] = ''; streamingRawData.set(current_streaming_instance); - sendCommandRequest('sheet', { + await sendCommandRequest('sheet', { country: current_meta?.country ?? '', content: saved_product_code_to_get_from_sheet, param: 'price', @@ -395,59 +403,59 @@ const handlers: Record void> = { lastRequestSheetPrice.set(lastRequestPriceInstance); }, - raw_stream: (p) => { - let streamRawInstance = get(streamingRawData); - let sub_type = p.sub_type; - let request_id = p.request_id; - let size_per_chunk = p.size_per_chunk; - let total_chunks = p.total_chunks; - let idx = p.idx; + // raw_stream: (p) => { + // let streamRawInstance = get(streamingRawData); + // let sub_type = p.sub_type; + // let request_id = p.request_id; + // let size_per_chunk = p.size_per_chunk; + // let total_chunks = p.total_chunks; + // let idx = p.idx; - switch (sub_type) { - case 'price': - streamingRawMeta.set({ - id: request_id, - total_size: total_chunks, - chunk_size: size_per_chunk, - progress: 0 - }); - break; - case 'chunk_price': - streamingRawMeta.set({ - id: request_id, - total_size: total_chunks, - chunk_size: size_per_chunk, - progress: idx - }); + // switch (sub_type) { + // case 'price': + // streamingRawMeta.set({ + // id: request_id, + // total_size: total_chunks, + // chunk_size: size_per_chunk, + // progress: 0 + // }); + // break; + // case 'chunk_price': + // streamingRawMeta.set({ + // id: request_id, + // total_size: total_chunks, + // chunk_size: size_per_chunk, + // progress: idx + // }); - let raw_payload = p.raw ?? ''; - streamRawInstance[request_id] += raw_payload; - streamingRawData.set(streamRawInstance); + // let raw_payload = p.raw ?? ''; + // streamRawInstance[request_id] += raw_payload; + // streamingRawData.set(streamRawInstance); - break; - case 'end_price': - let lastRequestPriceInstance = get(lastRequestSheetPrice); - let country = lastRequestPriceInstance[request_id]; + // break; + // case 'end_price': + // let lastRequestPriceInstance = get(lastRequestSheetPrice); + // let country = lastRequestPriceInstance[request_id]; - try { - let raw_payload = JSON.parse(streamRawInstance[request_id]); - let ref_from_raw = raw_payload.payload.ref ?? ''; - let from_service_raw = raw_payload.payload.from ?? ''; - let parsed_payload = raw_payload.payload ?? ''; + // try { + // let raw_payload = JSON.parse(streamRawInstance[request_id]); + // let ref_from_raw = raw_payload.payload.ref ?? ''; + // let from_service_raw = raw_payload.payload.from ?? ''; + // let parsed_payload = raw_payload.payload ?? ''; - if (from_service_raw == 'sheet-service') { - handleSheetResponseFromNoti(parsed_payload, ref_from_raw, country); - delete streamRawInstance[request_id]; - streamingRawData.set(streamRawInstance); - } - } catch (e) { - console.log(`end price process error: ${e}`); - } + // if (from_service_raw == 'sheet-service') { + // handleSheetResponseFromNoti(parsed_payload, ref_from_raw, country); + // delete streamRawInstance[request_id]; + // streamingRawData.set(streamRawInstance); + // } + // } catch (e) { + // console.log(`end price process error: ${e}`); + // } - break; - default: - } - }, + // break; + // default: + // } + // }, heartbeat: (p) => { socketConnectionOfflineCount.set(0); socketAlreadySendHeartbeat.set(0); @@ -476,22 +484,50 @@ const handlers: Record void> = { } }; -export function handleIncomingMessages(raw: string) { - const msg: WSMessage = JSON.parse(raw); +export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) { + const APP_VERSION = env.PUBLIC_APP_SEMVER; + + const ack: HandshakeAck = JSON.parse(raw); // console.log(`[WS MSG] type=${msg.type}`, msg.payload); - if (msg == null) { - // error response - addNotification('ERR:No response from server'); + if (ack != null && ack.status === 'authenticated') { + // has server response + + sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key)); + + addNotification('INFO:Secured Connection'); + return; } + if (semver.satisfies(APP_VERSION, '^0.0.2')) { + // secured message decryption + let sharedKeyStore = get(sharedKey); + if (sharedKeyStore) { + let raw_payload = JSON.parse(raw); + let decrypted_string = await WebCryptoHelper.decryptMessage( + sharedKeyStore, + raw_payload.ciphertext, + raw_payload.iv + ); + let actual_message: WSMessage = JSON.parse(decrypted_string); - // raw streaming type - if (msg.type.startsWith('raw_stream')) { - // convert - let sub_type = msg.type.replace('raw_stream_', ''); - msg.payload.sub_type = sub_type; - msg.type = 'raw_stream'; + handlers[actual_message.type]?.(actual_message.payload); + } + } else { + const msg: WSMessage = JSON.parse(raw); + if (msg == null) { + // error response + addNotification('ERR:No response from server'); + return; + } + + // raw streaming type + // if (msg.type.startsWith('raw_stream')) { + // // convert + // let sub_type = msg.type.replace('raw_stream_', ''); + // msg.payload.sub_type = sub_type; + // msg.type = 'raw_stream'; + // } + + handlers[msg.type]?.(msg.payload); } - - handlers[msg.type]?.(msg.payload); } diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 5d3dd01..8719e34 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -1,8 +1,11 @@ import { get, writable } from 'svelte/store'; import type { OutMessage } from '../types/outMessage'; -import { socketStore } from '../stores/websocketStore'; +import { sharedKey, socketStore } from '../stores/websocketStore'; import { addNotification } from '../stores/noti'; import { auth } from '../stores/auth'; +import { WebCryptoHelper } from '../utils/crypto'; +import * as semver from 'semver'; +import { env } from '$env/dynamic/public'; export const queue = writable([]); @@ -18,7 +21,7 @@ function getServiceName(cmdReq: CommandRequest) { } // Websocket message wrapper for commands like `sheet`, `command` -export function sendCommandRequest(target: CommandRequest, values: any): boolean { +export async function sendCommandRequest(target: CommandRequest, values: any): Promise { let srv_name = getServiceName(target); let curr_user = get(auth); @@ -31,7 +34,7 @@ export function sendCommandRequest(target: CommandRequest, values: any): boolean }; } - return sendMessage({ + return await sendMessage({ type: target, payload: { user_info: user_info ?? {}, @@ -41,9 +44,13 @@ export function sendCommandRequest(target: CommandRequest, values: any): boolean }); } -export function sendMessage(msg: OutMessage, ignore_queue_request: boolean = true): boolean { +export async function sendMessage( + msg: OutMessage, + ignore_queue_request: boolean = true +): Promise { + const APP_VERSION = env.PUBLIC_APP_SEMVER; const socket = get(socketStore); - const data = JSON.stringify(msg); + let data = JSON.stringify(msg); // console.log('try sending ', data); @@ -64,6 +71,17 @@ export function sendMessage(msg: OutMessage, ignore_queue_request: boolean = tru return false; } + // console.log('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2')); + + if (semver.satisfies(APP_VERSION, '^0.0.2')) { + console.log('sending secured'); + let sharedKeyRes = get(sharedKey); + + // do encrypt + if (sharedKeyRes != null) + data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data)); + } + socket.send(data); return true; } diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index 2e15f35..bb13af6 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -11,47 +11,51 @@ import { } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; -export function requestCatalogs(country: string): boolean { - return sendCommandRequest('sheet', { +export async function requestCatalogs(country: string): Promise { + return await sendCommandRequest('sheet', { country: country, param: 'catalogs' }); } -export function enterRoom(country: string, catalog: string): boolean { - return sendCommandRequest('sheet', { +export async function enterRoom(country: string, catalog: string): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, param: 'enter' }); } -export function sendHeartbeat(country: string, catalog: string): boolean { - return sendCommandRequest('sheet', { +export async function sendHeartbeat(country: string, catalog: string): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, param: 'heartbeat' }); } -export function exitRoom(country: string, catalog: string): boolean { - return sendCommandRequest('sheet', { +export async function exitRoom(country: string, catalog: string): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, param: 'exit' }); } -export function requestCatalogMenu(country: string, catalog: string): boolean { - return sendCommandRequest('sheet', { +export async function requestCatalogMenu(country: string, catalog: string): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, param: 'catalog/menu' }); } -export function updateMenu(country: string, catalog: string, content: any[]): boolean { - return sendCommandRequest('sheet', { +export async function updateMenu( + country: string, + catalog: string, + content: any[] +): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, content: content, @@ -59,9 +63,9 @@ export function updateMenu(country: string, catalog: string, content: any[]): bo }); } -export function addMenu(country: string, catalog: string, content: any[]): boolean { +export async function addMenu(country: string, catalog: string, content: any[]): Promise { console.log('[sheetService] Adding menu:', { country, catalog, content }); - const sent = sendCommandRequest('sheet', { + const sent = await sendCommandRequest('sheet', { country: country, catalog: catalog, content: content, @@ -71,9 +75,13 @@ export function addMenu(country: string, catalog: string, content: any[]): boole return sent; } -export function deleteMenu(country: string, catalog: string, targetIds: number[]): boolean { +export async function deleteMenu( + country: string, + catalog: string, + targetIds: number[] +): Promise { const content = targetIds.map((id) => ({ target_id: id })); - return sendCommandRequest('sheet', { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, content: content, @@ -81,12 +89,12 @@ export function deleteMenu(country: string, catalog: string, targetIds: number[] }); } -export function swapMenu( +export async function swapMenu( country: string, catalog: string, swaps: { source_id: number; target_id: number }[] -): boolean { - return sendCommandRequest('sheet', { +): Promise { + return await sendCommandRequest('sheet', { country: country, catalog: catalog, content: swaps, @@ -94,7 +102,7 @@ export function swapMenu( }); } -export function requestListMenu(country: string, boxid?: string): boolean { +export async function requestListMenu(country: string, boxid?: string): Promise { const curr_user = get(auth); let user_info: any = {}; @@ -111,7 +119,7 @@ export function requestListMenu(country: string, boxid?: string): boolean { console.log('[sheetService] Sending list_menu request for country:', country, 'boxid:', boxid); - return sendMessage({ + return await sendMessage({ type: 'list_menu', payload: { user_info, @@ -121,7 +129,7 @@ export function requestListMenu(country: string, boxid?: string): boolean { }); } -export function requestGenLayout(country: string): boolean { +export async function requestGenLayout(country: string): Promise { const curr_user = get(auth); let user_info: any = {}; @@ -137,7 +145,7 @@ export function requestGenLayout(country: string): boolean { console.log('[sheetService] Sending gen-layout request for country:', country); - return sendMessage({ + return await sendMessage({ type: 'command', payload: { user_info, @@ -156,7 +164,7 @@ export function requestGenLayout(country: string): boolean { * Request price data from sheet for specific product codes * NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check. */ -export function requestSheetPrice(country: string, productCodes: string[]): boolean { +export async function requestSheetPrice(country: string, productCodes: string[]): Promise { // Check if already sent if (hasSheetPriceBeenSent('price')) { console.warn('[sheetService] Price request already sent, skipping'); @@ -187,9 +195,16 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool // Convert to array of objects (backend expects objects, not strings) const content = productCodes.map((code) => ({ product_code: code })); - console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id); + console.log( + '[sheetService] Sending sheet price request for country:', + country, + 'codes:', + productCodes.length, + 'request_id:', + request_id + ); - const sent = sendCommandRequest('sheet', { + const sent = await sendCommandRequest('sheet', { country: country, content: content, param: 'price', @@ -210,18 +225,23 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool * Update price data in sheet * content: [{ row_index: number, cells: [{ value: string, coord: { row: number, col: number } }] }] */ -export function updateSheetPrice( +export async function updateSheetPrice( country: string, content: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[] -): boolean { +): Promise { if (!content || content.length === 0) { console.warn('[sheetService] No content to update'); return false; } - console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length); + console.log( + '[sheetService] Updating sheet price for country:', + country, + 'items:', + content.length + ); - return sendCommandRequest('sheet', { + return await sendCommandRequest('sheet', { country: country, content: content, param: 'update/price' @@ -232,18 +252,24 @@ export function updateSheetPrice( * Add new price rows to sheet (for product codes that don't exist in price sheet) * content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }] */ -export function addSheetPrice( +export async function addSheetPrice( country: string, content: { cells: string[] }[] -): boolean { +): Promise { if (!content || content.length === 0) { console.warn('[sheetService] No content to add'); return false; } - console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content); + console.log( + '[sheetService] Adding price rows for country:', + country, + 'items:', + content.length, + content + ); - return sendCommandRequest('sheet', { + return await sendCommandRequest('sheet', { country: country, content: content, param: 'add/price' diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 720fb7e..23de68c 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -7,16 +7,20 @@ import { auth } from '../client/firebase'; import { auth as authStore } from '$lib/core/stores/auth'; import { addNotification } from './noti'; import { permission } from './permissions'; +import { WebCryptoHelper } from '../utils/crypto'; let socket: WebSocket | null = null; let reconnectTimeout: any; let socketCheck: any; +let sendAuthInfoInterval: any; const ENABLE_WS_DEBUG: boolean = false; export const socketConnectionOfflineCount = writable(0); export const socketAlreadySendHeartbeat = writable(0); export const socketStore = writable(null); +export const sharedKey = writable(null); + export function waitForOpenSocket(timeoutMs = 8000): Promise { const currentSocket = get(socketStore); if (currentSocket?.readyState === WebSocket.OPEN) { @@ -49,7 +53,7 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise { }); } -export function connectToWebsocket(id_token?: string) { +export async function connectToWebsocket(id_token?: string) { if (browser) { // console.log('connecting to ', env.PUBLIC_WSS); try { @@ -57,12 +61,12 @@ export function connectToWebsocket(id_token?: string) { return; } - let productionMode = env.PUBLIC_WSS.startsWith('wss'); - - let ws_url = productionMode ? `${env.PUBLIC_WSS}?token=${id_token}` : `${env.PUBLIC_WSS}`; + let ws_url = env.PUBLIC_WSS; socket = new WebSocket(ws_url); + sharedKey.set(null); + const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair(); - socket.addEventListener('open', () => { + socket.addEventListener('open', async () => { socketStore.set(socket); addNotification('INFO:Connected!'); @@ -74,29 +78,40 @@ export function connectToWebsocket(id_token?: string) { let auth_data = get(authStore); let perms = get(permission); - // Debug: check if auth_data has uid - console.log('[WS Auth] Sending auth with:', { - uid: auth_data?.uid, - name: auth_data?.displayName, - email: auth_data?.email - }); + socket.send( + JSON.stringify({ + token: id_token ?? '', + client_public_key: publicKeyBase64 + }) + ); - sendMessage({ - type: 'auth', - payload: { - user: { - uid: auth_data?.uid ?? '', - name: auth_data?.displayName ?? '', - email: auth_data?.email ?? '', - permissions: perms.join(',') - } + sendAuthInfoInterval = setInterval(async () => { + if (get(sharedKey)) { + // Debug: check if auth_data has uid + console.log('[WS Auth] Sending auth info with:', { + uid: auth_data?.uid, + name: auth_data?.displayName, + email: auth_data?.email + }); + await sendMessage({ + type: 'auth', + payload: { + user: { + uid: auth_data?.uid ?? '', + name: auth_data?.displayName ?? '', + email: auth_data?.email ?? '', + permissions: perms.join(',') + } + } + }); + clearInterval(sendAuthInfoInterval); } - }); + }, 3000); } console.log(socket); // heartbeat 10s - socketCheck = setInterval(() => { + socketCheck = setInterval(async () => { if (get(socketAlreadySendHeartbeat) > 0) { let heartbeat_may_offline_count = get(socketConnectionOfflineCount); @@ -108,13 +123,13 @@ export function connectToWebsocket(id_token?: string) { socketConnectionOfflineCount.set(0); socketAlreadySendHeartbeat.set(0); - connectToWebsocket(id_token); + await connectToWebsocket(id_token); return; } if (socket != null) { - sendMessage({ + await sendMessage({ type: 'heartbeat', payload: {} }); @@ -130,18 +145,19 @@ export function connectToWebsocket(id_token?: string) { if (auth.currentUser && socket == null) { console.log('try reconnect websocket ...'); // retry again - reconnectTimeout = setTimeout(() => { - connectToWebsocket(id_token); + reconnectTimeout = setTimeout(async () => { + await connectToWebsocket(id_token); }, 5000); } }); - socket.addEventListener('message', (event) => { - handleIncomingMessages(event.data); + socket.addEventListener('message', async (event) => { + await handleIncomingMessages(event.data, privateKey); }); socket.addEventListener('close', () => { socketStore.set(null); + sharedKey.set(null); socket = null; clearInterval(socketCheck); @@ -149,13 +165,14 @@ export function connectToWebsocket(id_token?: string) { if (auth.currentUser && !socket) { console.log('try reconnect websocket ...'); // retry again - reconnectTimeout = setTimeout(() => connectToWebsocket(id_token), 5000); + reconnectTimeout = setTimeout(async () => await connectToWebsocket(id_token), 5000); } }); socket.addEventListener('error', (e) => { // console.log('WebSocket error: ', e); socketStore.set(null); + sharedKey.set(null); }); } catch (socket_error: any) { if (ENABLE_WS_DEBUG) { diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts index f51bd46..5795c9f 100644 --- a/src/lib/core/types/outMessage.ts +++ b/src/lib/core/types/outMessage.ts @@ -1,4 +1,5 @@ export type OutMessage = + | { token: any; client_public_key: any } | { type: 'chat'; payload: string } | { type: 'ping' } | { type: 'lock'; payload: { field: string } } @@ -54,7 +55,7 @@ export type OutMessage = values: any; }; } - | { + | { type: 'list_menu'; payload: { user_info: any; @@ -62,7 +63,6 @@ export type OutMessage = boxid?: string; }; } - | { type: 'price'; payload: { diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts new file mode 100644 index 0000000..0803966 --- /dev/null +++ b/src/lib/core/utils/crypto.ts @@ -0,0 +1,68 @@ +export class WebCryptoHelper { + static async generateKeyPair() { + const keyPair = await window.crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256' + }, + true, + ['deriveKey', 'deriveBits'] + ); + + const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); + const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic))); + + return { privateKey: keyPair.privateKey, publicKeyBase64 }; + } + + static async deriveSharedKey(clientPrivateKey: any, serverPublicKeyBase64: any) { + const binarySign = atob(serverPublicKeyBase64); + const bytes = new Uint8Array(binarySign.length); + for (let i = 0; i < binarySign.length; i++) { + bytes[i] = binarySign.charCodeAt(i); + } + + const importedServerPublic = await window.crypto.subtle.importKey( + 'raw', + bytes, + { name: 'ECDH', namedCurve: 'P-256' }, + true, + [] + ); + return await window.crypto.subtle.deriveKey( + { name: 'ECDH', public: importedServerPublic }, + clientPrivateKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async decryptMessage(aesKey: any, ciphertextBase64: any, ivBase64: any) { + const rawCipher = Uint8Array.from(atob(ciphertextBase64), (c) => c.charCodeAt(0)); + const rawIv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0)); + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: rawIv }, + aesKey, + rawCipher + ); + return new TextDecoder().decode(decryptedBuffer); + } + + // Encrypt outgoing messages before sending them to your Axum backend + static async encryptMessage(aesKey: any, plainText: any) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12-byte nonce + const encodedText = new TextEncoder().encode(plainText); + + const ciphertextBuffer = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + aesKey, + encodedText + ); + + const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer))); + const ivBase64 = btoa(String.fromCharCode(...iv)); + + return { ciphertext: ciphertextBase64, iv: ivBase64 }; + } +} diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte index 926c2d3..910c93e 100644 --- a/src/routes/(authed)/+layout.svelte +++ b/src/routes/(authed)/+layout.svelte @@ -97,8 +97,8 @@ websocketConnectedForUid = currentUser.uid; console.log('connect ws after auth ready'); - void currentUser.getIdToken().then((idToken) => { - connectToWebsocket(idToken); + void currentUser.getIdToken(true).then(async (idToken) => { + await connectToWebsocket(idToken); }); } diff --git a/src/routes/(authed)/recipe/overview/+page.svelte b/src/routes/(authed)/recipe/overview/+page.svelte index 8c9cf29..edd1459 100644 --- a/src/routes/(authed)/recipe/overview/+page.svelte +++ b/src/routes/(authed)/recipe/overview/+page.svelte @@ -58,9 +58,9 @@ } }); - function sendGetRecipeVersions(country: string) { + async function sendGetRecipeVersions(country: string) { version_list = []; - sendMessage({ + await sendMessage({ type: 'recipe_versions', payload: { auth: '', From 6a2f4e5945eec23cd7fe1dc2c1d23254c9a4ad84 Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Tue, 16 Jun 2026 11:30:23 +0700 Subject: [PATCH 2/7] update get data priceslot --- src/lib/core/adb/adb.ts | 10 + src/lib/core/handlers/messageHandler.ts | 67 +- src/lib/core/services/sheetService.ts | 76 +- src/lib/core/stores/sheetStore.ts | 373 +++++++-- .../sheet/priceslot/[country]/+page.svelte | 723 +++++++++++------- 5 files changed, 912 insertions(+), 337 deletions(-) diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 2cab7d5..716911e 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -362,6 +362,16 @@ export async function executeCmd(command: string) { } } + +export async function goToMachineHome() { + if (!getAdbInstance()) return; + try { + await executeCmd('input keyevent KEYCODE_HOME'); + } catch (e) { + console.error('[goToMachineHome] error', e); + } +} + export async function disconnect() { let instance = getAdbInstance(); if (instance) { diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 61cd6a3..d0f6cd5 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -21,6 +21,8 @@ import { handleSheetStreamEnd, handleSheetStreamError, handleCatalogsResponse, + handlePriceSlotsResponse, + isPriceSlotsPayload, handleListMenuResponse, sheetCatalogsLoading, handleRawStreamHeader, @@ -283,22 +285,55 @@ const handlers: Record void> = { if (from === 'sheet-service' && level === 'content') { const currentUid = auth.currentUser?.uid; + const content = p.content ?? p.value ?? p.payload; - if (target && currentUid && target === currentUid) { - if (!msg && p.content?.catalogs) { - handleCatalogsResponse(p.content); - addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`); + console.log('[Sheet] Notify content received:', { + msg, + target, + currentUid, + contentKeys: content && typeof content === 'object' ? Object.keys(content) : [], + content + }); + + if (!target || (currentUid && target === currentUid)) { + if (!msg && content?.catalogs) { + handleCatalogsResponse(content); + addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`); + return; + } + + if ( + !msg && + (content?.priceSlots || + content?.priceslots || + content?.price_slots || + content?.slots || + content?.param === 'priceslot' || + content?.option === 'PriceSlot' || + isPriceSlotsPayload(content)) + ) { + handlePriceSlotsResponse(content); + addNotification('INFO:Loaded PriceSlot data'); return; } // Handle streaming messages (with msg field) switch (msg) { + case 'priceslot': + case 'price_slot': + handlePriceSlotsResponse(content); + addNotification('INFO:Loaded PriceSlot data'); + break; case 'start': handleSheetStreamStart(p); addNotification('INFO:Sheet data streaming started'); break; case 'chunk': - handleSheetStreamChunk(p); + if (isPriceSlotsPayload(content)) { + handlePriceSlotsResponse(content); + } else { + handleSheetStreamChunk(p); + } break; case 'end': handleSheetStreamEnd(p); @@ -310,8 +345,15 @@ const handlers: Record void> = { break; default: // Handle other content notifications from sheet-service - console.log('[Sheet] Received content:', p.content); + console.log('[Sheet] Received content:', content); } + } else { + console.warn('[Sheet] Ignored content because target does not match current user:', { + target, + currentUid, + msg, + content + }); } return; } @@ -466,19 +508,30 @@ const handlers: Record void> = { // Header for price stream handleRawStreamHeader('price', p); }, + raw_stream_priceslot: (p) => { + handleRawStreamHeader('priceslot', p); + }, raw_stream_chunk_price: (p) => { // Chunk for price stream handleRawStreamChunk('price', p); }, + raw_stream_chunk_priceslot: (p) => { + handleRawStreamChunk('priceslot', p); + }, raw_stream_end_price: (p) => { // End for price stream handleRawStreamEnd('price', p); + }, + raw_stream_end_priceslot: (p) => { + handleRawStreamEnd('priceslot', p); } }; export function handleIncomingMessages(raw: string) { const msg: WSMessage = JSON.parse(raw); - // console.log(`[WS MSG] type=${msg.type}`, msg.payload); + if (msg.type !== 'heartbeat') { + console.log(`[WS MSG] type=${msg.type}`, msg.payload); + } if (msg == null) { // error response addNotification('ERR:No response from server'); diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index 8fb6c39..07aad5a 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -7,8 +7,12 @@ import { markSheetPriceAsSent, sheetPriceLoading, streamingRawData, - setPendingProductCodesCountry + setPendingProductCodesCountry, + setPendingPriceSlotsCountry, + priceSlotsLoading, + resetPriceSlotsCountry } from '../stores/sheetStore'; +import type { PriceSlot } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; export function requestCatalogs(country: string): boolean { @@ -19,21 +23,38 @@ export function requestCatalogs(country: string): boolean { } export function requestPriceSlots(country: string): boolean { - return sendCommandRequest('sheet', { + setPendingPriceSlotsCountry(country); + resetPriceSlotsCountry(country); + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + priceslot: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + priceSlotsLoading.set(true); + + const values = { country: country, - param: 'priceslot' - }); + param: 'price', + option: 'PriceSlot', + stream: true, + request_id + }; + console.log('[sheetService] Sending PriceSlot request:', values); + const sent = sendCommandRequest('sheet', values); + console.log('[sheetService] PriceSlot request sent:', sent); + if (!sent) { + priceSlotsLoading.set(false); + } + return sent; } -export function updatePriceSlot( - country: string, - content: { - slot: number; - name: string; - description: string; - products: { product_code: string; price: number | null; row_index?: number }[]; - } -): boolean { +export function updatePriceSlot(country: string, content: PriceSlot): boolean { return sendCommandRequest('sheet', { country: country, content: content, @@ -210,7 +231,14 @@ export function requestSheetPrice(country: string, productCodes: string[]): bool // Convert to array of objects (backend expects objects, not strings) const content = productCodes.map((code) => ({ product_code: code })); - console.log('[sheetService] Sending sheet price request for country:', country, 'codes:', productCodes.length, 'request_id:', request_id); + console.log( + '[sheetService] Sending sheet price request for country:', + country, + 'codes:', + productCodes.length, + 'request_id:', + request_id + ); const sent = sendCommandRequest('sheet', { country: country, @@ -242,7 +270,12 @@ export function updateSheetPrice( return false; } - console.log('[sheetService] Updating sheet price for country:', country, 'items:', content.length); + console.log( + '[sheetService] Updating sheet price for country:', + country, + 'items:', + content.length + ); return sendCommandRequest('sheet', { country: country, @@ -255,16 +288,19 @@ export function updateSheetPrice( * Add new price rows to sheet (for product codes that don't exist in price sheet) * content: [{ cells: [product_code, name_en, name_th, ..., price, ...] }] */ -export function addSheetPrice( - country: string, - content: { cells: string[] }[] -): boolean { +export function addSheetPrice(country: string, content: { cells: string[] }[]): boolean { if (!content || content.length === 0) { console.warn('[sheetService] No content to add'); return false; } - console.log('[sheetService] Adding price rows for country:', country, 'items:', content.length, content); + console.log( + '[sheetService] Adding price rows for country:', + country, + 'items:', + content.length, + content + ); return sendCommandRequest('sheet', { country: country, diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index eb2d626..a17421b 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -24,16 +24,212 @@ export interface PriceSlotProduct { row_index?: number; } +export interface PriceSlotServiceRow { + row_index?: number; + cells: Record; +} + export interface PriceSlot { slot: number; name: string; description: string; + kind?: 'price' | 'service'; + header?: string[]; products: PriceSlotProduct[]; + serviceRows?: PriceSlotServiceRow[]; } export const priceSlots = writable>({}); export const priceSlotsLoading = writable(false); export const priceSlotsError = writable(null); +let pendingPriceSlotsCountry = ''; + +export function setPendingPriceSlotsCountry(country: string) { + pendingPriceSlotsCountry = country.toLowerCase(); +} + +export function resetPriceSlotsCountry(country: string) { + const key = country.toLowerCase(); + priceSlots.update((data) => ({ + ...data, + [key]: [] + })); + priceSlotsError.set(null); +} + +function normalizePriceSlotProduct(product: any): PriceSlotProduct | null { + const cells = Array.isArray(product?.cells) ? product.cells : []; + const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value; + const productCode = + product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1); + + if (!productCode) return null; + + const priceValue = + product?.price ?? + product?.Price ?? + product?.value ?? + product?.cash_price ?? + product?.CashPrice ?? + cellValue(5); + const price = + priceValue === '' || priceValue === undefined || priceValue === null + ? null + : Number(priceValue); + + return { + product_code: String(productCode), + name: String( + product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? '' + ), + price: Number.isNaN(price) ? null : price, + row_index: product?.row_index ?? product?.row + }; +} + +function getPriceSlotHeader(slot: any): string[] { + const header = Array.isArray(slot?.header) ? slot.header : []; + return header.map((value: any) => String(value ?? '').trim()); +} + +function isServicePriceSlotHeader(header: string[]): boolean { + return header.some((value) => value.toLowerCase() === 'servicetype'); +} + +function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null { + const cells = Array.isArray(row?.cells) ? row.cells : []; + const mappedCells = header.reduce>((result, columnName, index) => { + if (!columnName) return result; + const value = + row?.[columnName] ?? + row?.[columnName.replace(/\s+/g, '')] ?? + cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ?? + ''; + result[columnName] = String(value ?? ''); + return result; + }, {}); + + if (Object.values(mappedCells).every((value) => value === '')) return null; + + return { + row_index: row?.row_index ?? row?.row, + cells: mappedCells + }; +} + +function normalizePriceSlot(slot: any, index: number): PriceSlot { + const sheetName = slot?.sheet ?? slot?.Sheet; + const displayName = slot?.name ?? slot?.title ?? sheetName; + const slotNumber = Number( + slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1 + ); + const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? []; + const header = getPriceSlotHeader(slot); + const isServiceSlot = isServicePriceSlotHeader(header); + const headerName = isServiceSlot ? header[12] : header[10]; + const headerDescription = isServiceSlot ? header[13] : header[11]; + const products = (Array.isArray(productsSource) ? productsSource : []) + .map(normalizePriceSlotProduct) + .filter((product): product is PriceSlotProduct => product !== null); + const serviceRows = isServiceSlot + ? (Array.isArray(productsSource) ? productsSource : []) + .map((row) => normalizePriceSlotServiceRow(row, header)) + .filter((row): row is PriceSlotServiceRow => row !== null) + : []; + + return { + slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber, + name: String( + headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}` + ), + description: String(headerDescription ?? ''), + kind: isServiceSlot ? 'service' : 'price', + header, + products: isServiceSlot ? [] : products, + serviceRows + }; +} + +export function handlePriceSlotsResponse(content: any) { + console.log('[PriceSlot] Raw backend response:', content); + const country = String( + content?.country ?? content?.Country ?? pendingPriceSlotsCountry + ).toLowerCase(); + const source = + content?.priceSlots ?? + content?.priceslots ?? + content?.price_slots ?? + content?.slots ?? + content?.data ?? + content?.value ?? + content?.content ?? + content; + const slotList = Array.isArray(source) + ? source + : typeof source === 'object' && source + ? Object.entries(source).map(([key, value]) => ({ + ...(typeof value === 'object' && value ? value : {}), + name: (value as any)?.name ?? key + })) + : []; + + if (!country || slotList.length === 0) { + console.warn('[PriceSlot] No slot list found:', { country, source, content }); + priceSlotsError.set('No PriceSlot data found in backend response'); + priceSlotsLoading.set(false); + return; + } + + const normalizedSlots = slotList + .map(normalizePriceSlot) + .filter((slot) => + slot.kind === 'service' ? (slot.serviceRows?.length ?? 0) > 0 : slot.products.length > 0 + ); + + if (normalizedSlots.length === 0) { + console.warn('[PriceSlot] Response did not include usable rows:', { country, slotList }); + return; + } + + console.log('[PriceSlot] Normalized slots:', { + country, + slots: normalizedSlots.length, + firstSlot: normalizedSlots[0] + }); + + priceSlots.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(`${slot.slot}:${slot.name}`, slot); + } + for (const slot of normalizedSlots) { + merged.set(`${slot.slot}:${slot.name}`, slot); + } + + return { + ...data, + [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot) + }; + }); + priceSlotsError.set(null); + priceSlotsLoading.set(false); +} + +export function isPriceSlotsPayload(content: any): boolean { + const source = + content?.priceSlots ?? + content?.priceslots ?? + content?.price_slots ?? + content?.slots ?? + content?.data ?? + content?.value ?? + content?.content ?? + content; + + if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true; + if (!Array.isArray(source)) return false; + return source.some((item) => String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot')); +} export const countryPrimaryLanguageMap: Record = { THAI: 'Thai', @@ -78,11 +274,14 @@ export function getCountryPrimaryLanguage(countryCode: string): string { // Sheet column configuration by country for new_layout_v2 // Maps language keys to column indices and product code columns -export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record; - productCode: { hot: number; cold: number; blend: number }; - primaryLanguage: string; -}> = { +export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< + string, + { + language: Record; + productCode: { hot: number; cold: number; blend: number }; + primaryLanguage: string; + } +> = { tha: { language: { en: 3, th: 4, zh: 5, my: 8 }, productCode: { hot: 9, cold: 10, blend: 11 }, @@ -151,8 +350,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record = { +export const PRICE_HEADER_NAMES_BY_COUNTRY: Record< + string, + { + cash_price: string[]; // Possible header names for cash price + non_cash_price: string[]; // Possible header names for non-cash price + } +> = { tha: { cash_price: ['Price'], non_cash_price: ['MainPrice'] @@ -366,7 +570,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record h.toLowerCase() === name.toLowerCase()); + const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase()); if (idx !== -1) { // Return col index (header index + 1 because cells start from col 1) return idx + 1; @@ -382,7 +586,9 @@ export const lastRequestSheetPrice = writable>({}); // Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates) -export const sheetPriceAllRows = writable>>({}); +export const sheetPriceAllRows = writable< + Record> +>({}); // Helper function to get price value from cells using dynamic header lookup export function getPriceFromCells( @@ -397,15 +603,20 @@ export function getPriceFromCells( } // Get possible header names for this country - const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default; - const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price; + const headerNames = + PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default; + const possibleNames = + priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price; // Find the column index for this price type const colIdx = findHeaderIndex(headers, possibleNames); //console.log(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames); if (colIdx < 0) { - console.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames); + console.warn( + `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, + possibleNames + ); return null; } @@ -444,15 +655,20 @@ export const streamingRawData = writable< // Handler: raw_stream header (e.g., raw_stream_price) export function handleRawStreamHeader(subtype: string, payload: any) { - console.log(`[RawStream] Header for ${subtype}:`, payload); + let targetSubtype = subtype; + const currentData = get(streamingRawData); + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log(`[RawStream] Header for ${targetSubtype}:`, payload); // Get existing stream data to preserve country from request - const currentData = get(streamingRawData); - const existingData = currentData[subtype]; + const existingData = currentData[targetSubtype]; streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { request_id: payload.request_id, header: payload.header || payload.headers, country: payload.country || existingData?.country || '', @@ -461,7 +677,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) { } })); - if (subtype === 'price') { + if (targetSubtype === 'price') { sheetPriceStreamMeta.set({ request_id: payload.request_id, country: payload.country || existingData?.country || '', @@ -473,13 +689,21 @@ export function handleRawStreamHeader(subtype: string, payload: any) { // Handler: raw_stream chunk (e.g., raw_stream_chunk_price) export function handleRawStreamChunk(subtype: string, payload: any) { - console.log(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length); - const currentData = get(streamingRawData); - const streamData = currentData[subtype]; + let targetSubtype = subtype; + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log( + `[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`, + payload.raw?.length + ); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { - console.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`); + console.warn(`[RawStream] Chunk received for unknown stream: ${targetSubtype}`); return; } @@ -488,13 +712,13 @@ export function handleRawStreamChunk(subtype: string, payload: any) { // Accumulate raw parts - will be joined and parsed in handleRawStreamEnd streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { ...streamData, country: payload.country || streamData.country, rawParts: [...(streamData.rawParts || []), payload.raw] } })); - console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${subtype}`); + console.log(`[RawStream] Accumulated chunk ${payload.idx} for ${targetSubtype}`); return; } @@ -504,25 +728,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) { streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { ...streamData, country: payload.country || streamData.country, chunks: [...streamData.chunks, ...contentArray] } })); - console.log(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`); + console.log(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`); } // Handler: raw_stream end (e.g., raw_stream_end_price) export function handleRawStreamEnd(subtype: string, payload: any) { - console.log(`[RawStream] End payload for ${subtype}:`, payload); - const currentData = get(streamingRawData); - const streamData = currentData[subtype]; + let targetSubtype = subtype; + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + console.log(`[RawStream] End payload for ${targetSubtype}:`, payload); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { - console.warn(`[RawStream] End received for unknown stream: ${subtype}`); + console.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`); return; } @@ -554,18 +783,36 @@ export function handleRawStreamEnd(subtype: string, payload: any) { } } - console.log(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`); + console.log( + `[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}` + ); - if (subtype === 'price') { - processSheetPriceData(country, streamData.header || [], chunks); - sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null)); - sheetPriceLoading.set(false); + if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) { + handlePriceSlotsResponse({ country, slots: chunks }); + } + + if (targetSubtype === 'price') { + const looksLikePriceSlot = chunks.some((item) => { + return ( + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + item?.option === 'PriceSlot' || + item?.param === 'priceslot' + ); + }); + + if (looksLikePriceSlot) { + handlePriceSlotsResponse({ country, slots: chunks }); + } else { + processSheetPriceData(country, streamData.header || [], chunks); + sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null)); + sheetPriceLoading.set(false); + } } // Clear the streaming data streamingRawData.update((data) => { const newData = { ...data }; - delete newData[subtype]; + delete newData[targetSubtype]; return newData; }); } @@ -600,8 +847,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) // Find column indices dynamically from header // product_code header is typically "ProductCode" or similar - const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']); - console.log(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader); + const productCodeIdx = findHeaderIndex(effectiveHeader, [ + 'ProductCode', + 'Product_Code', + 'product_code', + 'Code' + ]); + console.log( + `[SheetPrice] productCodeIdx from header:`, + productCodeIdx, + 'header:', + effectiveHeader + ); const priceByProductCode: Record = {}; // Track ALL rows per product code (for duplicates) @@ -702,7 +959,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) // Log duplicates info const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1); if (duplicates.length > 0) { - console.log(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3)); + console.log( + `[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, + duplicates.slice(0, 3) + ); } if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) { const sampleKey = Object.keys(priceByProductCode)[0]; @@ -769,14 +1029,24 @@ export function loadProductCodesFromCache(country?: string): boolean { // Only load if country matches (or no country filter specified) if (data.codes && Array.isArray(data.codes)) { if (country && data.country && data.country !== country) { - console.log('[sheetStore] Cache is for different country:', data.country, '!= requested:', country); + console.log( + '[sheetStore] Cache is for different country:', + data.country, + '!= requested:', + country + ); // Clear the store for different country existingProductCodes.set(new Set()); return false; } existingProductCodes.set(new Set(data.codes)); currentProductCodesCountry = data.country || ''; - console.log('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown'); + console.log( + '[sheetStore] Loaded', + data.codes.length, + 'product codes from cache for', + data.country || 'unknown' + ); return true; } } @@ -798,7 +1068,13 @@ export function clearProductCodes() { export function handleListMenuResponse(payload: { codes: string[]; country?: string }) { // Use pending country if not in payload const country = payload.country || pendingProductCodesCountry; - console.log('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes'); + console.log( + '[sheetStore] Received list_menu_response for', + country, + ':', + payload.codes?.length, + 'codes' + ); if (payload && payload.codes) { existingProductCodes.set(new Set(payload.codes)); @@ -814,7 +1090,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str timestamp: Date.now() }) ); - console.log('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country); + console.log( + '[sheetStore] Saved', + payload.codes.length, + 'product codes to cache for', + country + ); } catch (e) { console.warn('[sheetStore] Failed to save to cache:', e); } diff --git a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte index 86a2542..fa60048 100644 --- a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte +++ b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte @@ -8,15 +8,17 @@ import { permission as currentPerms } from '$lib/core/stores/permissions.js'; import { referenceFromPage } from '$lib/core/stores/recipeStore.js'; import { - clearSheetPriceSentTypes, getCountryPrimaryLanguage, getPriceFromCells, lastRequestSheetPrice, - sheetPriceLoading, + priceSlots, + priceSlotsError, + priceSlotsLoading, type PriceSlot, - type PriceSlotProduct + type PriceSlotProduct, + type PriceSlotServiceRow } from '$lib/core/stores/sheetStore.js'; - import { requestSheetPrice } from '$lib/core/services/sheetService.js'; + import { requestPriceSlots, updatePriceSlot } from '$lib/core/services/sheetService.js'; import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js'; import Button from '$lib/components/ui/button/button.svelte'; @@ -30,77 +32,31 @@ type AdjustmentMode = | 'increase_percent' | 'increase_amount' - | 'decrease_amount' - | 'decrease_percent'; + | 'decrease_percent' + | 'decrease_amount'; const adjustmentModeLabels: Record = { increase_percent: 'Increase by Percentage (%)', increase_amount: 'Increase by Fixed Amount', - decrease_amount: 'Decrease by Fixed Amount', - decrease_percent: 'Decrease by Percentage (%)' + decrease_percent: 'Decrease by Percentage (%)', + decrease_amount: 'Decrease by Fixed Amount' }; - const mockProducts: PriceSlotProduct[] = [ - { - product_code: '12-01-01-0001', - name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน', - price: 30, - row_index: 2 - }, - { product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 }, - { product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 }, - { product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 }, - { - product_code: '12-01-02-0001', - name: 'Iced AMERICANO | กาแฟดำเย็น', - price: 40, - row_index: 16 - }, - { product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 }, - { product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 }, - { - product_code: '12-02-01-0002', - name: 'Hot THAI MILK TEA | ชาไทยร้อน', - price: 40, - row_index: 27 - }, - { - product_code: '12-02-01-0004', - name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน', - price: 50, - row_index: 29 - } - ]; - - function buildMockSlots(): PriceSlot[] { - return Array.from({ length: 10 }, (_, index) => { - const slot = index + 1; - const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5; - - return { - slot, - name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`, - description: slot <= 2 ? `increase price ${increase}%` : '', - products: mockProducts.map((product) => ({ - ...product, - price: - product.price === null - ? null - : slot <= 2 - ? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5 - : product.price - })) - }; - }); - } + const emptySlot: PriceSlot = { + slot: 0, + name: '', + description: '', + kind: 'price', + header: [], + products: [] + }; let selectedCountry = $state($page.params.country || get(departmentStore) || ''); let enabledCountries = $state([]); - let selectedSlot = $state(1); - const initialSlots = buildMockSlots(); - let slots = $state(initialSlots); - let savedSnapshot = $state(structuredClone(initialSlots)); - let loading = $state(false); + let selectedSlot = $state(0); + let localSlots = $state([]); + let workingSlot = $state(null); + let savedSlot = $state(null); let productCodeSearch = $state(''); let createDialogOpen = $state(false); let adjustmentMode = $state('increase_percent'); @@ -108,17 +64,22 @@ let createName = $state('ProfileIncrease15'); let createDescription = $state('increase price 15%'); - let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]); + let selectedCountryKey = $derived(selectedCountry.toLowerCase()); let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry)); + let backendSlots = $derived($priceSlots[selectedCountryKey] ?? []); + let displaySlots = $derived([...backendSlots, ...localSlots].sort((a, b) => a.slot - b.slot)); + let selectedSourceSlot = $derived( + displaySlots.find((slot) => slot.slot === selectedSlot) ?? displaySlots[0] ?? null + ); + let currentSlot = $derived(workingSlot ?? emptySlot); + let isServiceSlot = $derived(currentSlot.kind === 'service'); + let serviceHeaders = $derived(currentSlot.header?.filter(Boolean) ?? []); + let loading = $derived($priceSlotsLoading); let basePriceCells = $derived( $lastRequestSheetPrice[selectedCountry.toLowerCase()] || $lastRequestSheetPrice[selectedCountry] || {} ); - let basePricesLoadedCount = $derived( - mockProducts.filter((product) => getBasePrice(product) !== null).length - ); - let basePriceLoading = $derived($sheetPriceLoading); let filteredProducts = $derived( currentSlot.products.filter((product) => { const keyword = productCodeSearch.trim().toLowerCase(); @@ -127,12 +88,34 @@ return product.product_code.toLowerCase().includes(keyword); }) ); - let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1])); - let hasHeaderChanges = $derived( - currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name || - currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description + let filteredServiceRows = $derived( + (currentSlot.serviceRows ?? []).filter((row) => { + const keyword = productCodeSearch.trim().toLowerCase(); + if (!keyword) return true; + + return Object.values(row.cells).some((value) => value.toLowerCase().includes(keyword)); + }) + ); + let changedCount = $derived(countChangedProducts(currentSlot, savedSlot ?? undefined)); + let changedServiceCount = $derived(countChangedServiceRows(currentSlot, savedSlot ?? undefined)); + let totalChangedCount = $derived(changedCount + changedServiceCount); + let visibleRowCount = $derived( + isServiceSlot ? filteredServiceRows.length : filteredProducts.length + ); + let totalRowCount = $derived( + isServiceSlot ? (currentSlot.serviceRows?.length ?? 0) : currentSlot.products.length + ); + let hasHeaderChanges = $derived( + currentSlot.name !== savedSlot?.name || currentSlot.description !== savedSlot?.description + ); + let hasChanges = $derived(totalChangedCount > 0 || hasHeaderChanges); + let resetButtonTitle = $derived( + !currentSlot.slot + ? 'Select a PriceSlot first' + : !hasChanges + ? 'Reset is available after changing this PriceSlot' + : 'Discard unsaved changes and restore the last loaded values' ); - let hasChanges = $derived(changedCount > 0 || hasHeaderChanges); onMount(() => { referenceFromPage.set('priceslot'); @@ -143,8 +126,43 @@ const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write')); enabledCountries = userPerms.map((x) => x.split('.')[2]); + + if (selectedCountry) { + void loadPriceSlots(); + } }); + let lastLoadedSlotSignature = $state(''); + + $effect(() => { + if (displaySlots.length === 0) { + workingSlot = null; + savedSlot = null; + lastLoadedSlotSignature = ''; + return; + } + + if (!displaySlots.some((slot) => slot.slot === selectedSlot)) { + selectedSlot = displaySlots[0]?.slot ?? 0; + return; + } + + if (!selectedSourceSlot) return; + + const signature = JSON.stringify(selectedSourceSlot); + if (signature === lastLoadedSlotSignature) return; + if (hasChanges && workingSlot?.slot === selectedSourceSlot.slot) return; + + lastLoadedSlotSignature = signature; + workingSlot = clonePriceSlot(selectedSourceSlot); + savedSlot = clonePriceSlot(selectedSourceSlot); + productCodeSearch = ''; + }); + + function getBaseProducts(): PriceSlotProduct[] { + return currentSlot.products; + } + function getBasePrice(product: PriceSlotProduct): number | null { const cells = basePriceCells[product.product_code]; if (cells?.length > 0) { @@ -165,6 +183,10 @@ }; } + function clonePriceSlot(slot: PriceSlot): PriceSlot { + return JSON.parse(JSON.stringify(slot)); + } + function calculateAdjustedPrice(basePrice: number | null): number | null { if (basePrice === null) return null; @@ -183,23 +205,6 @@ return Math.max(0, Math.round(nextPrice)); } - async function loadBasePrices() { - const productCodes = mockProducts.map((product) => product.product_code); - if (productCodes.length === 0) return; - - const socket = await waitForOpenSocket(); - if (!socket) { - addNotification('WARN:WebSocket not connected. Using local base price sample.'); - return; - } - - clearSheetPriceSentTypes(); - const sent = requestSheetPrice(selectedCountry, productCodes); - if (!sent) { - addNotification('ERR:Failed to request base prices'); - } - } - function applyCreateTemplate() { const value = Number(adjustmentValue); const formattedValue = Number.isNaN(value) ? 0 : value; @@ -221,9 +226,15 @@ } function createPriceSlotFromBase() { - const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1; + const baseProducts = getBaseProducts(); + if (baseProducts.length === 0) { + addNotification('WARN:No backend PriceSlot data loaded'); + return; + } - const products = mockProducts.map((product) => ({ + const nextSlotNumber = Math.max(0, ...displaySlots.map((slot) => slot.slot)) + 1; + + const products = baseProducts.map((product) => ({ ...product, price: calculateAdjustedPrice(getBasePrice(product)) })); @@ -232,12 +243,16 @@ slot: nextSlotNumber, name: createName.trim() || `PriceSlot${nextSlotNumber}`, description: createDescription.trim(), + kind: 'price', + header: currentSlot.header, products }; - slots = [...slots, nextSlot]; - savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)]; + localSlots = [...localSlots, nextSlot]; selectedSlot = nextSlotNumber; + workingSlot = clonePriceSlot(nextSlot); + savedSlot = clonePriceSlot(nextSlot); + lastLoadedSlotSignature = JSON.stringify(nextSlot); createDialogOpen = false; addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`); } @@ -245,56 +260,160 @@ function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number { if (!saved) return 0; - return current.products.filter((product) => { - const savedProduct = saved.products.find( - (item) => item.product_code === product.product_code - ); + return current.products.filter((product, index) => { + const savedProduct = + saved.products.find( + (item) => product.row_index !== undefined && item.row_index === product.row_index + ) ?? + saved.products[index] ?? + saved.products.find((item) => item.product_code === product.product_code); return savedProduct?.price !== product.price; }).length; } + function countChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined): number { + if (!saved || current.kind !== 'service') return 0; + + return (current.serviceRows ?? []).filter((row, index) => { + const savedRow = + saved.serviceRows?.find((item) => item.row_index === row.row_index) ?? + saved.serviceRows?.[index]; + return JSON.stringify(row.cells) !== JSON.stringify(savedRow?.cells ?? {}); + }).length; + } + function updateSlotField(field: 'name' | 'description', value: string) { - slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot)); + if (!workingSlot) return; + workingSlot = { ...workingSlot, [field]: value }; } function updateProductPrice(productCode: string, value: string) { const price = value === '' ? null : Number(value); - slots = slots.map((slot) => { - if (slot.slot !== selectedSlot) return slot; + if (!workingSlot) return; - return { - ...slot, - products: slot.products.map((product) => - product.product_code === productCode - ? { ...product, price: Number.isNaN(price) ? product.price : price } - : product - ) - }; - }); + workingSlot = { + ...workingSlot, + products: workingSlot.products.map((product) => + product.product_code === productCode + ? { ...product, price: Number.isNaN(price) ? product.price : price } + : product + ) + }; + } + + function updateServiceCell( + row: PriceSlotServiceRow, + fallbackIndex: number, + columnName: string, + value: string + ) { + if (!workingSlot) return; + + workingSlot = { + ...workingSlot, + serviceRows: (workingSlot.serviceRows ?? []).map((serviceRow, index) => { + const sameRow = + row.row_index !== undefined + ? serviceRow.row_index === row.row_index + : serviceRow === row || index === fallbackIndex; + return sameRow + ? { + ...serviceRow, + cells: { + ...serviceRow.cells, + [columnName]: value + } + } + : serviceRow; + }) + }; + } + + function getServiceColumnClass(columnName: string) { + const normalized = columnName.toLowerCase(); + if (normalized === 'value') return 'min-w-[320px]'; + if (normalized === 'desc') return 'min-w-[300px]'; + if (normalized === 'l') return 'min-w-[360px]'; + if (normalized.includes('schedule')) return 'min-w-[300px]'; + if (normalized.includes('discount')) return 'min-w-[180px]'; + if (normalized === 'type/key') return 'min-w-[150px]'; + if (normalized === 'servicetype') return 'min-w-[130px]'; + if (normalized === 'daytype') return 'min-w-[130px]'; + if (normalized === 'command') return 'min-w-[130px]'; + if (normalized === 'extendvalue') return 'min-w-[130px]'; + if (normalized === 'time(24 hr)' || normalized === 'time( 24 hr)') return 'min-w-[130px]'; + if (['year', 'month', 'day'].includes(normalized)) return 'min-w-[110px]'; + return 'min-w-[120px]'; + } + + function getServiceInputClass(columnName: string) { + const normalized = columnName.toLowerCase(); + const textClass = normalized === 'l' || normalized.includes('schedule') ? '' : 'font-mono'; + return ['h-10 w-full min-w-0 text-sm', textClass].filter(Boolean).join(' '); + } + + function getServiceTableMinWidth() { + return serviceHeaders.reduce((total, header) => { + const normalized = header.toLowerCase(); + if (normalized === 'value') return total + 320; + if (normalized === 'desc') return total + 300; + if (normalized === 'l') return total + 360; + if (normalized.includes('schedule')) return total + 300; + if (normalized.includes('discount')) return total + 180; + if (['type/key', 'servicetype', 'daytype', 'command', 'extendvalue'].includes(normalized)) { + return total + 140; + } + return total + 120; + }, 0); } function resetSlot() { - const savedSlot = savedSnapshot[selectedSlot - 1]; if (!savedSlot) return; - slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot)); + workingSlot = clonePriceSlot(savedSlot); addNotification(`INFO:Reset PriceSlot${selectedSlot}`); } function saveSlot() { - savedSnapshot = savedSnapshot.map((slot) => - slot.slot === selectedSlot ? structuredClone(currentSlot) : slot + if (!currentSlot.slot) { + addNotification('WARN:No PriceSlot selected'); + return; + } + + const sent = updatePriceSlot(selectedCountry, currentSlot); + if (!sent) { + addNotification('ERR:Failed to send PriceSlot update'); + return; + } + + savedSlot = clonePriceSlot(currentSlot); + localSlots = localSlots.map((slot) => + slot.slot === selectedSlot ? clonePriceSlot(currentSlot) : slot ); - addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.'); + addNotification(`INFO:PriceSlot${selectedSlot} update sent`); } - function loadPriceSlots() { - loading = true; - setTimeout(() => { - loading = false; - addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.'); - }, 250); + async function loadPriceSlots() { + localSlots = []; + workingSlot = null; + savedSlot = null; + selectedSlot = 0; + priceSlotsLoading.set(true); + priceSlotsError.set(null); + + const socket = await waitForOpenSocket(); + if (!socket) { + priceSlotsLoading.set(false); + addNotification('ERR:WebSocket not connected'); + return; + } + + const sent = requestPriceSlots(selectedCountry); + if (!sent) { + priceSlotsLoading.set(false); + addNotification('ERR:Failed to request PriceSlot data'); + } } @@ -348,144 +467,233 @@ -

+ +
+
- PriceSlot{slot.slot} - - {/each} -
- -
-
-
-
-

PriceSlot{selectedSlot}

- - {hasChanges ? `${changedCount} changes` : 'No changes'} - +
+
+

+ {currentSlot.slot ? `PriceSlot${selectedSlot}` : 'No PriceSlot'} +

+ + {hasChanges ? `${totalChangedCount} changes` : 'No changes'} + +
+
- -
-
- - updateSlotField('name', event.currentTarget.value)} - /> -
- -
- - updateSlotField('description', event.currentTarget.value)} - /> -
- -
- - - -
-
-
- -
-
-
- -
- +
+ updateSlotField('name', event.currentTarget.value)} />
+ +
+ + updateSlotField('description', event.currentTarget.value)} + /> +
+ +
+
+
+ + + +
+

+ Reset discards unsaved changes for the selected PriceSlot. +

+
+
-

- Showing {filteredProducts.length} of {currentSlot.products.length} products -

-
- - - - ProductCode - ProductName [{selectedCountryLanguage}] - ProductNameEng - Price - - - - {#each filteredProducts as product (product.product_code)} - {@const productNames = getProductNames(product)} - - - {product.product_code} - - {productNames.local} - {productNames.english} - - - updateProductPrice(product.product_code, event.currentTarget.value)} - /> - - - {/each} - {#if filteredProducts.length === 0} - - - No product code found. - - - {/if} - - +
+
+
+ +
+ + +
+
+

+ Showing {visibleRowCount} of {totalRowCount} + {isServiceSlot ? 'service rows' : 'products'} +

+
+ +
+ {#if loading} +
+ Loading PriceSlot data... +
+ {:else if $priceSlotsError} +
+ {$priceSlotsError} +
+ {:else if displaySlots.length === 0} +
+ No PriceSlot data from backend. +
+ {:else if isServiceSlot} +
+ + + + {#each serviceHeaders as header} + {header} + {/each} + + + + {#each filteredServiceRows as row, index (`${row.row_index ?? index}-${index}`)} + + {#each serviceHeaders as header} + + + updateServiceCell(row, index, header, event.currentTarget.value)} + /> + + {/each} + + {/each} + {#if filteredServiceRows.length === 0} + + + No service rows found. + + + {/if} + + +
+ {:else} +
+ + + + ProductCode + ProductName [{selectedCountryLanguage}] + ProductNameEng + Price + + + + {#each filteredProducts as product, index (`${product.product_code}-${product.row_index ?? index}`)} + {@const productNames = getProductNames(product)} + + + {product.product_code} + + {productNames.local} + {productNames.english} + + + updateProductPrice(product.product_code, event.currentTarget.value)} + /> + + + {/each} + {#if filteredProducts.length === 0} + + + No product code found. + + + {/if} + + +
+ {/if} +
@@ -499,19 +707,6 @@
- -
From a95e7bbb13170c789e85a4cebbf45d8170977d60 Mon Sep 17 00:00:00 2001 From: "pakintada@gmail.com" Date: Tue, 16 Jun 2026 15:15:34 +0700 Subject: [PATCH 3/7] update deps & hotfix errors Signed-off-by: pakintada@gmail.com --- bun.lockb | Bin 198147 -> 199988 bytes package.json | 2 +- src/lib/components/dashboard-quick-adb.svelte | 24 ++-- src/lib/core/handlers/adbPayloadHandler.ts | 20 +-- .../add/[country]/[catalog]/+page.svelte | 8 +- .../edit/[country]/[catalog]/+page.svelte | 129 ++++++++++++------ src/routes/(authed)/tools/brew/+page.svelte | 12 +- 7 files changed, 124 insertions(+), 71 deletions(-) diff --git a/bun.lockb b/bun.lockb index ce79e9c42e9864bf4221857039a6f68caab2fcf4..73c25fe62467b27a4ea7bd1772a47f42c49b42eb 100755 GIT binary patch delta 13652 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 selectCode(item.code)} - class="flex w-full items-center justify-between rounded-lg border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5 {item.isNew ? 'border-green-500/50 bg-green-500/5' : ''}" + class="flex w-full items-center justify-between rounded-lg border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-primary/5 {item.isNew + ? 'border-green-500/50 bg-green-500/5' + : ''}" >
{item.code} {#if item.isNew} - NEW + NEW {/if}
{#if item.name} diff --git a/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte b/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte index d8faf31..92b7eb0 100644 --- a/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte +++ b/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte @@ -158,7 +158,8 @@ const primaryLanguageKey = columnConfig.primaryLanguage; const primaryLanguageColumn = languageColumnMap[primaryLanguageKey] ?? languageColumnMap.en ?? 3; const secondaryLanguageKey = primaryLanguageKey === 'en' ? 'th' : 'en'; - const secondaryLanguageColumn = languageColumnMap[secondaryLanguageKey] ?? languageColumnMap.en ?? 3; + const secondaryLanguageColumn = + languageColumnMap[secondaryLanguageKey] ?? languageColumnMap.en ?? 3; function getCatalogDisplayName(catalogName: string): string { const match = catalogName.match(/page_catalog_group_(\w+)\.skt/); @@ -797,7 +798,11 @@ } // Get all prices for an item (hot, cold, blend) - function getItemPrices(item: any): { hot: string | null; cold: string | null; blend: string | null } { + function getItemPrices(item: any): { + hot: string | null; + cold: string | null; + blend: string | null; + } { return { hot: getItemPrice(item, 'hot'), cold: getItemPrice(item, 'cold'), @@ -807,18 +812,23 @@ // Price edit dialog state let priceEditDialogOpen = $state(false); - let priceEditData = $state<{ code: string; type: string; price: string; row: number; col: number; isNew: boolean }[]>([]); + let priceEditData = $state< + { code: string; type: string; price: string; row: number; col: number; isNew: boolean }[] + >([]); let savingPrice = $state(false); // Get price info for a single product code - function getPriceInfoForCode(productCode: string): { price: string; row: number; col: number } | null { + function getPriceInfoForCode( + productCode: string + ): { price: string; row: number; col: number } | null { const priceCells = sheetPrices[productCode]; if (!priceCells || priceCells.length === 0) return null; const headers = get(sheetPriceHeader)[countryCode]; if (!headers || headers.length === 0) return null; - const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default; + const headerNames = + PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default; const colIdx = findHeaderIndex(headers, headerNames.cash_price); if (colIdx < 0) return null; @@ -848,7 +858,14 @@ function openPriceEditDialog() { if (!editingItem) return; - const data: { code: string; type: string; price: string; row: number; col: number; isNew: boolean }[] = []; + const data: { + code: string; + type: string; + price: string; + row: number; + col: number; + isNew: boolean; + }[] = []; for (const code of editingItem.product_codes || []) { const info = getPriceInfoForCode(code); @@ -869,7 +886,10 @@ // Save price changes from dialog async function handleSavePricesFromDialog() { - const updates: { row_index: number; cells: { value: string; coord: { row: number; col: number } }[] }[] = []; + const updates: { + row_index: number; + cells: { value: string; coord: { row: number; col: number } }[]; + }[] = []; const newPrices: { cells: string[] }[] = []; // Get all rows data for duplicate handling @@ -877,7 +897,8 @@ // Get header for this country to build cells array correctly const priceHeaders = get(sheetPriceHeader)[countryCode] || []; - const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default; + const headerNames = + PRICE_HEADER_NAMES_BY_COUNTRY[countryCode] || PRICE_HEADER_NAMES_BY_COUNTRY.default; const priceColIdx = findHeaderIndex(priceHeaders, headerNames.cash_price); for (const item of priceEditData) { @@ -887,7 +908,7 @@ if (item.price && item.price.trim()) { // Build cells array based on header structure // Find column indices from header - const nameColIdx = priceHeaders.findIndex(h => h.toLowerCase() === 'name') + 1; + const nameColIdx = priceHeaders.findIndex((h) => h.toLowerCase() === 'name') + 1; // Create cells array with correct length const cells: string[] = new Array(priceHeaders.length).fill(''); @@ -936,20 +957,24 @@ for (const rowEntry of rowsForCode) { updates.push({ row_index: rowEntry.row, - cells: [{ - value: item.price, - coord: { row: rowEntry.row, col: item.col } - }] + cells: [ + { + value: item.price, + coord: { row: rowEntry.row, col: item.col } + } + ] }); } } else if (item.row) { // Single row - use the original logic updates.push({ row_index: item.row, - cells: [{ - value: item.price, - coord: { row: item.row, col: item.col } - }] + cells: [ + { + value: item.price, + coord: { row: item.row, col: item.col } + } + ] }); } } @@ -966,12 +991,12 @@ // Send updates for existing prices if (updates.length > 0) { - updateSent = updateSheetPrice(country, updates); + updateSent = await updateSheetPrice(country, updates); } // Send adds for new prices if (newPrices.length > 0) { - addSent = addSheetPrice(country, newPrices); + addSent = await addSheetPrice(country, newPrices); } if (updateSent && addSent) { @@ -1457,8 +1482,8 @@ // Step 2: Request menu data (this triggers streaming) // Small delay to ensure enter request is processed first - setTimeout(() => { - const requested = requestCatalogMenu(country, catalog); + setTimeout(async () => { + const requested = await requestCatalogMenu(country, catalog); if (requested) { console.log('[Edit] Requested menu data via WebSocket'); } else { @@ -1653,7 +1678,11 @@ recipe: recipe01_query })); - console.log('[Edit] Loaded', Object.keys(recipe01_query).length, 'recipes from machine'); + console.log( + '[Edit] Loaded', + Object.keys(recipe01_query).length, + 'recipes from machine' + ); break; } } catch (parseError) { @@ -1965,13 +1994,21 @@
{#if displayPrices.hot} - Hot {displayPrices.hot} + Hot {displayPrices.hot} {/if} {#if displayPrices.cold} - Cold {displayPrices.cold} + Cold + {displayPrices.cold} {/if} {#if displayPrices.blend} - Blend {displayPrices.blend} + Blend + {displayPrices.blend} {/if}
+ + + +{#if isOpen} + +{/if} + + + + + + +{#if expandedOutput} + +
(expandedOutput = null)} + role="presentation" + > + +
e.stopPropagation()} + role="dialog" + aria-label="Command full output" + > + +
+
+ Output for: + $ {expandedOutput.command} +
+
+ {expandedOutput.lines} line{expandedOutput.lines !== 1 ? 's' : ''} + +
+
+ + +
+ {#if expandedOutput.fullOutput} +
{expandedOutput.fullOutput}
+ {:else} +

(no output)

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

No command history yet

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

Select a command from the left panel

+

to view its full output here

+
+ {/if} +
+
+
+
+{/if} diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 2cab7d5..c4d45e5 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -11,6 +11,7 @@ import { Consumable, MaybeConsumable, ReadableStream } from '@yume-chan/stream-e import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy'; import { addNotification } from '../stores/noti'; import { handleAdbPayload } from '../handlers/adbPayloadHandler'; +import { GlobalEventBus } from '../utils/eventBus'; import { adbWriter } from '../stores/adbWriter'; import { WritableStream } from '@yume-chan/stream-extra'; import { env } from '$env/dynamic/public'; @@ -362,6 +363,83 @@ export async function executeCmd(command: string) { } } +/** + * Execute an ADB command and stream its output via callbacks. + * Used for commands like `logcat` that run indefinitely. + * + * Returns a cleanup function that can be called to abort the stream. + */ +export async function executeStreamingCmd( + command: string, + callbacks: { + onData?: (chunk: string) => void; + onError?: (error: string) => void; + onExit?: (exitCode: number | undefined) => void; + } +): Promise<() => void> { + const instance = getAdbInstance(); + let aborted = false; + + if (!instance) { + callbacks.onError?.('No ADB device connected'); + return () => {}; + } + + try { + // NOTE: Always use noneProtocol.spawn() for streaming. + // shellProtocol.spawnWaitText() waits for the process to exit — fine for + // batch commands like 'echo foo', but logcat runs indefinitely, so the + // Promise would never resolve and onData would never fire. + const process = await instance.subprocess.noneProtocol.spawn(command); + + const reader = process.output.getReader(); + const decoder = new TextDecoder(); + + // Start reading in the background + (async () => { + try { + while (!aborted) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + if (text && !aborted) { + callbacks.onData?.(text); + } + } + } catch (e: any) { + if (!aborted) { + callbacks.onError?.(e.message ?? 'Stream read error'); + } + } finally { + if (!aborted) { + reader.releaseLock(); + callbacks.onExit?.(0); + } + } + })(); + + // Return cleanup function to abort the stream + return () => { + aborted = true; + try { + reader.cancel(); + } catch { + // reader may already be done + } + }; + } catch (e: any) { + if (!aborted) { + if (e.message?.includes('ExactReadable ended')) { + callbacks.onError?.('Connection closed'); + } else { + callbacks.onError?.(e.message ?? 'Unknown error'); + } + } + } + + return () => {}; +} + export async function disconnect() { let instance = getAdbInstance(); if (instance) { @@ -526,12 +604,32 @@ async function connectToAndroidServer(maxRetries = 5) { if (writer) { addNotification('INFO:Enable Brewing Mode T on machine'); + const textDecoder = new TextDecoder(); + let buffer = ''; + (async () => { try { while (true) { const { value, done } = await reader.read(); if (done) break; - handleAdbPayload(new TextDecoder().decode(value)); + + // decode chunk + buffer += textDecoder.decode(value, { stream: true }); + + let lines = buffer.split('\n'); + + // save potential incomplete + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.trim() === '') continue; + + GlobalEventBus.emit('adb:raw-payload', line); + handleAdbPayload(line); + } + + // GlobalEventBus.emit('adb:raw-payload', new TextDecoder().decode(value)); + // handleAdbPayload(new TextDecoder().decode(value)); } } catch (e) { console.error('read error', e); @@ -637,6 +735,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO const trimmedMessage = message.trim(); if (trimmedMessage) { console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200)); + GlobalEventBus.emit('adb:raw-payload', trimmedMessage); handleAdbPayload(trimmedMessage); } } @@ -644,6 +743,7 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO const remainingMessage = messageBuffer.trim(); if (remainingMessage) { + GlobalEventBus.emit('adb:raw-payload', remainingMessage); handleAdbPayload(remainingMessage); } } catch (e) { diff --git a/src/lib/core/adb/adbTerminal.ts b/src/lib/core/adb/adbTerminal.ts new file mode 100644 index 0000000..d665f99 --- /dev/null +++ b/src/lib/core/adb/adbTerminal.ts @@ -0,0 +1,1151 @@ +import { get } from 'svelte/store'; +import { getAdbInstance, executeCmd, executeStreamingCmd } from './adb'; +import type { Terminal } from '@xterm/xterm'; +import { GlobalEventBus } from '../utils/eventBus'; +import { addNotification } from '../stores/noti'; +import { sendToAndroid, adbWriter } from '../stores/adbWriter'; + +/** + * ADB Terminal Service + * + * Bridges an xterm.js Terminal with the ADB connection. + * Supports: + * - Running raw adb shell commands + * - Built-in commands (!help, !payload {filter}, !cls, !exit, send, !status) + * - Real-time payload log viewing via !payload + * - Interactive ADB TCP socket payload sending via send command + */ + +export type LogEntry = { + timestamp: Date; + level: string; + message: string; + raw: string; +}; + +export type PayloadSubscriptionCallback = (payload: any) => void; + +/** + * A recorded command output for the output-blocks display. + */ +export type CommandOutput = { + id: number; + command: string; + fullOutput: string; + lines: number; + level: 'info' | 'error' | 'success'; + timestamp: number; +}; + +/** + * Callback invoked when a new command output is available. + * Set by terminal-drawer.svelte to reactively show output blocks. + */ +let _onCommandOutput: ((output: CommandOutput) => void) | null = null; +let _onExpandOutputRequest: ((id: number) => void) | null = null; + +let _outputIdCounter = 0; +let _commandOutputs: CommandOutput[] = []; + +/** + * Register a listener for new command outputs. + * Returns an unsubscribe function. + */ +export function onCommandOutput(cb: (output: CommandOutput) => void): () => void { + _onCommandOutput = cb; + return () => { + _onCommandOutput = null; + }; +} + +/** + * Register a listener for expand-output click requests from the terminal. + * Returns an unsubscribe function. + */ +export function onExpandOutputRequest(cb: (id: number) => void): () => void { + _onExpandOutputRequest = cb; + return () => { + _onExpandOutputRequest = null; + }; +} + +/** + * Get the current list of command outputs. + */ +export function getCommandOutputs(): CommandOutput[] { + return _commandOutputs; +} + +/** + * Clear all stored command outputs. + */ +export function clearCommandOutputs() { + _commandOutputs = []; +} + +/** + * Record a command output and notify listeners. + * Returns the recorded output. + */ +export function recordCommandOutput(cmd: string, fullOutput: string, level: CommandOutput['level'] = 'info') { + const id = ++_outputIdCounter; + const lines = fullOutput ? fullOutput.split('\n').filter(l => l.trim()).length : 0; + const output: CommandOutput = { + id, + command: cmd, + fullOutput, + lines, + level, + timestamp: Date.now() + }; + _lastOutputId = id; + _commandOutputs.push(output); + if (_commandOutputs.length > MAX_OUTPUT_BLOCKS) { + _commandOutputs.shift(); + } + _onCommandOutput?.(output); + return output; +} + +let _lastOutputId = 0; + +/** + * Get the most recent command output ID (for terminal link click expansion). + */ +export function getLastOutputId(): number { + return _lastOutputId; +} + +/** + * Trigger expand of a specific output (called from terminal-drawer link handler). + */ +export function requestExpandOutput(id: number) { + _onExpandOutputRequest?.(id); +} + +const COMMAND_HISTORY_KEY = 'supra:adb_terminal_history'; +const MAX_HISTORY = 200; + +let _terminal: Terminal | null = null; +let _commandHistory: string[] = []; +let _historyIndex = -1; +let _isPayloadMode = false; +let _payloadFilter = ''; +let _payloadUnsubscribe: (() => void) | null = null; +let _payloadLogBuffer: string[] = []; + +let _isLogcatMode = false; +let _logcatCleanup: (() => void) | null = null; +let _logcatBuffer: string[] = []; +const LOGCAT_MAX_LINES = 5000; // keep last 5000 lines in buffer +const LOGCAT_VISIBLE_LINES = 3; // show only 3 lines to terminal +const MAX_OUTPUT_BLOCKS = 50; // keep last 50 command outputs +const VISIBLE_OUTPUT_LINES = 3; // lines shown per command in terminal + +/** + * Load command history from localStorage + */ +function loadHistory(): string[] { + try { + const saved = localStorage.getItem(COMMAND_HISTORY_KEY); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } +} + +/** + * Save a command to history + */ +function saveToHistory(cmd: string) { + if (!cmd.trim()) return; + _commandHistory = _commandHistory.filter((c) => c !== cmd); + _commandHistory.push(cmd); + if (_commandHistory.length > MAX_HISTORY) { + _commandHistory = _commandHistory.slice(-MAX_HISTORY); + } + try { + localStorage.setItem(COMMAND_HISTORY_KEY, JSON.stringify(_commandHistory)); + } catch { + // ignore storage errors + } + _historyIndex = _commandHistory.length; +} + +/** + * Initialize the terminal session + */ +export function initTerminalSession(terminal: Terminal) { + _terminal = terminal; + _commandHistory = loadHistory(); + _historyIndex = _commandHistory.length; + _isPayloadMode = false; + + // Write welcome banner + const instance = getAdbInstance(); + const deviceStatus = instance ? 'Connected' : 'Disconnected'; + + terminal.writeln('\x1b[36m╔══════════════════════════════════════════════════════╗\x1b[0m'); + terminal.writeln( + '\x1b[36m║ \x1b[1mSupra ADB Terminal v1.0\x1b[22m ║\x1b[0m' + ); + terminal.writeln('\x1b[36m╠══════════════════════════════════════════════════════╣\x1b[0m'); + terminal.writeln(`\x1b[36m║ Device: \x1b[33m${deviceStatus.padEnd(43)}\x1b[36m║\x1b[0m`); + terminal.writeln( + `\x1b[36m║ Type \x1b[32m!help\x1b[0m\x1b[36m for available commands ║\x1b[0m` + ); + terminal.writeln('\x1b[36m╚══════════════════════════════════════════════════════╝\x1b[0m'); + terminal.write('\r\n'); + + prompt(terminal); +} + +/** + * Re-initialize terminal after drawer reopen (terminal element stays mounted, + * but we need to re-fit and re-attach event handlers). + */ +export function reinitTerminalSession(terminal: Terminal) { + if (_terminal !== terminal) { + // If it's a completely new terminal instance, do full init + initTerminalSession(terminal); + return; + } + // Terminal is same instance — just show appropriate prompt + if (_isLogcatMode) { + terminal.writeln('\x1b[90m[Logcat streaming active]\x1b[0m'); + prompt(terminal); + } else if (_isPayloadMode) { + // Re-show payload header + const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : ''; + terminal.writeln(`\x1b[90m[Payload monitoring active${filterInfo}]\x1b[0m`); + prompt(terminal); + } else { + prompt(terminal); + } +} + +/** + * Write a prompt to the terminal + */ +function prompt(terminal: Terminal) { + if (_isLogcatMode) { + terminal.write(`\r\n\x1b[33mlogcat\x1b[0m $ `); + } else if (_isPayloadMode) { + const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : ''; + terminal.write(`\r\n\x1b[32mpayload${filterInfo}\x1b[0m $ `); + } else { + terminal.write(`\r\n\x1b[31m$\x1b[0m `); + } +} + +/** + * Handle terminal data input (keypresses/input from xterm onData) + */ +export function handleTerminalData(data: string) { + if (!_terminal) return; + + const terminal = _terminal; + + // Handle special keys + switch (data) { + case '\r': // Enter + handleEnter(terminal); + return; + case '\u007f': // Backspace + handleBackspace(terminal); + return; + case '\u001b[A': // Up arrow + handleHistoryUp(terminal); + return; + case '\u001b[B': // Down arrow + handleHistoryDown(terminal); + return; + case '\u0003': // Ctrl+C + handleCtrlC(terminal); + return; + case '\u0001': // Ctrl+A — beginning of line + terminal.write(`\x1b[${_cursorPos}D`); + _cursorPos = 0; + return; + case '\u0005': // Ctrl+E — end of line + { + const charsToEnd = _currentInput.length - _cursorPos; + if (charsToEnd > 0) { + terminal.write(`\x1b[${charsToEnd}C`); + _cursorPos = _currentInput.length; + } + } + return; + case '\u001b[D': // Left arrow + handleCursorLeft(terminal); + return; + case '\u001b[C': // Right arrow + handleCursorRight(terminal); + return; + case '\t': // Tab + // Ignore tab + return; + default: + // Only write printable characters + if (data.length === 1 && data >= ' ') { + // If in payload mode or logcat mode, stop it when user types a new command + if (_isPayloadMode && data.trim()) { + stopPayloadMode(terminal); + } + if (_isLogcatMode && data.trim()) { + stopLogcatMode(terminal); + } + // Insert character at cursor position and redraw + _currentInput = _currentInput.slice(0, _cursorPos) + data + _currentInput.slice(_cursorPos); + _cursorPos++; + redrawInputLine(terminal); + } + return; + } +} + +let _currentInput = ''; +let _cursorPos = 0; + +/** + * Redraw the full input line (prompt + current input) and position the cursor. + */ +function redrawInputLine(terminal: Terminal) { + terminal.write('\r\x1b[K'); + if (_isLogcatMode) { + terminal.write('\x1b[33mlogcat\x1b[0m $ '); + } else if (_isPayloadMode) { + const filterInfo = _payloadFilter ? ` filter="${_payloadFilter}"` : ''; + terminal.write(`\x1b[32mpayload${filterInfo}\x1b[0m $ `); + } else { + terminal.write('\x1b[31m$\x1b[0m '); + } + terminal.write(_currentInput); + const back = _currentInput.length - _cursorPos; + if (back > 0) { + terminal.write(`\x1b[${back}D`); + } +} + +function handleCursorLeft(terminal: Terminal) { + if (_cursorPos > 0) { + _cursorPos--; + terminal.write('\x1b[D'); + } +} + +function handleCursorRight(terminal: Terminal) { + if (_cursorPos < _currentInput.length) { + _cursorPos++; + terminal.write('\x1b[C'); + } +} + +function handleEnter(terminal: Terminal) { + // Get the current line content from xterm + const line = _currentInput.trim(); + _currentInput = ''; + _cursorPos = 0; + + terminal.write('\r\n'); + + if (_isLogcatMode) { + // In logcat mode, empty Enter = stop logcat, text = execute command + if (!line) { + stopLogcatMode(terminal); + } else { + // Stop logcat, then execute the command + stopLogcatMode(terminal); + executeLine(terminal, line); + } + return; + } + + if (_isPayloadMode) { + // In payload mode, treat Enter as continuing to watch + // If user typed something, exit payload mode and execute + if (line) { + stopPayloadMode(terminal); + executeLine(terminal, line); + } else { + prompt(terminal); + } + return; + } + + executeLine(terminal, line); +} + +function handleBackspace(terminal: Terminal) { + // Delete character before cursor + if (_cursorPos > 0 && _currentInput.length > 0) { + _currentInput = _currentInput.slice(0, _cursorPos - 1) + _currentInput.slice(_cursorPos); + _cursorPos--; + redrawInputLine(terminal); + } +} + +function handleHistoryUp(terminal: Terminal) { + if (_commandHistory.length === 0) return; + if (_historyIndex <= 0) return; + + _historyIndex--; + _currentInput = _commandHistory[_historyIndex]; + _cursorPos = _currentInput.length; + redrawInputLine(terminal); +} + +function handleHistoryDown(terminal: Terminal) { + if (_historyIndex >= _commandHistory.length - 1) { + _historyIndex = _commandHistory.length; + _currentInput = ''; + _cursorPos = 0; + redrawInputLine(terminal); + return; + } + + _historyIndex++; + _currentInput = _commandHistory[_historyIndex]; + _cursorPos = _currentInput.length; + redrawInputLine(terminal); +} + +function handleCtrlC(terminal: Terminal) { + terminal.write('^C\r\n'); + if (_isLogcatMode) { + stopLogcatMode(terminal); + return; + } + if (_isPayloadMode) { + stopPayloadMode(terminal); + } + _currentInput = ''; + _cursorPos = 0; + prompt(terminal); +} + +function clearCurrentLine(terminal: Terminal) { + // Move to beginning of line and clear + terminal.write('\r\x1b[K'); +} + +/** + * Update the current input buffer (called from onKey) + * + * @deprecated This function does not support cursor position. Prefer handleTerminalData. + */ +export function updateCurrentInput(key: string) { + if (key.length === 1 && key >= ' ') { + _currentInput += key; + _cursorPos = _currentInput.length; + } +} + +/** + * Execute a command line + */ +async function executeLine(terminal: Terminal, line: string) { + if (!line.trim()) { + prompt(terminal); + return; + } + + saveToHistory(line); + + // Check for built-in commands (prefixed with !) + if (line.startsWith('!')) { + await handleBuiltInCommand(terminal, line); + return; + } + + // Check for non-prefixed built-in commands + const firstWord = line.split(' ')[0].toLowerCase(); + if (firstWord === 'send') { + await handleSendCommand(terminal, line); + return; + } + + // Regular adb shell command + await executeAdbCommand(terminal, line); +} + +/** + * Handle built-in commands (prefixed with !) + */ +async function handleBuiltInCommand(terminal: Terminal, cmd: string) { + const parts = cmd.split(' '); + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + + switch (command) { + case '!help': + showHelp(terminal); + prompt(terminal); + break; + + case '!cls': + case '!clear': + terminal.clear(); + prompt(terminal); + break; + + case '!exit': + terminal.writeln('\x1b[33mClosing terminal...\x1b[0m'); + if (_isPayloadMode) stopPayloadMode(terminal); + // Close the drawer + const { closeTerminalDrawer } = await import('../stores/terminalDrawer'); + closeTerminalDrawer(); + break; + + case '!payload': + case '!logs': + startPayloadMode(terminal, args.join(' ')); + break; + + case '!status': + case '!info': + showDeviceStatus(terminal); + prompt(terminal); + break; + + case '!history': + showHistory(terminal); + prompt(terminal); + break; + + case '!logcat': + await handleLogcatCommand(terminal, cmd); + // Don't call prompt here — handleLogcatCommand manages its own prompt + break; + + default: + terminal.writeln(`\x1b[31mUnknown command: ${command}\x1b[0m`); + terminal.writeln(`Type \x1b[32m!help\x1b[0m for available commands`); + prompt(terminal); + } +} + +/** + * Format ADB shell command output for readable display. + * + * 1. Trims ALL leading and trailing whitespace from each line — raw ADB + * output often contains varying leading tabs that accumulate per line + * and make the display unreadable. + * 2. Replaces internal tabs with a single space (cleaner than 2 spaces). + * 3. Prepends a consistent 2-space indent for visual separation from + * prompts/commands. + * 4. Strips trailing blank lines. + */ +function formatAdbOutput(output: string): string { + const lines = output.split('\n'); + // Trim trailing empty lines + let end = lines.length; + while (end > 0 && lines[end - 1].trim() === '') end--; + const trimmed = lines.slice(0, end); + if (trimmed.length === 0) return ''; + + // Trim both leading and trailing whitespace, replace internal tabs with + // single space, then add consistent 2-space indent. + const cleaned = trimmed.map((line) => { + const stripped = line.trim().replace(/\t+/g, ' '); + return stripped ? ` ${stripped}` : ''; + }); + + return cleaned.join('\r\n'); +} + +/** + * Show help text + */ +function showHelp(terminal: Terminal) { + terminal.writeln(''); + terminal.writeln('\x1b[1;36m── Supra ADB Terminal Commands ──\x1b[0m'); + terminal.writeln(''); + terminal.writeln('\x1b[33m Built-in:\x1b[0m'); + terminal.writeln(' \x1b[32m!help\x1b[0m Show this help message'); + terminal.writeln(' \x1b[32m!cls\x1b[0m Clear the terminal screen'); + terminal.writeln(' \x1b[32m!clear\x1b[0m Clear the terminal screen'); + terminal.writeln(' \x1b[32m!status\x1b[0m Show device connection status'); + terminal.writeln(' \x1b[32m!history\x1b[0m Show command history'); + terminal.writeln(' \x1b[32m!exit\x1b[0m Close the terminal drawer'); + terminal.writeln(''); + terminal.writeln('\x1b[33m Payload & Send:\x1b[0m'); + terminal.writeln(' \x1b[32m!payload\x1b[0m Start watching ADB payload logs'); + terminal.writeln(' \x1b[32m!payload \x1b[0m Watch logs filtered by type'); + terminal.writeln(' \x1b[32m!logs\x1b[0m Alias for !payload'); + terminal.writeln( + " \x1b[32msend -t -p '' [-w ]\x1b[0m Send a TCP payload to the Android device" + ); + terminal.writeln(' \x1b[90m Example: send -t test -p \'{"p1": "test12"}\'\x1b[0m'); + terminal.writeln( + ' \x1b[90m Flags: -t type -p payload -w timeout in seconds (0=∞, default=30)\x1b[0m' + ); + terminal.writeln(''); + terminal.writeln('\x1b[33m Logcat:\x1b[0m'); + terminal.writeln(' \x1b[32m!logcat\x1b[0m Stream ADB logcat from device'); + terminal.writeln(' \x1b[32m!logcat \x1b[0m Filter logcat output'); + terminal.writeln(' \x1b[90m e.g. !logcat -s MyTag, !logcat *:E\x1b[0m'); + terminal.writeln(''); + terminal.writeln('\x1b[33m ADB Shell:\x1b[0m'); + terminal.writeln( + ' Any command not starting with \x1b[32m!\x1b[0m is sent as adb shell command' + ); + terminal.writeln(' \x1b[90m e.g. "echo hello", "ls -la", "dumpsys battery"\x1b[0m'); + terminal.writeln(''); + terminal.writeln('\x1b[33m Tips:\x1b[0m'); + terminal.writeln(' \x1b[90m ↑/↓ - Navigate command history\x1b[0m'); + terminal.writeln(' \x1b[90m Ctrl+C - Cancel current command / exit payload mode\x1b[0m'); + terminal.writeln(''); +} + +/** + * Show device connection status + */ +function showDeviceStatus(terminal: Terminal) { + const instance = getAdbInstance(); + terminal.writeln(''); + terminal.writeln('\x1b[1;36m── Device Status ──\x1b[0m'); + if (instance) { + terminal.writeln(` \x1b[32m●\x1b[0m Connected`); + terminal.writeln(` Transport: ${instance.transport.constructor.name}`); + try { + terminal.writeln(` Serial: ${(instance.transport as any).serial ?? 'unknown'}`); + } catch { + // serial may not be accessible + } + // Show ADB writer status + const writer = get(adbWriter); + terminal.writeln( + ` TCP Socket: ${writer ? '\x1b[32mActive\x1b[0m' : '\x1b[90mNot connected\x1b[0m'}` + ); + } else { + terminal.writeln(` \x1b[31m●\x1b[0m Disconnected`); + terminal.writeln( + ` \x1b[33m → Use the "Connect" button in the dashboard to connect a device\x1b[0m` + ); + } + terminal.writeln(''); +} + +/** + * Show command history + */ +function showHistory(terminal: Terminal) { + terminal.writeln(''); + terminal.writeln('\x1b[1;36m── Command History ──\x1b[0m'); + if (_commandHistory.length === 0) { + terminal.writeln(' \x1b[90m(no commands yet)\x1b[0m'); + } else { + const start = Math.max(0, _commandHistory.length - 50); + for (let i = start; i < _commandHistory.length; i++) { + const num = (i + 1).toString().padStart(4, ' '); + terminal.writeln(` \x1b[90m${num}\x1b[0m ${_commandHistory[i]}`); + } + } + terminal.writeln(''); +} + +/** + * Parsed send command arguments. + */ +type SendArgs = { + type: string; + payload: string; + rawPayload: string; + timeoutMs: number; // 0 = no timeout +}; + +const DEFAULT_SEND_TIMEOUT_MS = 30_000; + +/** + * Parse argument string with -t -p [-w ] support. + * Handles quoted strings for JSON payloads. + */ +function parseSendArgs(argStr: string): SendArgs { + const result: SendArgs = { + type: '', + payload: '', + rawPayload: '', + timeoutMs: DEFAULT_SEND_TIMEOUT_MS + }; + + // Tokenize respecting single and double quotes + const tokens: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < argStr.length; i++) { + const ch = argStr[i]; + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + if (!inSingle) { + tokens.push(current); + current = ''; + continue; + } + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + if (!inDouble) { + tokens.push(current); + current = ''; + continue; + } + continue; + } + if (ch === ' ' && !inSingle && !inDouble) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + current += ch; + } + if (current) tokens.push(current); + + // Parse tokens for -t, -p, -w flags + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] === '-t' && i + 1 < tokens.length) { + result.type = tokens[++i]; + } else if (tokens[i] === '-p' && i + 1 < tokens.length) { + result.rawPayload = tokens[++i]; + } else if (tokens[i] === '-w' && i + 1 < tokens.length) { + const val = parseInt(tokens[++i], 10); + if (!isNaN(val) && val >= 0) { + result.timeoutMs = val === 0 ? 0 : val * 1000; + } + } + } + + return result; +} + +/** + * Execute the interactive send command. + * + * Syntax: send -t -p '' + * + * Sends a JSON message via the ADB TCP socket and waits for a response event. + */ +async function handleSendCommand(terminal: Terminal, line: string) { + // Extract args after "send" + const args = line.slice('send'.length).trim(); + const parsed = parseSendArgs(args); + + if (!parsed.type) { + terminal.writeln("\x1b[33mUsage: send -t -p '' [-w ]\x1b[0m"); + terminal.writeln('\x1b[90m Example: send -t test -p \'{"p1": "test12"}\' -w 60\x1b[0m'); + prompt(terminal); + return; + } + + if (!parsed.rawPayload) { + terminal.writeln('\x1b[31mError: Missing payload (-p).\x1b[0m'); + terminal.writeln("\x1b[33mUsage: send -t -p '' [-w ]\x1b[0m"); + prompt(terminal); + return; + } + + // Parse the JSON payload + let payloadObj: any; + try { + payloadObj = JSON.parse(parsed.rawPayload); + } catch { + terminal.writeln(`\x1b[31mError: Invalid JSON payload\x1b[0m`); + terminal.writeln(`\x1b[90m Received: ${parsed.rawPayload}\x1b[0m`); + prompt(terminal); + return; + } + + // Build the message + const message = { type: parsed.type, payload: payloadObj }; + + // Show sent confirmation + terminal.writeln(`\x1b[90m→ Sending: ${JSON.stringify(message)}\x1b[0m`); + + // Send via the writer + try { + const sent = await sendToAndroid(message); + if (!sent) { + terminal.writeln(`\x1b[31mError: Failed to send message — no ADB socket connection.\x1b[0m`); + terminal.writeln( + `\x1b[33m Make sure the device is connected and the TCP socket is active.\x1b[0m` + ); + prompt(terminal); + return; + } + } catch (e: any) { + terminal.writeln(`\x1b[31mError sending message: ${e.message ?? 'Unknown error'}\x1b[0m`); + prompt(terminal); + return; + } + + // Wait for a response via the event bus (configurable timeout) + const timeoutSeconds = parsed.timeoutMs === 0 ? '∞' : `${parsed.timeoutMs / 1000}`; + terminal.writeln(`\x1b[90m← Waiting for response (timeout: ${timeoutSeconds}s)...\x1b[0m`); + + const response = await waitForPayloadResponse(parsed.timeoutMs); + + if (response === null) { + terminal.writeln(`\x1b[33m⚠ No response received within timeout.\x1b[0m`); + } else { + terminal.writeln(`\x1b[32m✓ Response received:\x1b[0m`); + terminal.writeln(`\x1b[2m${formatAdbOutput(JSON.stringify(response, null, 2))}\x1b[0m`); + } + + prompt(terminal); +} + +/** + * Wait for a payload response via GlobalEventBus. + * Resolves with the first adb:payload event emitted, or null on timeout. + * Pass 0 for timeoutMs to wait indefinitely. + */ +function waitForPayloadResponse(timeoutMs: number): Promise { + return new Promise((resolve) => { + let timer: ReturnType | undefined; + + if (timeoutMs > 0) { + timer = setTimeout(() => { + unsub(); + resolve(null); + }, timeoutMs); + } + + const unsub = GlobalEventBus.on('adb:payload', (payload: any) => { + if (timer) clearTimeout(timer); + unsub(); + resolve(payload); + }); + }); +} + +/** + * Execute an ADB shell command and display formatted output. + * + * Display layout — user already sees their typed command via xterm local echo, + * so we only show indented output for visual separation: + * + * ← blank line separator + * ← 2-space indent, dim gray + * ← red, if present + * ← yellow, if non-zero + * ← blank line before prompt + * $ ← next prompt + */ +async function executeAdbCommand(terminal: Terminal, cmd: string) { + const instance = getAdbInstance(); + if (!instance) { + const err = 'No ADB device connected.'; + terminal.writeln(`\x1b[31mError: ${err}\x1b[0m`); + terminal.writeln('\x1b[33mPlease connect a device first via the dashboard.\x1b[0m'); + recordCommandOutput(cmd, `Error: ${err}\nPlease connect a device first via the dashboard.`, 'error'); + prompt(terminal); + return; + } + + try { + const result: Record = (await executeCmd(cmd)) ?? {}; + + terminal.writeln(''); + + let fullOutput = ''; + const level: CommandOutput['level'] = result.exitCode && result.exitCode !== 0 ? 'error' : 'info'; + + if (result.output) { + const formatted = formatAdbOutput(String(result.output)); + if (formatted) { + fullOutput = formatted; + const outputLines = formatted.split('\r\n'); + // Show a brief summary in the terminal + terminal.writeln(`\x1b[90m ✓ Result stored (${outputLines.length} line${outputLines.length > 1 ? 's' : ''})\x1b[0m`); + } + } + + if (result.error) { + const errText = String(result.error); + terminal.writeln(`\x1b[31m${errText}\x1b[0m`); + if (fullOutput) fullOutput += '\n' + errText; + else fullOutput = errText; + } + + if (result.exitCode !== undefined && result.exitCode !== 0) { + terminal.writeln(`\x1b[33mExit code: ${result.exitCode}\x1b[0m`); + } + + // Record for output blocks (even if empty) + recordCommandOutput(cmd, fullOutput || '(no output)', level); + + // Auto-popup the output dialog immediately + requestExpandOutput(getLastOutputId()); + } catch (e: any) { + const errMsg = e.message ?? 'Unknown error'; + terminal.writeln(`\x1b[31mError: ${errMsg}\x1b[0m`); + recordCommandOutput(cmd, `Error: ${errMsg}`, 'error'); + requestExpandOutput(getLastOutputId()); + } + + // Blank line before prompt for visual breathing room + terminal.writeln(''); + + prompt(terminal); +} + +/** + * Start payload monitoring mode - shows incoming ADB payloads in real-time + */ +function startPayloadMode(terminal: Terminal, filter: string) { + _isPayloadMode = true; + _payloadFilter = filter; + _payloadLogBuffer = []; + + terminal.writeln(''); + terminal.writeln(`\x1b[1;36m── ADB Payload Monitor ──\x1b[0m`); + if (filter) { + terminal.writeln(`\x1b[33m Filter: ${filter}\x1b[0m`); + } + terminal.writeln('\x1b[90m Watching for incoming ADB payloads...\x1b[0m'); + terminal.writeln('\x1b[90m Press Ctrl+C or type a command to exit\x1b[0m'); + terminal.writeln(''); + + // Subscribe to payload events + _payloadUnsubscribe = GlobalEventBus.on('adb:payload', (payload: any) => { + if (!_terminal) return; + + // Apply filter if set + if (filter && payload.type) { + const filterLower = filter.toLowerCase(); + const typeLower = payload.type.toLowerCase(); + if ( + !typeLower.includes(filterLower) && + !JSON.stringify(payload).toLowerCase().includes(filterLower) + ) { + return; + } + } + + const timestamp = new Date().toLocaleTimeString(); + const type = payload.type ?? 'unknown'; + const payloadStr = payload.payload ? JSON.stringify(payload.payload).slice(0, 500) : ''; + + // Color code by type + let colorPrefix = ''; + switch (type) { + case 'log': + colorPrefix = '\x1b[36m'; // cyan + break; + case 'error': + colorPrefix = '\x1b[31m'; // red + break; + case 'machine': + colorPrefix = '\x1b[33m'; // yellow + break; + case 'response': + colorPrefix = '\x1b[32m'; // green + break; + case 'brew-finish': + colorPrefix = '\x1b[35m'; // magenta + break; + default: + colorPrefix = '\x1b[90m'; // gray + } + + terminal.writeln(`${colorPrefix}[${timestamp}] ${type}:\x1b[0m ${payloadStr}`); + + // Keep buffer bounded + _payloadLogBuffer.push(`[${timestamp}] ${type}: ${payloadStr}`); + if (_payloadLogBuffer.length > 1000) { + _payloadLogBuffer.shift(); + } + }); + + prompt(terminal); +} + +/** + * Stop payload monitoring mode + */ +function stopPayloadMode(terminal: Terminal) { + _isPayloadMode = false; + _payloadFilter = ''; + + if (_payloadUnsubscribe) { + _payloadUnsubscribe(); + _payloadUnsubscribe = null; + } + + terminal.writeln(''); + terminal.writeln('\x1b[33mPayload monitoring stopped.\x1b[0m'); +} + +/** + * Start logcat mode — streams adb logcat output in real-time. + * Syntax: !logcat [filters] + * Filters follow Android logcat syntax: + * !logcat → all logs + * !logcat -s MyTag → only MyTag (silent others) + * !logcat *:E → only errors + * !logcat ActivityManager:I *:S → ActivityManager info+ only + * !logcat -v color → colored output + */ +async function handleLogcatCommand(terminal: Terminal, cmd: string) { + const instance = getAdbInstance(); + if (!instance) { + terminal.writeln('\x1b[31mError: No ADB device connected.\x1b[0m'); + terminal.writeln('\x1b[33mPlease connect a device first via the dashboard.\x1b[0m'); + prompt(terminal); + return; + } + + // Parse filter args from "!logcat" command + // "!logcat -s MyTag" → args = "-s MyTag" + const args = cmd.slice('!logcat'.length).trim(); + const logcatCommand = `logcat -v brief ${args}`; + + _isLogcatMode = true; + _logcatBuffer = []; + + terminal.writeln(''); + terminal.writeln(`\x1b[1;36m── ADB Logcat ──\x1b[0m`); + if (args) { + terminal.writeln(`\x1b[33m Filter: ${args}\x1b[0m`); + } + terminal.writeln('\x1b[90m Streaming from device...\x1b[0m'); + terminal.writeln('\x1b[90m Press Ctrl+C or type Enter (empty) to stop\x1b[0m'); + terminal.writeln(''); + + let lineCount = 0; + + _logcatCleanup = await executeStreamingCmd(logcatCommand, { + onData: (chunk: string) => { + if (!_terminal || !_isLogcatMode) return; + + // Split chunk into lines + const lines = chunk.split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + lineCount++; + + // Prepend timestamp to both buffer and terminal output + // so the history dialog shows timestamps too + const ts = new Date().toLocaleTimeString(); + const tsLine = `[${ts}] ${line}`; + + // Store in buffer (with timestamp) + _logcatBuffer.push(tsLine); + if (_logcatBuffer.length > LOGCAT_MAX_LINES) { + _logcatBuffer.shift(); + } + + // Write to terminal: color the line content (not timestamp) + // Lines look like: "V/MyTag: message" or "E/MyTag: message" + let coloredLine = tsLine; + if (line.includes('/')) { + const level = line.charAt(0).toUpperCase(); + switch (level) { + case 'E': coloredLine = `\x1b[31m${tsLine}\x1b[0m`; break; // red + case 'W': coloredLine = `\x1b[33m${tsLine}\x1b[0m`; break; // yellow + case 'I': coloredLine = `\x1b[36m${tsLine}\x1b[0m`; break; // cyan + case 'D': coloredLine = `\x1b[90m${tsLine}\x1b[0m`; break; // gray + case 'V': coloredLine = `\x1b[2m${tsLine}\x1b[0m`; break; // dim + case 'F': coloredLine = `\x1b[35m${tsLine}\x1b[0m`; break; // magenta (fatal) + } + } + + terminal.writeln(coloredLine); + } + }, + onError: (error: string) => { + if (!_terminal || !_isLogcatMode) return; + terminal.writeln(`\x1b[31mLogcat error: ${error}\x1b[0m`); + }, + onExit: (exitCode: number | undefined) => { + if (!_terminal || !_isLogcatMode) return; + terminal.writeln(''); + terminal.writeln(`\x1b[33mLogcat exited (code: ${exitCode ?? 'unknown'}).\x1b[0m`); + _isLogcatMode = false; + _logcatCleanup = null; + // Record logcat history as a command output and auto-show dialog + if (_logcatBuffer.length > 0) { + const fullOutput = _logcatBuffer.join('\n'); + recordCommandOutput('!logcat', fullOutput, 'info'); + requestExpandOutput(getLastOutputId()); + } + _logcatBuffer = []; + prompt(terminal); + } + }); + + // We're in streaming mode — prompt will show when logcat ends or user stops it +} + +/** + * Stop logcat streaming + */ +function stopLogcatMode(terminal: Terminal) { + _isLogcatMode = false; + + if (_logcatCleanup) { + _logcatCleanup(); + _logcatCleanup = null; + } + + terminal.writeln(''); + terminal.writeln(`\x1b[33mLogcat stopped. Captured \x1b[1m${_logcatBuffer.length}\x1b[22m lines.\x1b[0m`); + + // Record logcat history as a command output and auto-show dialog + if (_logcatBuffer.length > 0) { + const fullOutput = _logcatBuffer.join('\n'); + recordCommandOutput('!logcat', fullOutput, 'info'); + requestExpandOutput(getLastOutputId()); + } + + _logcatBuffer = []; + prompt(terminal); +} + +/** + * Clean up terminal session resources. + * Called when the terminal component is destroyed (page navigation, not drawer close). + */ +export function cleanupTerminalSession() { + if (_logcatCleanup) { + _logcatCleanup(); + _logcatCleanup = null; + } + if (_payloadUnsubscribe) { + _payloadUnsubscribe(); + _payloadUnsubscribe = null; + } + _isLogcatMode = false; + _isPayloadMode = false; + _payloadFilter = ''; + _terminal = null; + _currentInput = ''; + _cursorPos = 0; + _logcatBuffer = []; +} + +/** + * Save the terminal buffer content so it can be restored on reopen. + * Called when the drawer is minimized to preserve session state. + * With the current "keep-mounted" approach, this is a lightweight save + * of the cursor / input state. + */ +export function onTerminalMinimized() { + _currentInput = ''; + _cursorPos = 0; +} + +/** + * Check if the user has admin permission based on their role + */ +export function isAdminUser(user: { role?: string } | null): boolean { + return user?.role === 'admin'; +} + +// Re-export for use in components +export { _isPayloadMode as isPayloadMode }; diff --git a/src/lib/core/handlers/adbPayloadHandler.ts b/src/lib/core/handlers/adbPayloadHandler.ts index a27f993..450b202 100644 --- a/src/lib/core/handlers/adbPayloadHandler.ts +++ b/src/lib/core/handlers/adbPayloadHandler.ts @@ -7,16 +7,40 @@ import { } from '../services/androidRecipeExportService'; import { handleIncomingMessages } from './messageHandler'; import { setMenuSaved, setMenuSaveError } from '../stores/menuSaveStore'; -import { recipeFromMachineQuery } from '../stores/recipeStore'; +import { recipeFromMachine, recipeFromMachineQuery } from '../stores/recipeStore'; +import { buildTags, getMenuStatus } from '$lib/data/recipeService'; +import * as semver from 'semver'; +import { env } from '$env/dynamic/public'; +import { getContext } from 'svelte'; +import { GlobalEventBus, useEventBus } from '../utils/eventBus'; type AdbPayload = { type: string; payload: any }; +let queuedPromises = new Array>(); + async function handleAdbPayload(raw_payload: string) { - console.log('[ADB] Received payload:', raw_payload.slice(0, 300)); + // console.log('[ADB] Received payload:', raw_payload.slice(0, 300)); + const APP_VERSION = env.PUBLIC_APP_SEMVER; + // const bus = useEventBus(); + try { const payload: AdbPayload = JSON.parse(raw_payload); - console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload); - switch (payload.type) { + // console.log('[ADB] Parsed type:', payload.type, 'payload:', payload.payload); + + // Emit payload event for terminal drawer payload viewer + GlobalEventBus.emit('adb:payload', payload); + + let payload_type = payload.type; + let sub_type = null; + // sub type handler + if (payload_type.includes('/')) { + // + let tspl = payload_type.split('/'); + payload_type = tspl[0]; + sub_type = tspl[1]; + } + + switch (payload_type) { case 'log': let log_level = payload.payload['level'] ?? 'INFO'; let log_message = payload.payload['msg'] ?? ''; @@ -162,10 +186,177 @@ async function handleAdbPayload(raw_payload: string) { addNotification(`ERR:${error?.message ?? 'Unable to load recipe export from Android'}`) ); break; + case 'get-recipe-response': + // only update recipe from memory of brew app + if (!semver.satisfies(APP_VERSION, '^0.0.3')) { + // reject + console.log('unsupported version'); + break; + } + + let stream_recipe_idx = -1; + + try { + stream_recipe_idx = parseInt(sub_type ?? '-1'); + } catch (_) {} + + if (stream_recipe_idx > -1) { + // TODO: update both recipeFromMachine and recipeFromMachineQuery + // + + queuedPromises.push( + new Promise((resolve, reject) => { + let recipeRawFromMachine = get(recipeFromMachine); + let recipeMachineQ = get(recipeFromMachineQuery); + + if (!Object.keys(recipeMachineQ).includes('recipe')) { + recipeMachineQ = { + ...recipeMachineQ, + recipe: {} + }; + } + + let current_r01_query = recipeMachineQ.recipe; + + let rp_from_payload = JSON.parse(payload.payload); + + try { + if ( + recipeRawFromMachine != null && + Object.keys(recipeRawFromMachine).includes('Recipe01') + ) { + let is_update = false; + // update, assume that we already fetch + for (let rp of recipeRawFromMachine['Recipe01']) { + if (rp['productCode'] == rp_from_payload['productCode']) { + rp = rp_from_payload; + is_update = true; + break; + } + } + + // new menu + if (!is_update) recipeRawFromMachine['Recipe01'].push(rp_from_payload); + } else { + // not initialize + recipeRawFromMachine = { + Recipe01: [rp_from_payload] + }; + } + + // build as overview style compatible + + let overview_menu = { + productCode: rp_from_payload['productCode'] ?? '', + name: rp_from_payload['name'] + ? rp_from_payload['name'] + : (rp_from_payload['otherName'] ?? ''), + description: rp_from_payload['desciption'] + ? rp_from_payload['desciption'] + : (rp_from_payload['otherDescription'] ?? ''), + tags: buildTags(rp_from_payload), + status: getMenuStatus(rp_from_payload['MenuStatus']) + }; + + current_r01_query[overview_menu.productCode] = overview_menu; + + recipeMachineQ = { + ...recipeMachineQ, + recipe: current_r01_query + }; + + recipeFromMachineQuery.set(recipeMachineQ); + recipeFromMachine.set(recipeRawFromMachine); + + if (stream_recipe_idx == 0) { + addNotification(`INFO:Loading recipes ...`); + } + + resolve(); + } catch (e) { + reject(e); + } + }) + ); + } else if (sub_type == 'end' && payload.payload.includes('finish')) { + let force_reload = payload.payload.includes('reload'); + + console.log('queued recipes: ', queuedPromises.length); + if (queuedPromises.length > 0) { + try { + await Promise.all(queuedPromises); + console.log('clear all recipe promises'); + queuedPromises = new Array(); + + GlobalEventBus.emitUntilConsumed('recipe-event', { + type: 'load-recipe', + status: 'end', + reload: force_reload + }); + } catch (e) { + console.error('some promise failed: ', e); + + GlobalEventBus.emitUntilConsumed('recipe-event', { + type: 'load-recipe-fail', + status: 'end', + reload: force_reload + }); + } + } + } else if (sub_type?.startsWith('mat')) { + let recipeRawFromMachine = get(recipeFromMachine); + + if (sub_type == 'mat-0') { + addNotification('INFO:Loading materials ...'); + } + + if ( + recipeRawFromMachine != null && + Object.keys(recipeRawFromMachine).includes('MaterialSetting') + ) { + recipeRawFromMachine.MaterialSetting.push(JSON.parse(payload.payload)); + } else { + // not has field material yet + recipeRawFromMachine = { + ...recipeRawFromMachine, + MaterialSetting: [JSON.parse(payload.payload)] + }; + } + + console.log('checking add mat', recipeRawFromMachine); + recipeFromMachine.set(recipeRawFromMachine); + } else if (sub_type == 'toppings') { + let recipeRawFromMachine = get(recipeFromMachine); + + let topping_raw = JSON.parse(payload.payload); + console.log('receive topping', topping_raw); + + // + recipeRawFromMachine = { + ...recipeRawFromMachine, + Topping: topping_raw + }; + + // materialFromMachineQuery + + addNotification('INFO:Loading toppings ...'); + + recipeFromMachine.set(recipeRawFromMachine); + } else { + // unhandled sub type + console.log('unhandled sub type', payload); + } + + break; default: } } catch (error: any) { - // invalid format + // invalid format — emit error event so listeners (e.g. terminal) can react + GlobalEventBus.emit('adb:payload-error', { + raw_payload, + error: error?.message ?? 'Unknown parse error', + timestamp: new Date().toISOString() + }); } } diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 852dfcb..7d38d38 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -9,7 +9,7 @@ import { env } from '$env/dynamic/public'; export const queue = writable([]); -type CommandRequest = 'sheet' | 'command'; +type CommandRequest = 'sheet' | 'command' | 'upload-log'; function getServiceName(cmdReq: CommandRequest) { switch (cmdReq) { @@ -17,6 +17,8 @@ function getServiceName(cmdReq: CommandRequest) { return 'sheet-service'; case 'command': return 'command'; + case 'upload-log': + return 'upload-log'; } } diff --git a/src/lib/core/stores/recipeStore.ts b/src/lib/core/stores/recipeStore.ts index ac21a3a..7af3a35 100644 --- a/src/lib/core/stores/recipeStore.ts +++ b/src/lib/core/stores/recipeStore.ts @@ -51,6 +51,7 @@ export const toppingGroupFromServerQuery = writable([]); export const latestRecipeToppingData = writable([]); // edit data update +/// NOTE: Will be obsolete in future, and replace with `EventBus` style. export const recipeDataEvent = writable<{ event_type: string; payload: any; diff --git a/src/lib/core/stores/terminalDrawer.ts b/src/lib/core/stores/terminalDrawer.ts new file mode 100644 index 0000000..a41044b --- /dev/null +++ b/src/lib/core/stores/terminalDrawer.ts @@ -0,0 +1,28 @@ +import { writable } from 'svelte/store'; + +/** + * Store for managing the terminal drawer open/closed state. + * Also holds command history and connection status. + */ +export const terminalDrawerOpen = writable(false); + +/** + * Toggle the terminal drawer open/closed + */ +export function toggleTerminalDrawer() { + terminalDrawerOpen.update((v) => !v); +} + +/** + * Open the terminal drawer + */ +export function openTerminalDrawer() { + terminalDrawerOpen.set(true); +} + +/** + * Close the terminal drawer + */ +export function closeTerminalDrawer() { + terminalDrawerOpen.set(false); +} diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 4fa779c..5cb0301 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -81,7 +81,8 @@ export async function connectToWebsocket(id_token?: string) { socket.send( JSON.stringify({ token: id_token ?? '', - client_public_key: publicKeyBase64 + client_public_key: publicKeyBase64, + client_version: env.PUBLIC_APP_SEMVER }) ); diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts index 5795c9f..13dff0e 100644 --- a/src/lib/core/types/outMessage.ts +++ b/src/lib/core/types/outMessage.ts @@ -48,7 +48,7 @@ export type OutMessage = payload: {}; } | { - type: 'sheet' | 'command'; + type: 'sheet' | 'command' | 'upload-log'; payload: { user_info: any; srv_name: string; diff --git a/src/lib/core/utils/eventBus.ts b/src/lib/core/utils/eventBus.ts new file mode 100644 index 0000000..ce7e6c5 --- /dev/null +++ b/src/lib/core/utils/eventBus.ts @@ -0,0 +1,117 @@ +import { getContext, setContext } from 'svelte'; + +const COMMON_BUS = Symbol('g-event'); + +class EventBus { + #listeners = new Map(); + + /** + * Register event with callback on this channel + * @param event + * @param callback + * @returns unsubscribe function of this event, remove callback out of this event + */ + on(event: string, callback: any) { + if (!this.#listeners.has(event)) { + this.#listeners.set(event, new Set()); + } + + this.#listeners.get(event).add(callback); + + // return unsubscribe + return () => this.#listeners.get(event).delete(callback); + } + + /** + * Emit data to this event, call every registered callbacks + * @param event + * @param data + */ + emit(event: string, data: any) { + if (this.#listeners.has(event)) { + this.#listeners.get(event).forEach((cb: any) => cb(data)); + } + } + + emitUntilConsumed(event: string, data: any, timeout?: number) { + if (this.#listeners.has(event)) { + let listener_count = this.#listeners.get(event).length; + + if (listener_count == 0) { + setTimeout( + () => { + this.emitUntilConsumed(event, data); + }, + (timeout ?? 1) * 1000 + ); + } else { + this.emit(event, data); + } + } + } + + /** + * Clear all listeners + */ + clear() { + this.#listeners.clear(); + } + + /** + * Clear all callbacks on this event + * @param event + */ + resetCallbackOnEvent(event: string) { + if (this.#listeners.has(event)) { + this.#listeners.set(event, new Set()); + } + } +} + +/** + * Initialize the common channel event bus + * @returns EventBus | undefined + */ +function setEventBus(): EventBus | undefined { + return setContext(COMMON_BUS, new EventBus()); +} + +/** + * Get common channel event bus, cannot be used in non-component + * @returns EventBus | undefined + */ +function useEventBus(): EventBus | undefined { + return getContext(COMMON_BUS); +} + +/** + * Initialize the channel with name event bus + * @param name channel name + * @returns EventBus | undefined + */ +function setEventBusWithName(name: string): EventBus | undefined { + return setContext(name, new EventBus()); +} + +/** + * Get a specific channel event bus, cannot be used in non-component + * @param name channel name + * @returns EventBus | undefined + */ +function useEventBusWithName(name: string): EventBus | undefined { + return getContext(name); +} + +/** + * Global type event bus, allow use without Svelte context + */ +const GlobalEventBus = new EventBus(); + +export { + setEventBus, + useEventBus, + setEventBusWithName, + useEventBusWithName, + COMMON_BUS, + GlobalEventBus +}; diff --git a/src/lib/data/recipeService.ts b/src/lib/data/recipeService.ts index 778698d..7791fdf 100644 --- a/src/lib/data/recipeService.ts +++ b/src/lib/data/recipeService.ts @@ -272,5 +272,7 @@ export { getMaterialType, getCategories, isNonMaterial, - extractMaterialIdFromDisplay + extractMaterialIdFromDisplay, + getMenuStatus, + buildTags }; diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte index 910c93e..0d38883 100644 --- a/src/routes/(authed)/+layout.svelte +++ b/src/routes/(authed)/+layout.svelte @@ -18,10 +18,22 @@ } from '@yume-chan/adb-daemon-webusb'; import AdbWebCredentialStore from '@yume-chan/adb-credential-web'; import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager'; + import { browser } from '$app/environment'; + import { onMount } from 'svelte'; let { children } = $props(); let websocketConnectedForUid = $state(''); let adbReconnectTriedForUid = $state(''); + let TerminalDrawerComponent: any = $state(null); + + // Dynamic import: TerminalDrawer depends on @xterm/xterm which references + // browser globals (self, window). Static import would crash SSR evaluation. + onMount(async () => { + if (browser) { + const mod = await import('$lib/components/terminal-drawer.svelte'); + TerminalDrawerComponent = mod.default; + } + }); function getAutoConnectChannel(pathname: string) { if (pathname.startsWith('/tools/create-menu')) { @@ -125,4 +137,8 @@ {@render children()} + + {#if TerminalDrawerComponent} + + {/if} diff --git a/src/routes/(authed)/tools/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte index 58edf22..219ed16 100644 --- a/src/routes/(authed)/tools/brew/+page.svelte +++ b/src/routes/(authed)/tools/brew/+page.svelte @@ -37,11 +37,16 @@ clearMenuSaveState } from '$lib/core/stores/menuSaveStore'; + import * as semver from 'semver'; + import { GlobalEventBus } from '$lib/core/utils/eventBus'; + const sourceDir = '/sdcard/coffeevending'; const stagedMenuStorageKey = 'brew.create-menu.drafts.v1'; const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`; const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`; + const APP_VERSION = env.PUBLIC_APP_SEMVER; + // fetched recipe let devRecipe: any | undefined = $state(); @@ -63,6 +68,32 @@ let isAndroidSocketConnected = $derived(Boolean($adbWriter)); let isRecipeLoaded = $derived(Boolean(devRecipe)); + // clear out event + + GlobalEventBus.on('recipe-event', (d: any) => { + console.log('[recipe-ev] get event: ', d); + if (d?.type == 'load-recipe' && d?.status == 'end') { + addNotification('INFO:Get data, waiting for reloading ...'); + // load finish + //\ + let recipeRaw = get(recipeFromMachine); + + if (recipeRaw) { + devRecipe = recipeRaw; + // update material & topping + console.log('check dev recipe', devRecipe); + } + // data.recipes = r01Q.recipe; + + buildOverviewForBrewing(); + + console.log('refresh by m2 mem recipe data done'); + recipeLoading = false; + + addNotification('INFO:Load recipe from memories success!'); + } + }); + async function pullTextWithRetry(path: string, timeoutMs = 15000, attempts = 2) { for (let attempt = 1; attempt <= attempts; attempt++) { const content = await adb.pull(path, timeoutMs); @@ -82,35 +113,51 @@ console.log('check instance', instance); if (instance) { recipeLoading = true; - try { - console.log('instance passed!'); - const recipePaths = [ - `${sourceDir}/cfg/recipe_branch_dev.json`, - `${sourceDir}/coffeethai02.json` - ]; - for (const recipePath of recipePaths) { - const dev_recipe = await pullTextWithRetry(recipePath); - console.log('dev recipe pull result', { - recipePath, - loaded: dev_recipe != undefined, - size: dev_recipe?.length ?? 0 + if (semver.satisfies(APP_VERSION, '^0.0.3')) { + try { + addNotification('WARN:Load recipe from app memories ...'); + + sendToAndroid({ + type: 'get_recipe', + payload: {} }); - if (!dev_recipe || dev_recipe.trim().length == 0) continue; - try { - devRecipe = JSON.parse(dev_recipe); - buildOverviewForBrewing(); - return; - } catch (error) { - console.error('failed to parse recipe json', recipePath, error); - addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`); - } + // GlobalEventBus.emit('recipe-event', 'wait-finish'); + } finally { + recipeLoading = false; } + } else { + try { + console.log('instance passed!'); + const recipePaths = [ + `${sourceDir}/cfg/recipe_branch_dev.json`, + `${sourceDir}/coffeethai02.json` + ]; - addNotification('ERROR:Cannot fetch recipe from machine'); - } finally { - recipeLoading = false; + for (const recipePath of recipePaths) { + const dev_recipe = await pullTextWithRetry(recipePath); + console.log('dev recipe pull result', { + recipePath, + loaded: dev_recipe != undefined, + size: dev_recipe?.length ?? 0 + }); + if (!dev_recipe || dev_recipe.trim().length == 0) continue; + + try { + devRecipe = JSON.parse(dev_recipe); + buildOverviewForBrewing(); + return; + } catch (error) { + console.error('failed to parse recipe json', recipePath, error); + addNotification(`ERROR:Invalid recipe JSON from ${recipePath}`); + } + } + + addNotification('ERROR:Cannot fetch recipe from machine'); + } finally { + recipeLoading = false; + } } } else { addNotification('ERROR:Cannot connect to machine'); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f8e97fe..6057787 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import { AdbInstance } from './state.svelte'; import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js'; - import { onMount } from 'svelte'; + import { onMount, setContext } from 'svelte'; import { onAuthStateChanged } from 'firebase/auth'; import { auth as authStore, authInitialized } from '$lib/core/stores/auth'; import { auth } from '$lib/core/client/firebase'; @@ -26,9 +26,19 @@ setCookieOnNonBrowser } from '$lib/helpers/cookie'; import { connectToWebsocket } from '$lib/core/stores/websocketStore'; + import { GlobalEventBus } from '$lib/core/utils/eventBus'; + import * as semver from 'semver'; + import { env } from '$env/dynamic/public'; let { children } = $props(); + const APP_VERSION = env.PUBLIC_APP_SEMVER; + + if (semver.satisfies(APP_VERSION, '^0.0.3')) { + // clean event bus + GlobalEventBus.clear(); + } + onMount(() => { console.log('base url', window.location.origin, document.cookie); diff --git a/vite.config.ts b/vite.config.ts index 0b49238..7f3223c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ noExternal: ['@dnd-kit/core', '@dnd-kit/sortable'] }, optimizeDeps: { - include: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search'] + include: ['@xterm/xterm', 'xterm-addon-fit', 'xterm-addon-search'] }, test: { expect: { requireAssertions: true }, From 270faf6b34dd5792ed206a9d87b6a87f261d4054 Mon Sep 17 00:00:00 2001 From: "pakintada@gmail.com" Date: Fri, 19 Jun 2026 11:26:44 +0700 Subject: [PATCH 6/7] feat: add announcement - fix: bug encryption not working on newer version Signed-off-by: pakintada@gmail.com --- src/lib/components/AnnouncementDialog.svelte | 174 +++++++++++++++++++ src/lib/core/handlers/messageHandler.ts | 7 +- src/lib/core/handlers/ws_messageSender.ts | 2 +- src/routes/+layout.svelte | 2 + 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/AnnouncementDialog.svelte diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte new file mode 100644 index 0000000..ed18c7d --- /dev/null +++ b/src/lib/components/AnnouncementDialog.svelte @@ -0,0 +1,174 @@ + + +{#if visible} + + -
- +
+
+ Recipe Source +
+
+ + + +
+ + + + Load Recipe From Server + Select a country to load material data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -499,31 +1051,60 @@ - {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - Create or update one MaterialSetting entry. The JSON preview shows the payload - before saving to Android. + Create or update one MaterialSetting entry. Server-loaded data is read-only until + ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - {#if existingMaterial} + {#if duplicateMaterialOnCreate} +
+ Material ID {form.id} already exists. Choose another ID before creating. +
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial} +
+ Material ID {form.id} already exists. Choose another ID before saving. +
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting. + Editing Material ID {editingMaterialId}.
{/if} +
+ + +
+
- + +

+ Generated from the highest existing ID in this type + 1. +

@@ -546,11 +1127,11 @@
- +
@@ -582,23 +1163,7 @@
-
-
- - -
+
@@ -618,21 +1183,6 @@

-
-
- - -
-
- - -
-
-
@@ -686,12 +1236,18 @@
- Cancel -
@@ -713,14 +1269,46 @@ + + + + Select Material Type + + Choose the type for the new material. Material ID will be generated from existing IDs in + that type + 1. + + + +
+ {#each selectableMaterialTypeOptions as option} + + {/each} +
+ + + + +
+
+
Existing Materials - - Use Edit to update a material, or Delete to remove it after confirmation. - +
- Loading materials from Android... + Loading materials...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
+ + + + Load Recipe From Server + Select a country to load topping data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -591,23 +887,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'} - - Switch between list items and groups. Edit/Delete actions are explicit per row. - +
- +
@@ -615,12 +911,14 @@
{#if activeTab === 'list'} - Add Topping {:else} - Add Group {/if}
@@ -629,19 +927,19 @@ {#if loading}
- Loading toppings from Android... + Loading toppings...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'} {#if filteredToppingList.length === 0}
No topping list items found.
{:else}