diff --git a/bun.lock b/bun.lock index b36186e..979b130 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@dnd-kit/abstract": "^0.2.4", "@dnd-kit/helpers": "^0.2.4", "@tanstack/match-sorter-utils": "^8.19.4", + "@types/semver": "^7.7.1", "@xterm/xterm": "^5.5.0", "@yume-chan/adb": "^2.6.0", "@yume-chan/adb-credential-web": "^2.1.0", @@ -22,6 +23,7 @@ "firebase": "^12.14.0", "idb": "^8.0.3", "mode-watcher": "^1.1.0", + "semver": "^7.8.4", "usb": "^2.17.0", "uuid": "^13.0.0", "xterm-addon-fit": "^0.8.0", @@ -54,7 +56,7 @@ "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", "storybook": "^10.3.5", - "svelte": "^5.55.2", + "svelte": "^5.56.3", "svelte-adapter-bun": "^1.0.1", "svelte-check": "^4.4.6", "svelte-sonner": "^1.1.0", @@ -504,6 +506,8 @@ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="], @@ -834,7 +838,7 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], @@ -862,7 +866,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="], + "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], "svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="], @@ -1040,9 +1044,15 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="], + "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], "svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="], diff --git a/bun.lockb b/bun.lockb index 73c25fe..600a8f8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 570a1ee..e0eb61d 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@dnd-kit-svelte/svelte": "0.1.3", "@dnd-kit/abstract": "^0.2.4", "@dnd-kit/helpers": "^0.2.4", + "@logtape/logtape": "^2.2.2", "@tanstack/match-sorter-utils": "^8.19.4", "@types/semver": "^7.7.1", "@xterm/xterm": "^5.5.0", @@ -77,6 +78,7 @@ "animejs": "^4.3.6", "firebase": "^12.14.0", "idb": "^8.0.3", + "logtape": "https://github.com/dahlia/logtape.git", "mode-watcher": "^1.1.0", "semver": "^7.8.4", "usb": "^2.17.0", diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte index fa67d85..a1ac50b 100644 --- a/src/lib/components/AnnouncementDialog.svelte +++ b/src/lib/components/AnnouncementDialog.svelte @@ -1,24 +1,47 @@ + +{#if pending} + +{/if} diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 8e71838..3149e4b 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -1,4 +1,5 @@ - + + + + + + + +
+ +
+
+

Screen Mirror

+ {#if sessionCodec} + + {sessionCodec} + + {/if} + {#if sessionSize} + + {sessionSize} + + {/if} + {#if frameCount > 0} + + {frameCount} frames + + {/if} +
+ + + Close + +
+ + +
+ {#if loading && frameCount === 0 && !error} +
+ {#each stepStates as step (step.key)} +
+ {#if step.isCompleted} +
+ +
+ {:else if step.isActive} +
+ {:else} +
+ {/if} + {step.label} + {#if step.isActive && stepDetail} + — {stepDetail} + {/if} +
+ {/each} +
+ {/if} + {#if error} +
+
+

{error}

+
+ +
+ {:else if !loading && frameCount === 0 && sessionStarted && !stopping} +
+ +
+ {/if} + + {#if touchLocked && frameCount > 0} +
+ + + + + Touch locked +
+ {/if} +
+ + +
+
+ + + +
+ +
+ + +
+
+ {#each logEntries as entry (entry.id)} + {@const iconClass = entry.type === 'ok' + ? 'text-emerald-600 dark:text-emerald-400' + : entry.type === 'error' + ? 'text-red-600 dark:text-red-400' + : entry.type === 'warn' + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-muted-foreground'} + {@const msgClass = entry.type === 'ok' + ? 'text-emerald-700 dark:text-emerald-300' + : entry.type === 'error' + ? 'text-red-700 dark:text-red-300' + : entry.type === 'warn' + ? 'text-yellow-700 dark:text-yellow-300' + : 'text-foreground'} +
+ {entry.time} + + {entry.type === 'ok' ? '✓' : entry.type === 'error' ? '✗' : entry.type === 'warn' ? '!' : '·'} + + + {entry.message} + +
+ {/each} + {#if logEntries.length === 0} + Waiting… + {/if} +
+
+
+
+
+
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index b6f80ef..88f03b6 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -13,6 +13,8 @@ ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", link: "text-primary underline-offset-4 hover:underline", + accept: "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-600 dark:hover:bg-emerald-500", + reject: "bg-red-600 text-white shadow-sm hover:bg-red-500 dark:bg-red-600 dark:hover:bg-red-500", }, size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 419a0ff..cdb93f9 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -13,15 +13,22 @@ 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 { adbWriter, sendToAndroid } from '../stores/adbWriter'; +import { adbReconnect } from '../stores/adbReconnectStore'; import { WritableStream } from '@yume-chan/stream-extra'; import { env } from '$env/dynamic/public'; import { get } from 'svelte/store'; import { adbConnectionStatus } from '../stores/adbConnectionStore'; +import { browser } from '$app/environment'; +import AdbWorker from '$lib/workers/androidPayload.worker?worker'; let _connectionPromise: Promise | null = null; let syncConnection: any = null; +let worker: Worker | undefined; + +let healthCheckRoutine: any; + /** * Centralized connection function that all pages and components should use. * @@ -192,7 +199,7 @@ async function connectWithRetry( export async function connnectViaWebUSB(connectAndroidServer = true) { adbConnectionStatus.setConnecting(); const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice(); - logger.info('usb ok', globalThis.navigator.usb); + logger.info('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb); if (device) { logger.info('connect ', device.name); @@ -215,11 +222,9 @@ export async function connnectViaWebUSB(connectAndroidServer = true) { // save device info await deviceCredentialManager.saveDeviceInfo(device); } catch (e: any) { - logger.error('error on connect', e); - if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) { addNotification( - 'ERR:Device is already in use by another program, please close the program and try again' + 'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.' ); } @@ -296,7 +301,7 @@ export async function connectRecipeMenuViaWebUSB() { if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) { addNotification( - 'ERR:Device is already in use by another program, please close the program and try again' + 'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.' ); } @@ -336,6 +341,7 @@ export async function connectRecipeMenuDeviceByCred( } export async function reconnectAndroidRecipeMenuServer() { + adbWriter.set(null); await connectToAndroidRecipeMenuServer(true); } @@ -392,8 +398,8 @@ function sanitizeAdbCommand(command: string): string { return command; } -export async function executeCmd(command: string) { - command = sanitizeAdbCommand(command); +export async function executeCmd(command: string, block: boolean = false) { + command = block ? sanitizeAdbCommand(command) : command; let instance = getAdbInstance(); if (!instance) { @@ -441,6 +447,15 @@ 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); + } +} + /** * Execute an ADB command and stream its output via callbacks. * Used for commands like `logcat` that run indefinitely. @@ -652,6 +667,9 @@ export async function pushBinary( // NOTE: adb reverse is not work by unavailable features support export async function reconnectAndroidServer() { + // Clear any stale writer before reconnecting so the new stream + // doesn't conflict with a dead one still referenced in the store. + adbWriter.set(null); await connectToAndroidServer(); } @@ -678,13 +696,28 @@ async function connectToAndroidServer(maxRetries = 5) { const writer = stream.writable.getWriter(); const reader = stream.readable.getReader(); - // logger.info('checking on writer ', writer); + if (browser) { + worker = new AdbWorker(); + + worker.onmessage = (e) => { + if (e.data.type === 'DATA') { + const raw = e.data.payload; + handleAdbPayload(new TextDecoder().decode(raw)); + } + }; + } adbWriter.set(writer); + if (healthCheckRoutine) { + clearInterval(healthCheckRoutine); + healthCheckRoutine = null; + } + if (writer) { addNotification('INFO:Enable Brewing Mode T on machine'); - const textDecoder = new TextDecoder(); - let buffer = ''; + healthCheckRoutine = setInterval(() => { + sendToAndroid({ type: 'status', payload: {} }); + }, 1000); (async () => { try { @@ -692,38 +725,28 @@ async function connectToAndroidServer(maxRetries = 5) { const { value, done } = await reader.read(); if (done) break; - // 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)); + // Transfer ONLY the buffer, not the stream + // Type assertion (as any) fixes the overload error + (worker as any).postMessage({ type: 'CHUNK', payload: value }, [value.buffer]); } } catch (e) { - logger.error('read error', e); - if (isRecoverableError(e)) { - void connectToAndroidServer(); + logger.error('Android server read error', e); + } finally { + adbWriter.set(null); + + if (healthCheckRoutine) { + clearInterval(healthCheckRoutine); + healthCheckRoutine = null; } - } finally { - adbWriter.set(null); - addNotification('WARN:Brewing Mode T Offline ...'); - reader.cancel().catch(() => {}); - } - })(); - return; - } else { - addNotification('WARN:Brewing Mode T unavailable'); + addNotification('WARN:Android server channel offline ...'); + reader.cancel().catch(() => {}); + // Prompt the user to reconnect instead of auto-reconnecting + adbReconnect.requestReconnect('Android server disconnected'); + } + })(); + return; + } else { + addNotification('WARN:Brewing Mode T unavailable'); if (attempt < maxRetries - 1) { const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000); @@ -788,6 +811,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO adbWriter.set(writer); if (writer) { addNotification('INFO:Enable Android recipe menu channel'); + healthCheckRoutine = setInterval(() => { + sendToAndroid({ type: 'status', payload: {} }); + }, 1000); } else { addNotification('WARN:Android recipe menu channel unavailable'); @@ -830,20 +856,23 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO logger.error('recipe menu read error', e); } finally { adbWriter.set(null); + + if (healthCheckRoutine) { + clearInterval(healthCheckRoutine); + healthCheckRoutine = null; + } addNotification('WARN:Android recipe menu channel offline ...'); reader.cancel().catch(() => {}); - if (retryOnFailure) { - scheduleRecipeMenuAndroidServerReconnect(); - } + // Prompt the user to reconnect instead of auto-scheduling + adbReconnect.requestReconnect('Android recipe menu disconnected'); } })(); } catch (err) { logger.error('Recipe menu connection failed. Suspect java running or not', err); adbWriter.set(null); if (notifyFailure) addNotification('ERR:Fail to enable Android recipe menu channel'); - if (retryOnFailure) { - scheduleRecipeMenuAndroidServerReconnect(); - } + // Prompt the user to reconnect instead of auto-scheduling + adbReconnect.requestReconnect('Android recipe menu connection failed'); } } diff --git a/src/lib/core/handlers/adbPayloadHandler.ts b/src/lib/core/handlers/adbPayloadHandler.ts index f20b6e9..a924cee 100644 --- a/src/lib/core/handlers/adbPayloadHandler.ts +++ b/src/lib/core/handlers/adbPayloadHandler.ts @@ -19,6 +19,9 @@ type AdbPayload = { type: string; payload: any }; let queuedPromises = new Array>(); +// 10 secs +const SLOW_PAYLOAD_RESPONSE_MS = 10000; + async function handleAdbPayload(raw_payload: string) { // logger.info('[ADB] Received payload:', raw_payload.slice(0, 300)); const APP_VERSION = env.PUBLIC_APP_SEMVER; @@ -156,6 +159,22 @@ async function handleAdbPayload(raw_payload: string) { // show error to user from brew app addNotification(`ERR:${payload.payload}`); // send message to server if needed + break; + case 'status': + let current_ts = Date.now(); + let source_ts = payload.payload?.timestamp ?? current_ts; + + let diff_ts = current_ts - source_ts; + + if (diff_ts == 0) { + // no source + addNotification('WARN:Unknown time detected in status check.'); + } else if (diff_ts > 0 && diff_ts < SLOW_PAYLOAD_RESPONSE_MS) { + // is acceptable + } else { + addNotification('WARN:Slow adb response detected'); + } + break; case 'recipe-export': if (payload.payload?.content) { @@ -347,8 +366,73 @@ async function handleAdbPayload(raw_payload: string) { recipeFromMachine.set(recipeRawFromMachine); } else { + if (sub_type == null) { + try { + let big_recipe = JSON.parse(JSON.stringify(payload.payload)); + logger.info(`get large recipe keys: ${Object.keys(big_recipe)}`); + + let recipes = JSON.parse(big_recipe?.recipes); + let materials = JSON.parse(big_recipe?.materials); + let toppings = JSON.parse(big_recipe?.toppings); + + let updated = { + Recipe01: recipes, + MaterialSetting: materials, + Topping: toppings + }; + + recipeFromMachine.set(updated); + + // ===================================== + // + // Build Overview + // + + let queries = []; + for (let rp of recipes) { + queries.push({ + productCode: rp.productCode ?? '', + name: rp.name ? rp.name : (rp.otherName ?? ''), + description: rp.description + ? rp.description + : (rp.otherDescription ?? ''), + tags: buildTags(rp), + status: getMenuStatus(rp.MenuStatus) + }); + } + + let cached_query_from_machine = get(recipeFromMachineQuery); + + cached_query_from_machine = { + ...cached_query_from_machine, + recipe: queries + }; + + recipeFromMachineQuery.set(cached_query_from_machine); + + // ===================================== + // + // Send finish signal + + GlobalEventBus.emitUntilConsumed('recipe-event', { + type: 'load-recipe', + status: 'end', + reload: true + }); + } catch (e) { + logger.error('some promise failed: ', e); + + GlobalEventBus.emitUntilConsumed('recipe-event', { + type: 'load-recipe-fail', + status: 'end', + reload: true + }); + } + } else { + logger.info(`unhandled sub type: %${sub_type}%`); + } + // unhandled sub type - logger.info('unhandled sub type', payload); } break; diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 5b3f579..1bd2932 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -22,11 +22,14 @@ import { handleSheetStreamEnd, handleSheetStreamError, handleCatalogsResponse, + handlePriceSlotsResponse, + isPriceSlotsPayload, handleListMenuResponse, sheetCatalogsLoading, handleRawStreamHeader, handleRawStreamChunk, - handleRawStreamEnd + handleRawStreamEnd, + handleSheetPriceResponse } from '../stores/sheetStore'; import { handleGenLayoutBatchStart, @@ -49,10 +52,10 @@ 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'; import { GlobalEventBus } from '../utils/eventBus'; import { handleIncomingRemoteShell } from '../stores/remoteShellStore'; +import * as semver from 'semver'; export const messages = writable([]); @@ -298,26 +301,76 @@ const handlers: Record void> = { if (from === 'sheet-service' && level === 'content') { const currentUid = auth.currentUser?.uid; + const content = p.content ?? p.value ?? p.payload; + const ref = p.ref ?? ''; - if (target && currentUid && target === currentUid) { - if (!msg && p.content?.catalogs) { - handleCatalogsResponse(p.content); - addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`); + logger.debug('[Sheet] Notify content received:', { + msg, + target, + currentUid, + contentKeys: content && typeof content === 'object' ? Object.keys(content) : [], + contentItems: Array.isArray(content) ? content.length : undefined + }); + + 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; + } + + if (!msg && ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); + addNotification('INFO:Loaded sheet price 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'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming started'); + } else { + handleSheetStreamStart(p); + addNotification('INFO:Sheet data streaming started'); + } break; case 'chunk': - handleSheetStreamChunk(p); + if (isPriceSlotsPayload(content)) { + handlePriceSlotsResponse(content); + } else if (ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); + } else { + handleSheetStreamChunk(p); + } break; case 'end': - handleSheetStreamEnd(p); - addNotification('INFO:Sheet data streaming complete'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming complete'); + } else { + handleSheetStreamEnd(p); + addNotification('INFO:Sheet data streaming complete'); + } break; case 'error': handleSheetStreamError(p); @@ -325,8 +378,17 @@ const handlers: Record void> = { break; default: // Handle other content notifications from sheet-service - logger.info('[Sheet] Received content:', p.content); + logger.info('[Sheet] Received content:', { + contentItems: Array.isArray(content) ? content.length : undefined + }); } + } else { + console.warn('[Sheet] Ignored content because target does not match current user:', { + target, + currentUid, + msg, + contentItems: Array.isArray(content) ? content.length : undefined + }); } return; } @@ -404,6 +466,7 @@ const handlers: Record void> = { country: current_meta?.country ?? '', content: saved_product_code_to_get_from_sheet, param: 'price', + option: 'price', stream: true, request_id }); @@ -466,7 +529,7 @@ const handlers: Record void> = { heartbeat: (p) => { socketConnectionOfflineCount.set(0); socketAlreadySendHeartbeat.set(0); - logger.info('heartbeat reset offline count'); + // logger.info('heartbeat reset offline count'); }, // Raw stream handlers for sheet data (e.g., price) raw_stream: (p) => { @@ -481,14 +544,23 @@ 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); + }, announce: (p) => { // Server-pushed announcement (e.g., closing maintenance) GlobalEventBus.emit('announce', p); @@ -496,52 +568,78 @@ const handlers: Record void> = { remote_shell: (p) => { // Server requests command execution on connected Android device handleIncomingRemoteShell(p); + }, + firmware_confirm: (p) => { + // console.log(`received firmware confirmation: ${JSON.stringify(p)}`); + const items = Object.entries(p.history ?? {}).map(([filename, msg]) => ({ + filename, + message: Object.entries(msg as any).map((x) => x[1]) + })); + GlobalEventBus.emit('announce', { + title: 'Firmware Confirmation', + subtitle: `Please confirm this build contents. Session Ref: ${p.session}`, + message: items, + buttonText: 'Confirm', + type: 'info' + }); + }, + firmware_report: (p) => { + console.log(`received firmware report: ${JSON.stringify(p)}`); + }, + firmware_versions: (p) => { + GlobalEventBus.emit('firmware-versions', p.versions ?? {}); } }; -export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) { +export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) { const APP_VERSION = env.PUBLIC_APP_SEMVER; + const parsedMessage = JSON.parse(raw); - const ack: HandshakeAck = JSON.parse(raw); - // logger.info(`[WS MSG] type=${msg.type}`, msg.payload); + const ack: HandshakeAck = parsedMessage; if (ack != null && ack.status === 'authenticated') { // has server response + if (!clientPrivateKey) return; sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key)); - addNotification('INFO:Secured Connection'); + // addNotification('INFO:Secured Connection'); return; } - if (semver.satisfies(APP_VERSION, '>=0.0.2')) { + if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) { // 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 + parsedMessage.ciphertext, + parsedMessage.iv ); let actual_message: WSMessage = JSON.parse(decrypted_string); + // preprocess type + actual_message.type = actual_message.type.replace('-', '_'); + handlers[actual_message.type]?.(actual_message.payload); } } else { - const msg: WSMessage = JSON.parse(raw); + const msg: WSMessage = parsedMessage; + if (msg.type !== 'heartbeat') { + // logger.debug(`[WS MSG] type=${msg.type}`, msg.payload); + } 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'; - // } + // 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); } diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 6b10414..43f7851 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -1,12 +1,12 @@ import { logger } from '$lib/core/utils/logger'; import { get, writable } from 'svelte/store'; import type { OutMessage } from '../types/outMessage'; -import { sharedKey, socketStore } from '../stores/websocketStore'; +import { sharedKey, socketStore, wsAuthReady } 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'; +import * as semver from 'semver'; export const queue = writable([]); @@ -23,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) { } } +function waitForWsAuthReady(timeoutMs = 10000): Promise { + if (get(wsAuthReady)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(false); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(true); + }); + }); +} + // Websocket message wrapper for commands like `sheet`, `command` export async function sendCommandRequest(target: CommandRequest, values: any): Promise { + const authReady = await waitForWsAuthReady(); + if (!authReady) { + console.warn('[WS Send] Skip command request because websocket auth is not ready', { + target, + param: values?.param + }); + return false; + } + let srv_name = getServiceName(target); let curr_user = get(auth); @@ -85,6 +117,13 @@ export async function sendMessage( data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data)); } + // logger.debug('[WS Send]', { + // type: logMessage.type, + // service: logMessage.payload?.srv_name, + // param: logMessage.payload?.values?.param, + // bytes: data.length, + // secured: isSecuredAppVersion(APP_VERSION) + // }); socket.send(data); return true; } diff --git a/src/lib/core/scrcpy/scrcpy-manager.ts b/src/lib/core/scrcpy/scrcpy-manager.ts new file mode 100644 index 0000000..ea75aa8 --- /dev/null +++ b/src/lib/core/scrcpy/scrcpy-manager.ts @@ -0,0 +1,178 @@ +/** + * ScrcpyManager — Lifecycle manager for @yume-chan/adb-scrcpy screen mirroring. + * + * Handles: + * - Fetching the scrcpy-server binary (cached after first download) + * - Pushing the binary to the Android device + * - Starting a scrcpy session (video + optional control) + * - Exposing the AdbScrcpyClient for the dialog to consume the video stream + * - Clean teardown on close + * + * IMPORTANT: This manager does NOT consume the video stream. The dialog is + * responsible for reading packets from client.videoStream.stream. + */ + +import { AdbScrcpyClient, AdbScrcpyOptions2_3 } from '@yume-chan/adb-scrcpy'; +import { ReadableStream } from '@yume-chan/stream-extra'; +import { AdbInstance } from '../../../routes/state.svelte'; +import { logger } from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ScrcpyStatusStep = 'fetching' | 'pushing' | 'starting' | 'ready' | 'error'; + +export interface ScrcpyStartOptions { + /** Max video width (0 = no limit). Default 1024. */ + maxSize?: number; + /** Video bitrate in bits/s. Default 8 000 000. */ + videoBitRate?: number; + /** Video codec. Default "h264". */ + videoCodec?: 'h264' | 'h265' | 'av1'; + /** Max FPS (0 = no limit). Default 0. */ + maxFps?: number; + /** Enable touch / key control. Default true. */ + control?: boolean; + /** Push a specific server version tag. */ + serverVersion?: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Path where the scrcpy-server binary lives on the Android device. */ +const SCRCPY_SERVER_DEVICE_PATH = '/data/local/tmp/scrcpy-server.jar'; + +// --------------------------------------------------------------------------- +// Binary cache (module-level — persists across session starts) +// --------------------------------------------------------------------------- + +let cachedBinaryData: Uint8Array | null = null; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createOptions(init: ScrcpyStartOptions) { + return new AdbScrcpyOptions2_3({ + tunnelForward: true, + video: true, + audio: false, + control: init.control ?? true, + maxSize: init.maxSize ?? 1024, + videoCodec: init.videoCodec ?? 'h264', + videoBitRate: init.videoBitRate ?? 8_000_000, + maxFps: init.maxFps ?? 0, + sendFrameMeta: true + }); +} + +/** + * Fetch the scrcpy-server binary. Uses the local SvelteKit endpoint + * (/api/scrcpy-server) to avoid CORS issues with GitHub. + * Binary data is cached in memory after the first fetch. + */ +async function fetchServerBinary(): Promise> { + if (cachedBinaryData) { + logger.info('[ScrcpyManager] Using cached scrcpy-server binary'); + return new ReadableStream({ + start(controller) { + controller.enqueue(cachedBinaryData!); + controller.close(); + } + }); + } + + logger.info('[ScrcpyManager] Fetching scrcpy-server from /api/scrcpy-server…'); + const response = await fetch('/api/scrcpy-server'); + + if (!response.ok) { + throw new Error(`Failed to fetch scrcpy-server: ${response.status} ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + cachedBinaryData = new Uint8Array(buffer); + logger.info(`[ScrcpyManager] Binary cached (${cachedBinaryData.length} bytes)`); + + return new ReadableStream({ + start(controller) { + controller.enqueue(cachedBinaryData!); + controller.close(); + } + }); +} + +// --------------------------------------------------------------------------- +// ScrcpyManager class +// --------------------------------------------------------------------------- + +class ScrcpyManager { + private _client: AdbScrcpyClient> | null = null; + + get client(): AdbScrcpyClient> | null { + return this._client; + } + + get isActive(): boolean { + return this._client !== null; + } + + /** + * Start a scrcpy screen-mirroring session. + * + * @param opts Configuration overrides (bitrate, codec, maxSize, …). + * @param onStatus Optional callback for step-by-step progress reporting. + * @returns The AdbScrcpyClient instance for the active session. + */ + async start( + opts: ScrcpyStartOptions = {}, + onStatus?: (step: ScrcpyStatusStep, detail?: string) => void + ): Promise>> { + // Clean up any existing session first. + if (this._client) { + await this.close(); + } + + const adb = AdbInstance.instance; + if (!adb) { + throw new Error('No ADB device connected'); + } + + // 1. Fetch the scrcpy-server binary (cached after first download). + onStatus?.('fetching', cachedBinaryData ? 'Using cached binary' : 'Downloading scrcpy-server…'); + const serverBinary = await fetchServerBinary(); + + // 2. Push the binary to the device. + onStatus?.('pushing', 'Pushing to device…'); + await AdbScrcpyClient.pushServer(adb, serverBinary, SCRCPY_SERVER_DEVICE_PATH); + logger.info('[ScrcpyManager] Server binary pushed.'); + + // 3. Start the scrcpy session. + onStatus?.('starting', 'Starting session…'); + const options = createOptions(opts); + const client = await AdbScrcpyClient.start(adb, SCRCPY_SERVER_DEVICE_PATH, options); + logger.info('[ScrcpyManager] Session started.'); + + this._client = client; + onStatus?.('ready', 'Connected'); + return client; + } + + /** Tear down the current session. */ + async close(): Promise { + try { + if (this._client) { + await this._client.close(); + } + } catch (e) { + logger.warn('[ScrcpyManager] Error closing session', e); + } finally { + this._client = null; + } + } +} + +/** Singleton instance — import and use anywhere. */ +export const scrcpyManager = new ScrcpyManager(); diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index 6c114f5..2034fb7 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -8,10 +8,18 @@ import { markSheetPriceAsSent, sheetPriceLoading, streamingRawData, - setPendingProductCodesCountry + setPendingProductCodesCountry, + setPendingPriceSlotsCountry, + priceSlotsLoading, + resetPriceSlotsCountry } from '../stores/sheetStore'; +import type { PriceSlot } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; +type SheetCellUpdate = { value: string; coord: { row: number; col: number } }; +type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] }; +type SheetRowCreate = { header?: string[]; cells: string[] }; + export async function requestCatalogs(country: string): Promise { return await sendCommandRequest('sheet', { country: country, @@ -19,27 +27,179 @@ export async function requestCatalogs(country: string): Promise { }); } -export async function requestPriceSlots(country: string): Promise { +/** + * Register a newly created catalog as a Grist table so it shows in the overview + * and menus can be added to it. `catalog` is the .skt filename produced by + * /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt"). + */ +export async function addCatalog( + country: string, + catalogName: string, + catalog: string +): Promise { return await sendCommandRequest('sheet', { country: country, - param: 'priceslot' + catalog: catalog, + catalog_name: catalogName, + param: 'add/catalog' }); } +export async function requestPriceSlots(country: string): Promise { + setPendingPriceSlotsCountry(country); + resetPriceSlotsCountry(country); + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function requestPriceSlot(country: string, slotNumber: number): Promise { + setPendingPriceSlotsCountry(country); + return requestPriceSlotOption(country, `PriceSlot${slotNumber}`); +} + +async function requestPriceSlotOption(country: string, option: string): Promise { + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + priceslot: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + priceSlotsLoading.set(true); + + const values = { + country: country, + param: 'price', + option, + stream: true, + request_id + }; + logger.debug('[sheetService] Sending PriceSlot request:', values); + const sent = await sendCommandRequest('sheet', values); + logger.debug('[sheetService] PriceSlot request sent:', sent); + if (!sent) { + priceSlotsLoading.set(false); + } + return sent; +} + +export async function refreshPriceSlotList(country: string): Promise { + return requestPriceSlotOption(country, 'PriceSlot'); +} + export async function updatePriceSlot( country: string, - content: { - slot: number; - name: string; - description: string; - products: { product_code: string; price: number | null; row_index?: number }[]; - } + slot: PriceSlot, + content: SheetRowUpdate[] ): Promise { - return await sendCommandRequest('sheet', { + // logger.debug('[sheetService] Sending PriceSlot update:', { + // country, + // slot: slot.slot, + // name: slot.name, + // description: slot.description, + // kind: slot.kind, + // rows: content.length, + // param: 'update/price', + // option: `PriceSlot${slot.slot}` + // }); + + const sent = await sendCommandRequest('sheet', { country: country, content: content, - param: 'update/priceslot' + param: 'update/price', + option: `PriceSlot${slot.slot}` }); + + logger.debug('[sheetService] PriceSlot update sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlot( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + logger.debug('[sheetService] Sending PriceSlot create:', { + country, + slot: slot.slot, + name: slot.name, + description: slot.description, + kind: slot.kind, + rows: content.length, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + logger.debug('[sheetService] PriceSlot create sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlotRows( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + if (!content || content.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + logger.debug('[sheetService] PriceSlot rows add sent:', { + country, + slot: slot.slot, + rows: content.length, + sent + }); + + return sent; +} + +export async function deletePriceSlotRows( + country: string, + slot: PriceSlot, + rowIds: number[] +): Promise { + if (!rowIds || rowIds.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: rowIds.map((target_id) => ({ target_id })), + param: 'delete/price', + option: `PriceSlot${slot.slot}` + }); + + logger.debug('[sheetService] PriceSlot rows delete sent:', { + country, + slot: slot.slot, + rows: rowIds.length, + sent + }); + + return sent; } export async function enterRoom(country: string, catalog: string): Promise { @@ -188,9 +348,13 @@ export async function requestGenLayout(country: string): Promise { * Request price data from sheet for specific product codes * NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check. */ -export async function requestSheetPrice(country: string, productCodes: string[]): Promise { +export async function requestSheetPrice( + country: string, + productCodes: string[], + force = false +): Promise { // Check if already sent - if (hasSheetPriceBeenSent('price')) { + if (!force && hasSheetPriceBeenSent('price')) { logger.warn('[sheetService] Price request already sent, skipping'); return false; } @@ -232,6 +396,48 @@ export async function requestSheetPrice(country: string, productCodes: string[]) country: country, content: content, param: 'price', + option: 'price', + stream: true, + request_id + }); + logger.debug('[sheetService] Sheet price request sent:', { country, request_id, sent }); + + if (sent) { + markSheetPriceAsSent('price'); + } else { + sheetPriceLoading.set(false); + } + + return sent; +} + +export async function requestAllSheetPrice(country: string, force = false): Promise { + if (!force && hasSheetPriceBeenSent('price')) { + console.warn('[sheetService] Price request already sent, skipping'); + return false; + } + + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + price: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + + sheetPriceLoading.set(true); + + logger.debug('[sheetService] Sending all sheet price request:', { country, request_id }); + + const sent = await sendCommandRequest('sheet', { + country, + content: [], + param: 'price', + option: 'price', stream: true, request_id }); diff --git a/src/lib/core/stores/adbReconnectStore.ts b/src/lib/core/stores/adbReconnectStore.ts new file mode 100644 index 0000000..6f32109 --- /dev/null +++ b/src/lib/core/stores/adbReconnectStore.ts @@ -0,0 +1,68 @@ +/** + * ADB reconnection prompt store. + * + * When the Android server socket dies, instead of silently reconnecting + * in the background, we surface a persistent banner asking the user to + * confirm the reconnection. This gives the user control over when the + * USB/TCP handshake happens (it can be disruptive) while still making + * it obvious that the connection is down. + * + * Flow: + * 1. Stream dies or write fails → `requestReconnect('reason')` + * 2. Banner appears on every authed page + * 3. User taps "Reconnect" → `confirmReconnect()` fires the + * stored callback, then clears the pending state. + * 4. User dismisses → `dismissReconnect()` clears the state + * without reconnecting. + */ +import { writable, get } from 'svelte/store'; + +export interface ReconnectRequest { + /** Human-readable reason (shown in the banner). */ + reason: string; + /** Timestamp when the reconnect was requested. */ + requestedAt: Date; +} + +function createAdbReconnectStore() { + const { subscribe, set } = writable(null); + + return { + subscribe, + + /** + * Signal that the ADB socket has died and a reconnection is needed. + * This shows the banner — the user must confirm before we actually + * reconnect. + */ + requestReconnect(reason: string = 'Android socket disconnected') { + // Don't overwrite if a request is already pending + if (get({ subscribe }) !== null) return; + set({ reason, requestedAt: new Date() }); + }, + + /** + * The user confirmed they want to reconnect. + * Callers should perform the reconnection after calling this. + */ + confirmReconnect() { + set(null); + }, + + /** + * The user dismissed the banner without reconnecting. + */ + dismissReconnect() { + set(null); + }, + + /** + * Returns whether a reconnection is currently pending. + */ + isPending(): boolean { + return get({ subscribe }) !== null; + } + }; +} + +export const adbReconnect = createAdbReconnectStore(); diff --git a/src/lib/core/stores/adbWriter.ts b/src/lib/core/stores/adbWriter.ts index 2575c7c..cadb417 100644 --- a/src/lib/core/stores/adbWriter.ts +++ b/src/lib/core/stores/adbWriter.ts @@ -1,12 +1,27 @@ import { logger } from '$lib/core/utils/logger'; import { get, writable } from 'svelte/store'; import { addNotification } from './noti'; +import { adbReconnect } from './adbReconnectStore'; const adbWriter: any = writable(null); +/** + * Tracks whether the Android socket was ever alive in this session. + * Used to distinguish: + * - First visit (never connected) → auto-connect silently + * - Reconnection (was alive, now dead) → show reconnect banner + */ +let _wasEverAlive = false; + +// Subscribe to writer changes to track liveness +adbWriter.subscribe((writer: any) => { + if (writer && writer.desiredSize !== null && writer.desiredSize !== undefined) { + _wasEverAlive = true; + } +}); + async function sendToAndroid(message: any) { let writer: any = get(adbWriter); - // logger.info('writer', writer); if (!writer) { addNotification('ERR:No active Android connection'); return false; @@ -15,26 +30,65 @@ async function sendToAndroid(message: any) { const encoder = new TextEncoder(); const serializedMessage = JSON.stringify(message); await writer.write(encoder.encode(serializedMessage + '\n')); - // logger.info('[ADB] sent', { - // type: message?.type, - // bytes: serializedMessage.length, - // productCode: message?.payload?.data?.productCode, - // batchCount: Array.isArray(message?.payload?.data) ? message.payload.data.length : undefined, - // batchProductCodes: Array.isArray(message?.payload?.data) - // ? message.payload.data.map((menu: any) => menu?.productCode) - // : undefined - // }); return true; } catch (error) { logger.error('write failed', error); + // The underlying stream is dead — clear the writer so + // callers don't keep trying to use a stale reference. + adbWriter.set(null); addNotification('ERR:Failed to send message to Android'); + // Prompt the user to reconnect (this was alive before → reconnection) + adbReconnect.requestReconnect('Android socket write failed'); return false; } } -// helper function for checking if connection is ok +/** + * Check whether the ADB writer store currently holds a reference. + * + * ⚠ This only checks if a writer object is present — it does NOT + * guarantee the underlying stream is alive. Use {@link isAdbWriterAlive} + * in page code to verify + reconnect if needed. + */ function isAdbWriterAvailable() { return get(adbWriter) != null; } -export { sendToAndroid, adbWriter, isAdbWriterAvailable }; +/** + * Attempt a lightweight probe to check if the writer's stream is still + * writable. Returns `true` if the writer appears alive, `false` if + * the stream has been closed or the writer is stale. + * + * The probe checks `writer.desiredSize` — a standard + * `WritableStreamDefaultWriter` property that returns `null` once + * the stream has been closed or errored. + * + * If a stale writer is detected (was alive, now dead), the reconnect + * banner is triggered. + */ +function isAdbWriterAlive(): boolean { + const writer: any = get(adbWriter); + if (!writer) return false; + + // WritableStreamDefaultWriter.desiredSize is null after close/error + if (writer.desiredSize === null) { + adbWriter.set(null); + adbReconnect.requestReconnect('Android socket is stale'); + return false; + } + + return true; +} + +/** + * Returns `true` if the Android socket was ever successfully connected + * in this browser session. + * + * Used to decide between auto-connect (first visit) and prompted + * reconnect (socket died after being alive). + */ +function wasAndroidSocketEverAlive(): boolean { + return _wasEverAlive; +} + +export { sendToAndroid, adbWriter, isAdbWriterAvailable, isAdbWriterAlive, wasAndroidSocketEverAlive }; diff --git a/src/lib/core/stores/firmwareStore.ts b/src/lib/core/stores/firmwareStore.ts new file mode 100644 index 0000000..76ada4e --- /dev/null +++ b/src/lib/core/stores/firmwareStore.ts @@ -0,0 +1,287 @@ +/** + * Firmware request store — manages per-country firmware build requests + * and available firmware files. + * + * The data structure is designed to be wired up to a backend API later. + * For now it uses a writable store with mock data so the UI can be + * developed and tested independently. + */ +import { writable, derived, get } from 'svelte/store'; +import { sendMessage } from '../handlers/ws_messageSender'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type FirmwareBuildStatus = 'idle' | 'queued' | 'building' | 'ready' | 'failed'; + +export interface FirmwareFile { + /** Unique identifier. */ + id: string; + /** Filename e.g. `supra-v1.2.3-tha.bin` */ + filename: string; + /** File size in bytes. */ + size: number; + /** Build date ISO string. */ + builtAt: string; + /** Firmware version string. */ + version: string; + /** Download URL (populated when status === 'ready'). */ + downloadUrl?: string; + /** Status of the build. */ + status: FirmwareBuildStatus; + /** Optional status message / error detail. */ + message?: string; +} + +export interface FirmwareCountryState { + /** Country code (e.g. 'tha', 'sgp'). */ + country: string; + /** Human-readable country label. */ + label: string; + /** Current build status for this country. */ + buildStatus: FirmwareBuildStatus; + /** List of firmware files for this country. */ + files: FirmwareFile[]; +} + +export interface FirmwareBuildRequest { + country: string; + version: string; + notes?: string; + requester: string; + param?: string[]; +} + +// --------------------------------------------------------------------------- +// Country list (derived from the same codes used in productCode.ts) +// --------------------------------------------------------------------------- + +export const FIRMWARE_COUNTRIES = [ + { code: 'tha', label: 'Thailand' }, + { code: 'mys', label: 'Malaysia' }, + { code: 'idr', label: 'Indonesia' }, + { code: 'aus', label: 'Australia' }, + { code: 'sgp', label: 'Singapore' }, + { code: 'dubai', label: 'UAE Dubai' }, + { code: 'hkg', label: 'Hong Kong' }, + { code: 'gbr', label: 'United Kingdom' }, + { code: 'rou', label: 'Romania' }, + { code: 'lva', label: 'Latvia' }, + { code: 'est', label: 'Estonia' }, + { code: 'ltu', label: 'Lithuania' } +] as const; + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +function createFirmwareStore() { + const { subscribe, set, update } = writable>({}); + + // Initialise with empty state for each country + const initial: Record = {}; + for (const c of FIRMWARE_COUNTRIES) { + initial[c.code] = { + country: c.code, + label: c.label, + buildStatus: 'idle', + files: [] + }; + } + set(initial); + + return { + subscribe, + update, + + /** + * Request a new firmware build for a country. + */ + requestBuild(req: FirmwareBuildRequest) { + const countryState = get(firmwareStore)[req.country]; + if (!countryState) return; + if (countryState.buildStatus === 'queued' || countryState.buildStatus === 'building') { + return; // Already in progress + } + + // Set to queued + update((state) => ({ + ...state, + [req.country]: { + ...state[req.country], + buildStatus: 'queued', + files: [ + { + id: `${req.country}-${Date.now()}`, + filename: '', + size: 0, + builtAt: new Date().toISOString(), + version: 'unknown', + status: 'queued', + message: req.notes + }, + ...state[req.country].files + ] + } + })); + + // Simulate build lifecycle (replace with real WS call later) + // simulateBuild(req.country); + + sendMessage({ + type: 'firmware-request', + payload: { + mode: 'not_full-delay-build', + brand: req.country, + files: [], + param: (req.param as any[]) ?? [], + requester: req.requester + } + }); + }, + + /** + * Refresh the file list for a country. + */ + refreshFiles(country: string) { + // In production, fetch from server. No-op for now. + }, + + /** + * Reset the store (useful for testing). + */ + reset() { + const fresh: Record = {}; + for (const c of FIRMWARE_COUNTRIES) { + fresh[c.code] = { + country: c.code, + label: c.label, + buildStatus: 'idle', + files: [] + }; + } + set(fresh); + } + }; +} + +export const firmwareStore = createFirmwareStore(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Simulate a build lifecycle: queued → building → ready. + * This is a placeholder until the real backend is wired up. + */ +function simulateBuild(country: string) { + // queued → building (1s) + setTimeout(() => { + firmwareStore.update((state) => ({ + ...state, + [country]: { + ...state[country], + buildStatus: 'building', + files: state[country].files.map((f: FirmwareFile, i: number) => + i === 0 ? { ...f, status: 'building' as FirmwareBuildStatus } : f + ) + } + })); + }, 1000); + + // building → ready (3s) + setTimeout(() => { + const mockSize = 2_400_000 + Math.floor(Math.random() * 800_000); // ~2.4–3.2 MB + firmwareStore.update((state: Record) => ({ + ...state, + [country]: { + ...state[country], + buildStatus: 'ready', + files: state[country].files.map((f: FirmwareFile, i: number) => + i === 0 + ? { + ...f, + status: 'ready' as FirmwareBuildStatus, + size: mockSize, + builtAt: new Date().toISOString(), + downloadUrl: `#/download/${f.filename}` + } + : f + ) + } + })); + }, 4000); +} + +/** + * Format bytes into a human-readable string. + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +/** + * Format an ISO date string into a short local date-time. + */ +export function formatBuildDate(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return iso; + } +} + +/** + * Map a build status to a badge variant for the UI. + */ +export function statusBadgeVariant( + status: FirmwareBuildStatus +): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (status) { + case 'ready': + return 'default'; + case 'building': + return 'secondary'; + case 'failed': + return 'destructive'; + case 'queued': + case 'idle': + default: + return 'outline'; + } +} + +/** + * Map a build status to a human-readable label. + */ +export function statusLabel(status: FirmwareBuildStatus): string { + switch (status) { + case 'idle': + return 'Idle'; + case 'queued': + return 'Queued'; + case 'building': + return 'Building'; + case 'ready': + return 'Ready'; + case 'failed': + return 'Failed'; + } +} diff --git a/src/lib/core/stores/remoteShellStore.ts b/src/lib/core/stores/remoteShellStore.ts index 2ec8c4d..57385d8 100644 --- a/src/lib/core/stores/remoteShellStore.ts +++ b/src/lib/core/stores/remoteShellStore.ts @@ -5,9 +5,21 @@ import { addNotification } from './noti'; // Commands that modify device state — always require explicit user confirmation. const DANGEROUS_COMMAND_PREFIXES = [ - 'rm', 'reboot', 'reboot-bootloader', 'flash', 'format', - 'mount', 'unmount', 'dd', 'mkfs', 'wipe', - 'install', 'uninstall', 'pm ', 'am ', 'svc ', + 'rm', + 'reboot', + 'reboot-bootloader', + 'flash', + 'format', + 'mount', + 'unmount', + 'dd', + 'mkfs', + 'wipe', + 'install', + 'uninstall', + 'pm ', + 'am ', + 'svc ' ]; function isDangerousCommand(command: string): boolean { @@ -151,13 +163,15 @@ async function executeAndRespond(req: RemoteShellRequest) { * Uses the simpler executeCmd() which properly captures exitCode and stderr. */ async function executeDirect(req: RemoteShellRequest) { - const result = await executeCmd(req.commandInput); + const result = await executeCmd(req.commandInput, true); const output = result.output ?? ''; const error = result.error ?? ''; const exitCode = result.exitCode ?? (error ? 1 : 0); const success = exitCode === 0; - addNotification(`${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`); + addNotification( + `${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}` + ); remoteShellRequest.set({ ...req, diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index 88f930f..5f3c741 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -25,16 +25,304 @@ 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 priceSlotNamespaces = 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]: [] + })); + priceSlotNamespaces.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 + }; +} + +function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot { + const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1); + const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber; + + return { + slot, + name: sheetName || `PriceSlot${slot}`, + description: '', + kind: 'price', + header: [], + products: [] + }; +} + +function getPriceSlotSource(content: any) { + return ( + content?.priceSlots ?? + content?.priceslots ?? + content?.price_slots ?? + content?.slots ?? + content?.data ?? + content?.value ?? + content?.content ?? + content + ); +} + +function getPriceSlotItems(content: any): any[] { + const source = getPriceSlotSource(content); + + if (Array.isArray(source)) { + return source.flatMap((item) => { + if (Array.isArray(item?.sheet)) { + return item.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + return [item]; + }); + } + if (Array.isArray(source?.sheet)) { + return source.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source]; + if (typeof source === 'object' && source) { + return Object.entries(source).map(([key, value]) => ({ + ...(typeof value === 'object' && value ? value : {}), + name: (value as any)?.name ?? key + })); + } + + return []; +} + +export function handlePriceSlotsResponse(content: any) { + logger.debug('[PriceSlot] Raw backend response:', { + items: Array.isArray(content) ? content.length : undefined, + keys: + content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : [] + }); + const country = String( + content?.country ?? content?.Country ?? pendingPriceSlotsCountry + ).toLowerCase(); + const source = getPriceSlotSource(content); + const slotList = getPriceSlotItems(content); + + if (!country || slotList.length === 0) { + console.warn('[PriceSlot] No slot list found:', { + country, + sourceItems: Array.isArray(source) ? source.length : undefined + }); + priceSlotsError.set('No PriceSlot data found in backend response'); + priceSlotsLoading.set(false); + return; + } + + const normalizedSlots = slotList.map((slot, index) => + isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index) + ); + + if (normalizedSlots.length === 0) { + console.warn('[PriceSlot] Response did not include usable rows:', { + country, + slotListItems: slotList.length + }); + return; + } + + logger.debug('[PriceSlot] Normalized slots:', { + country, + slots: normalizedSlots.length, + firstSlot: normalizedSlots[0] + ? { + slot: normalizedSlots[0].slot, + name: normalizedSlots[0].name, + kind: normalizedSlots[0].kind, + products: normalizedSlots[0].products.length, + serviceRows: normalizedSlots[0].serviceRows?.length ?? 0 + } + : undefined + }); + + const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any)); + + if (loadedSlots.length > 0) { + priceSlots.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(slot.slot, slot); + } + for (const slot of loadedSlots) { + merged.set(slot.slot, slot); + } + + return { + ...data, + [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot) + }; + }); + } + priceSlotNamespaces.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(slot.slot, slot); + } + for (const slot of normalizedSlots) { + merged.set(slot.slot, 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 = getPriceSlotSource(content); + + if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true; + if (Array.isArray(source?.sheet)) { + return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')); + } + if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot'); + if (!Array.isArray(source)) return false; + return source.some( + (item) => + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) + ); +} + +function isPriceSlotNamespace(slot: any): slot is PriceSlot { + return ( + typeof slot?.slot === 'number' && + Array.isArray(slot?.products) && + slot.products.length === 0 && + Array.isArray(slot?.header) && + slot.header.length === 0 && + slot.name?.startsWith?.('PriceSlot') + ); +} export const countryPrimaryLanguageMap: Record = { THAI: 'Thai', @@ -79,11 +367,19 @@ 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, + { + // Column→language map for the new-layout-v2 sheet (menu name/desc rows). + language: Record; + // Column→language map for the name-desc-v2 sheet (Translations). Different + // namespace/sheet so the columns can differ from new-layout-v2; falls back + // to `language` when not set (countries where the two are identical). + nameDescLanguage?: 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 }, @@ -91,6 +387,7 @@ 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'] @@ -367,7 +672,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; @@ -383,7 +688,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( @@ -391,29 +698,43 @@ export function getPriceFromCells( cells: GristCell[], priceType: 'cash_price' | 'non_cash_price' = 'cash_price' ): string | null { + const colIdx = getPriceColumnIndex(country, priceType); + if (colIdx < 0) return null; + + // Find the cell with matching column index + const priceCell = cells.find((c) => c.coord?.col === colIdx); + return priceCell?.value ?? null; +} + +export function getPriceColumnIndex( + country: string, + priceType: 'cash_price' | 'non_cash_price' = 'cash_price' +): number { const headers = get(sheetPriceHeader)[country]; if (!headers || headers.length === 0) { logger.warn(`[getPriceFromCells] No header found for country: ${country}`); - return null; + return -1; } // 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); //logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames); if (colIdx < 0) { - logger.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames); - return null; + logger.warn( + `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, + possibleNames + ); + return -1; } - // Find the cell with matching column index - const priceCell = cells.find((c) => c.coord?.col === colIdx); - //logger.info(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell); - return priceCell?.value ?? null; + return colIdx; } // Store for tracking streaming state @@ -445,15 +766,20 @@ export const streamingRawData = writable< // Handler: raw_stream header (e.g., raw_stream_price) export function handleRawStreamHeader(subtype: string, payload: any) { - logger.info(`[RawStream] Header for ${subtype}:`, payload); + let targetSubtype = subtype; + const currentData = get(streamingRawData); + if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) { + targetSubtype = 'priceslot'; + } + + logger.info(`[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 || '', @@ -462,7 +788,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 || '', @@ -474,10 +800,18 @@ export function handleRawStreamHeader(subtype: string, payload: any) { // Handler: raw_stream chunk (e.g., raw_stream_chunk_price) export function handleRawStreamChunk(subtype: string, payload: any) { - logger.info(`[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'; + } + + logger.debug( + `[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`, + payload.raw?.length + ); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`); @@ -489,7 +823,7 @@ 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] @@ -505,25 +839,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) { streamingRawData.update((data) => ({ ...data, - [subtype]: { + [targetSubtype]: { ...streamData, country: payload.country || streamData.country, chunks: [...streamData.chunks, ...contentArray] } })); - logger.info(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`); + logger.info(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`); } // Handler: raw_stream end (e.g., raw_stream_end_price) export function handleRawStreamEnd(subtype: string, payload: any) { - logger.info(`[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'; + } + + logger.debug(`[RawStream] End payload for ${targetSubtype}:`, payload); + + const streamData = currentData[targetSubtype]; if (!streamData || streamData.request_id !== payload.request_id) { - logger.warn(`[RawStream] End received for unknown stream: ${subtype}`); + logger.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`); return; } @@ -555,18 +894,41 @@ export function handleRawStreamEnd(subtype: string, payload: any) { } } - logger.info(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`); + logger.info( + `[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 === 'priceslot') { + priceSlotsLoading.set(false); + } + + if (targetSubtype === 'price') { + const looksLikePriceSlot = chunks.some((item) => { + return ( + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').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; }); } @@ -601,8 +963,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']); - logger.info(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader); + const productCodeIdx = findHeaderIndex(effectiveHeader, [ + 'ProductCode', + 'Product_Code', + 'product_code', + 'Code' + ]); + logger.info( + `[SheetPrice] productCodeIdx from header:`, + productCodeIdx, + 'header:', + effectiveHeader + ); const priceByProductCode: Record = {}; // Track ALL rows per product code (for duplicates) @@ -703,7 +1075,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) { - logger.info(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3)); + logger.info( + `[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]; @@ -711,6 +1086,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) } } +export function handleSheetPriceResponse(country: string, content: any) { + const resolvedCountry = country || get(streamingRawData).price?.country || ''; + const chunks = Array.isArray(content) ? content : [content]; + processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks); + sheetPriceLoading.set(false); +} + // Reset sheet price stores export function resetSheetPriceStore() { sheetPriceStreamMeta.set(null); @@ -770,14 +1152,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) { - logger.info('[sheetStore] Cache is for different country:', data.country, '!= requested:', country); + logger.info( + '[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 || ''; - logger.info('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown'); + logger.info( + '[sheetStore] Loaded', + data.codes.length, + 'product codes from cache for', + data.country || 'unknown' + ); return true; } } @@ -799,7 +1191,13 @@ export function clearProductCodes() { export function handleListMenuResponse(payload: { codes: string[]; country?: string }) { // Use pending country if not in payload const country = payload.country || pendingProductCodesCountry; - logger.info('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes'); + logger.info( + '[sheetStore] Received list_menu_response for', + country, + ':', + payload.codes?.length, + 'codes' + ); if (payload && payload.codes) { existingProductCodes.set(new Set(payload.codes)); @@ -815,7 +1213,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str timestamp: Date.now() }) ); - logger.info('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country); + logger.info( + '[sheetStore] Saved', + payload.codes.length, + 'product codes to cache for', + country + ); } catch (e) { logger.warn('[sheetStore] Failed to save to cache:', e); } diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 7a71e7a..989a5ba 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -19,6 +19,7 @@ const ENABLE_WS_DEBUG: boolean = false; export const socketConnectionOfflineCount = writable(0); export const socketAlreadySendHeartbeat = writable(0); export const socketStore = writable(null); +export const wsAuthReady = writable(false); export const sharedKey = writable(null); @@ -54,6 +55,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise { }); } +export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise { + const openSocket = await waitForOpenSocket(timeoutMs); + if (!openSocket) return null; + if (get(wsAuthReady)) return openSocket; + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(null); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(openSocket); + }); + }); +} + export async function connectToWebsocket(id_token?: string) { if (browser) { // logger.info('connecting to ', env.PUBLIC_WSS); @@ -64,12 +90,13 @@ export async function connectToWebsocket(id_token?: string) { let ws_url = env.PUBLIC_WSS; socket = new WebSocket(ws_url); + wsAuthReady.set(false); sharedKey.set(null); const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair(); socket.addEventListener('open', async () => { socketStore.set(socket); - addNotification('INFO:Connected!'); + // addNotification('INFO:Connected!'); if (socket) { clearTimeout(reconnectTimeout); @@ -89,13 +116,11 @@ export async function connectToWebsocket(id_token?: string) { sendAuthInfoInterval = setInterval(async () => { if (get(sharedKey)) { + auth_data = get(authStore); + perms = get(permission); // Debug: check if auth_data has uid - logger.info('[WS Auth] Sending auth info with:', { - uid: auth_data?.uid, - name: auth_data?.displayName, - email: auth_data?.email - }); - await sendMessage({ + logger.info('[WS Auth] Sending auth info with [REDACTED]'); + const sent = await sendMessage({ type: 'auth', payload: { user: { @@ -106,11 +131,12 @@ export async function connectToWebsocket(id_token?: string) { } } }); + wsAuthReady.set(sent); clearInterval(sendAuthInfoInterval); } - }, 3000); + }, 2000); } - logger.info(socket); + // logger.info(socket); // heartbeat 10s socketCheck = setInterval(async () => { @@ -161,10 +187,12 @@ export async function connectToWebsocket(id_token?: string) { socket.addEventListener('close', () => { socketStore.set(null); + wsAuthReady.set(false); sharedKey.set(null); socket = null; clearInterval(socketCheck); + clearInterval(sendAuthInfoInterval); if (auth.currentUser && !socket) { logger.info('try reconnect websocket ...'); @@ -176,12 +204,13 @@ export async function connectToWebsocket(id_token?: string) { } }); - socket.addEventListener('error', (e) => { - // logger.info('WebSocket error: ', e); - socketStore.set(null); - sharedKey.set(null); - clearInterval(socketCheck); - }); + socket.addEventListener('error', (e) => { + // logger.info('WebSocket error: ', e); + socketStore.set(null); + wsAuthReady.set(false); + sharedKey.set(null); + clearInterval(socketCheck); + }); } catch (socket_error: any) { if (ENABLE_WS_DEBUG) { logger.error('WS_ERR', socket_error); diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts index dbf0a6c..22d8381 100644 --- a/src/lib/core/types/outMessage.ts +++ b/src/lib/core/types/outMessage.ts @@ -91,4 +91,25 @@ export type OutMessage = timestamp: number; timedOut?: boolean; }; + } + | { + type: 'firmware-request'; + payload: { + mode: 'not_full' | 'not_full-delay-build'; + brand: string; + files: string[]; + param: ('new-auto-inc-version' | 'save-build-date' | 'save-build-version-override')[]; + requester: string; + }; + } + | { + type: 'firmware-confirm'; + payload: { + confirm: boolean; + session: string; + }; + } + | { + type: 'firmware-versions'; + payload: {}; }; diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts index 0803966..7cbe33f 100644 --- a/src/lib/core/utils/crypto.ts +++ b/src/lib/core/utils/crypto.ts @@ -1,4 +1,14 @@ export class WebCryptoHelper { + private static bytesToBase64(bytes: Uint8Array) { + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + + return btoa(binary); + } + static async generateKeyPair() { const keyPair = await window.crypto.subtle.generateKey( { @@ -10,7 +20,7 @@ export class WebCryptoHelper { ); const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); - const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic))); + const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic)); return { privateKey: keyPair.privateKey, publicKeyBase64 }; } @@ -60,8 +70,8 @@ export class WebCryptoHelper { encodedText ); - const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer))); - const ivBase64 = btoa(String.fromCharCode(...iv)); + const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer)); + const ivBase64 = WebCryptoHelper.bytesToBase64(iv); return { ciphertext: ciphertextBase64, iv: ivBase64 }; } diff --git a/src/lib/core/utils/logger.ts b/src/lib/core/utils/logger.ts index e2c5fc4..c035eae 100644 --- a/src/lib/core/utils/logger.ts +++ b/src/lib/core/utils/logger.ts @@ -1,91 +1,225 @@ /** - * Lightweight logger that suppresses output in production. + * Structured logger powered by LogTape (@logtape/logtape). * - * In development (npm run dev): logs are shown with a prefix tag. - * In production (npm run build): all console output is suppressed - * so logs are never exposed to the browser inspector. + * Two tiers of logging: + * - **logger** — general-purpose logging (suppressed in production for security) + * - Dev: `trace`, `debug`, `info`, `warn`, `error` (configurable via PUBLIC_APP_LOG_LEVEL) + * - Prod: `info` by default (or what PUBLIC_APP_LOG_LEVEL says) + * - **inspector** — error-only logging, ALWAYS active (never suppressed). + * Use for security-critical events that must appear in the browser + * developer console (e.g. auth failures, API errors). * - * Usage: - * import { logger } from '$lib/core/utils/logger'; - * logger.info('hello', someVar); - * logger.error('failed', err); + * API (100 % backward compatible): + * logger.info('message', arg1, …) + * logger.error('Failed:', err) + * inspector.error('auth failure', uid) // always visible */ +import { configureSync, getConfig, getConsoleSink, withFilter, getLogger } from '@logtape/logtape'; +import type { LogLevel } from '@logtape/logtape'; -type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; +// --------------------------------------------------------------------------- +// LogTape configuration (idempotent — uses getConfig() not a local flag) +// --------------------------------------------------------------------------- -const LOG_LEVEL_RANK: Record = { - trace: -1, - debug: 0, - info: 1, - warn: 2, - error: 3 +/** + * Resolve the log level lazily (not at module-eval time). + * + * import.meta.env is undefined when Vite loads vite.config.ts, so we + * must not read it at the top level. Deferring to first use ensures + * Vite has finished initialising by the time we read it. + */ +function resolveLogLevel(): LogLevel { + const env = import.meta.env as Record | undefined; + const isDev = env?.DEV ?? (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'); + const configured = env?.PUBLIC_APP_LOG_LEVEL as string | undefined; + return (configured || (isDev ? 'trace' : 'info')) as LogLevel; +} + +function ensureConfigured() { + // Vite evaluates the config module twice during build (SSR + client). + // A local `_configured` boolean resets between evaluations, so we + // rely on LogTape's own global state instead. + if (getConfig() !== null) return; + configureSync({ + sinks: { + console: getConsoleSink(), + errorsOnly: withFilter(getConsoleSink(), 'error'), + }, + loggers: [ + { + category: ['supra-app'], + sinks: ['console'], + lowestLevel: resolveLogLevel(), + }, + { + category: ['supra-app', 'inspector'], + sinks: ['errorsOnly'], + lowestLevel: 'error', + }, + { + category: ['logtape', 'meta'], + sinks: ['console'], + lowestLevel: 'error', + }, + ], + }); +} + +// --------------------------------------------------------------------------- +// Internal LogTape loggers (lazy-init) +// --------------------------------------------------------------------------- + +function getBaseLogger() { + ensureConfigured(); + return getLogger(['supra-app']); +} + +function getInspectorLogger() { + ensureConfigured(); + return getLogger(['supra-app', 'inspector']); +} + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !(v instanceof Error) && !Array.isArray(v); +} + +/** + * Convert variadic args into a flat message string + optional extras. + * + * Patterns handled: + * logger.info('text') → message, no props + * logger.info('text', obj) → message, obj as props if obj is a plain Record + * logger.info('text', error) → error overload + * logger.info('text', arg1, arg2, …) → concatenated message + */ +function normalize( + args: unknown[], +): { message: string; error?: Error; properties?: Record } { + if (args.length === 0) return { message: '' }; + + const first = args[0]; + if (args.length === 1) { + if (first instanceof Error) return { message: first.message, error: first }; + return { message: String(first) }; + } + + if (args.length === 2 && isRecord(args[1])) { + return { message: String(first), properties: args[1] }; + } + + // error overload: logger.warn(msg, error) / logger.error('msg', err) + if (args.length === 2 && args[1] instanceof Error) { + return { message: String(first), error: args[1] as Error }; + } + + // multi-arg: concatenate + return { + message: args.map((a) => (typeof a === 'object' ? safeStringify(a) : String(a))).join(' '), + }; +} + +function safeStringify(v: unknown): string { + if (v instanceof Error) return `${v.name}: ${v.message}`; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- + +export const logger = { + trace(...args: unknown[]) { + const { message } = normalize(args); + getBaseLogger().trace(message); + }, + + debug(...args: unknown[]) { + const { message, properties } = normalize(args); + if (properties) { + getBaseLogger().debug(message, properties); + } else { + getBaseLogger().debug(message); + } + }, + + info(...args: unknown[]) { + const { message, properties } = normalize(args); + if (properties) { + getBaseLogger().info(message, properties); + } else { + getBaseLogger().info(message); + } + }, + + warn(...args: unknown[]) { + const { message, error, properties } = normalize(args); + if (properties) { + getBaseLogger().warn(message, properties); + } else if (error) { + getBaseLogger().warn(message, error); + } else { + getBaseLogger().warn(message); + } + }, + + error(...args: unknown[]) { + const { message, error, properties } = normalize(args); + if (error && properties) { + getBaseLogger().error(error, properties); + } else if (error) { + getBaseLogger().error(error); + } else if (properties) { + getBaseLogger().error(message, properties); + } else { + getBaseLogger().error(message); + } + }, }; -const ENV_LOG_LEVEL: LogLevel = - (import.meta.env.PUBLIC_APP_LOG_LEVEL as LogLevel) || 'info'; -const IS_DEV = import.meta.env.DEV; - -function shouldLog(level: LogLevel): boolean { - return IS_DEV && LOG_LEVEL_RANK[level] >= LOG_LEVEL_RANK[ENV_LOG_LEVEL]; -} - -function formatArgs(level: LogLevel, args: any[]): any[] { - return [`[${level.toUpperCase()}]`, ...args]; -} +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- /** - * Extract caller location (file:line) from a stack trace. - * Stack frame format: " at method (file:///path/file.ts:10:20)" - * The 3rd frame (index 2) is the caller of the method that created the Error. + * Inspector logger — always active, error-only, never suppressed. + * Messages are written via the same LogTape infrastructure but on a + * separate category (supra-app → inspector) whose sink is hard-filtered to + * `error` / `fatal` so security-sensitive events are always visible. */ -function getCallerLocation(): string { - try { - const stack = new Error().stack?.split('\n'); - if (!stack || stack.length < 3) return 'unknown'; - // stack[0] = "Error", stack[1] = " at getCallerLocation (...)", - // stack[2] = " at Logger.trace (...)" — skip both - const caller = stack[2]?.trim(); - // Extract path from common formats: - // "at method (file:///path/file.ts:10:20)" or "at file:///path/file.ts:10:20" - const match = caller.match(/(?:\(|at\s+)([^(]+?\.\w+):(\d+)/); - if (match) return `${match[1]}:${match[2]}`; - return caller || 'unknown'; - } catch { - return 'unknown'; - } -} - -class Logger { - trace(...args: any[]) { - if (shouldLog('trace')) { - const location = getCallerLocation(); - console.debug(`[TRACE:${location}]`, ...args); +export const inspector = { + error(...args: unknown[]) { + const { message, error, properties } = normalize(args); + if (error && properties) { + getInspectorLogger().error(error, properties); + } else if (error) { + getInspectorLogger().error(error); + } else if (properties) { + getInspectorLogger().error(message, properties); + } else { + getInspectorLogger().error(message); } - } + }, - debug(...args: any[]) { - if (shouldLog('debug')) { - console.debug(...formatArgs('debug', args)); + warn(...args: unknown[]) { + const { message, error } = normalize(args); + // inspector only logs errors; escalate to error level + if (error) { + getInspectorLogger().error(error); + } else if (message) { + getInspectorLogger().error(message); } - } + }, - info(...args: any[]) { - if (shouldLog('info')) { - console.info(...formatArgs('info', args)); - } - } - - warn(...args: any[]) { - if (shouldLog('warn')) { - console.warn(...formatArgs('warn', args)); - } - } - - error(...args: any[]) { - if (shouldLog('error')) { - console.error(...formatArgs('error', args)); - } - } -} - -export const logger = new Logger(); + info(...args: unknown[]) { + const { message } = normalize(args); + // promote to error so it passes the filter + if (message) getInspectorLogger().error(message); + }, +}; diff --git a/src/lib/core/utils/vite-logger.ts b/src/lib/core/utils/vite-logger.ts new file mode 100644 index 0000000..836e6f1 --- /dev/null +++ b/src/lib/core/utils/vite-logger.ts @@ -0,0 +1,70 @@ +/** + * Custom Vite logger that routes Vite's build/dev-server messages + * through our LogTape-powered logger for consistent formatting. + * + * Usage in vite.config.ts: + * import { createViteLogger } from './src/lib/core/utils/vite-logger'; + * export default defineConfig({ + * customLogger: createViteLogger(), + * // ... + * }); + */ +import type { LogOptions, LogErrorOptions } from 'vite'; +import type { Logger } from 'vite'; +import { logger } from './logger'; + +export function createViteLogger(): Logger { + const warnedMessages = new Set(); + + return { + get hasWarned() { + return warnedMessages.size > 0; + }, + + info(msg: string, options?: LogOptions) { + const prefix = options?.environment ? `[${options.environment}]` : ''; + if (prefix) { + logger.info(`${prefix} ${msg}`); + } else if (options?.timestamp) { + logger.info(`[vite] ${msg}`); + } else { + logger.info(msg); + } + }, + + warn(msg: string, options?: LogOptions) { + warnedMessages.add(msg); + const prefix = options?.environment ? `[${options.environment}]` : ''; + if (prefix) { + logger.warn(`${prefix} ${msg}`); + } else { + logger.warn(msg); + } + }, + + warnOnce(msg: string, options?: LogOptions) { + if (warnedMessages.has(msg)) return; + this.warn(msg, options); + }, + + error(msg: string, options?: LogErrorOptions) { + const prefix = options?.environment ? `[${options.environment}]` : ''; + const err = options?.error; + const fullMsg = prefix ? `${prefix} ${msg}` : msg; + if (err && err instanceof Error) { + logger.error(fullMsg, err); + } else { + logger.error(fullMsg); + } + }, + + clearScreen() { + // Let Vite handle screen clearing itself + }, + + hasErrorLogged(_error: Error | import('rollup').RollupError): boolean { + // Don't keep error state — always re-report + return false; + }, + }; +} diff --git a/src/lib/workers/androidPayload.worker.ts b/src/lib/workers/androidPayload.worker.ts new file mode 100644 index 0000000..c1408d0 --- /dev/null +++ b/src/lib/workers/androidPayload.worker.ts @@ -0,0 +1,63 @@ +/// +import { logger } from '$lib/core/utils/logger'; +let chunks: Uint8Array[] = []; +let totalBytes = 0; +let expectedLength = -1; + +self.onmessage = async (e) => { + if (e.data.type === 'CHUNK') { + const chunk = e.data.payload; + chunks.push(chunk); + totalBytes += chunk.length; + + // Loop to process complete messages + while (true) { + // 1. Try to get length header (need 4 bytes) + if (expectedLength === -1 && totalBytes >= 4) { + // Need a flat buffer just to read the header + const headerBuf = concatChunks(chunks, 4); + expectedLength = new DataView(headerBuf.buffer).getInt32(0, false); + + // Remove header bytes from our chunks array + removeBytes(4); + } + + // 2. Try to get payload + if (expectedLength !== -1 && totalBytes >= expectedLength) { + const payload = concatChunks(chunks, expectedLength); + removeBytes(expectedLength); + + self.postMessage({ type: 'DATA', payload }, [payload.buffer]); + + expectedLength = -1; // Reset for next message + } else { + break; // Wait for more data + } + } + } +}; + +// Helper: Concatenate only what we need +function concatChunks(chunks: Uint8Array[], length: number): Uint8Array { + const result = new Uint8Array(length); + let offset = 0; + for (let i = 0; i < chunks.length && offset < length; i++) { + const chunk = chunks[i]; + const toCopy = Math.min(chunk.length, length - offset); + result.set(chunk.slice(0, toCopy), offset); + // If we only used part of the chunk, we keep the rest in the array + if (toCopy < chunk.length) { + chunks[i] = chunk.slice(toCopy); + } else { + chunks.shift(); + i--; + } + offset += toCopy; + } + return result; +} + +// Helper: Update total count +function removeBytes(n: number) { + totalBytes -= n; +} diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte index 058b71c..ce27d8d 100644 --- a/src/routes/(authed)/+layout.svelte +++ b/src/routes/(authed)/+layout.svelte @@ -1,5 +1,6 @@ - + +
+ +
+
+ +
+

Firmware Requests

+

+ Request and download firmware builds per country +

+
+
+
+ + +
+
+ + + + + {#each FIRMWARE_COUNTRIES as country (country.code)} + + {country.label} + {#if allCountries[country.code]?.buildStatus === 'building'} + + {:else if allCountries[country.code]?.buildStatus === 'ready'} + + {/if} + + {/each} + + + {#each FIRMWARE_COUNTRIES as country (country.code)} + + {@render countryPanel(country.code)} + + {/each} + +
+ + +{#snippet countryPanel(countryCode: string)} + {@const state = allCountries[countryCode]} + {@const files = state?.files ?? []} + {@const buildInProgress = state?.buildStatus === 'queued' || state?.buildStatus === 'building'} + +
+ + + +
+
+ + {#if buildInProgress} + + {:else if state?.buildStatus === 'ready'} + + {:else if state?.buildStatus === 'failed'} + + {:else} + + {/if} + {state?.label ?? countryCode} + + Firmware build status and available files +
+ + {statusLabel(state?.buildStatus ?? 'idle')} + +
+
+ + {#if buildInProgress} +
+
+ Building firmware… + ~{state?.files[0]?.version ?? ''} +
+ +
+ {:else if files.length === 0} +
+ +
+

No firmware builds yet

+

+ Click "Request Build" to create a new firmware for {state?.label} +

+
+
+ {:else} + + + + + Filename + Version + Size + Built + Status + Download + + + + {#each files as file (file.id)} + {@const StatusIcon = statusIcon(file.status)} + + + {file.filename} + + + {file.version} + + + {formatFileSize(file.size)} + + + {formatBuildDate(file.builtAt)} + + +
+ + {statusLabel(file.status)} +
+
+ + {#if file.status === 'ready' && file.downloadUrl} + + {:else if file.status === 'building'} + + {:else} + + {/if} + +
+ {/each} +
+
+ {/if} +
+
+
+{/snippet} + + + + + + Request Firmware Build + + Request a new firmware build for + {allCountries[selectedCountry]?.label ?? selectedCountry}. You will be notified when the build is ready or need confirmation. + + + +
+
+ + + {#if latest_versions[selectedCountry].version == 'NO_DATA'} +

+ Note: this will use latest version provided from source. +

+ {/if} +
+ +
+ + + + {#if latest_versions[selectedCountry].date == 'NO_DATA'} +

Note: this will forcefully save today as build date.

+ {/if} +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte index a542a01..355e3a8 100644 --- a/src/routes/(authed)/recipe/material/+page.svelte +++ b/src/routes/(authed)/recipe/material/+page.svelte @@ -1,6 +1,6 @@ - @@ -461,21 +941,93 @@ {/if} -
- +
+
+ 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. + + + +
+ +
+
+
+
@@ -501,31 +1053,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. +

@@ -548,11 +1129,11 @@
- +
@@ -584,23 +1165,7 @@
-
-
- - -
+
@@ -620,21 +1185,6 @@

-
-
- - -
-
- - -
-
-
@@ -688,12 +1238,18 @@
- Cancel -
@@ -715,14 +1271,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. + + + +
+ +
+
+
+
@@ -593,23 +889,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'} - - Switch between list items and groups. Edit/Delete actions are explicit per row. - +
- +
@@ -617,12 +913,14 @@
{#if activeTab === 'list'} - Add Topping {:else} - Add Group {/if}
@@ -631,19 +929,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}