diff --git a/src/lib/components/adb-reconnect-banner.svelte b/src/lib/components/adb-reconnect-banner.svelte new file mode 100644 index 0000000..b6df9a2 --- /dev/null +++ b/src/lib/components/adb-reconnect-banner.svelte @@ -0,0 +1,69 @@ + + +{#if pending} + +{/if} diff --git a/src/lib/components/recipe-details/recipe-detail.svelte b/src/lib/components/recipe-details/recipe-detail.svelte index 71fd2b6..e9a0430 100644 --- a/src/lib/components/recipe-details/recipe-detail.svelte +++ b/src/lib/components/recipe-details/recipe-detail.svelte @@ -32,7 +32,7 @@ import { sendCommand, sendReset } from '$lib/core/brew/command'; import { sendCommandRequest } from '$lib/core/handlers/ws_messageSender'; import { needPermission } from '$lib/core/handlers/permissionHandler'; - import { isAdbWriterAvailable } from '$lib/core/stores/adbWriter'; + import { isAdbWriterAlive } from '$lib/core/stores/adbWriter'; import { sendToAndroid } from '$lib/core/stores/adbWriter'; import { departmentStore } from '$lib/core/stores/departments'; @@ -169,7 +169,7 @@ // let inst = adb.getAdbInstance(); if (inst) { - logger.info('check adb writer', isAdbWriterAvailable()); + logger.info('check adb writer', isAdbWriterAlive()); recipeDetailDispatch('brewNow'); } else { logger.info('result check fail'); diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 94a4bf7..0b9192e 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -14,6 +14,7 @@ import { addNotification } from '../stores/noti'; import { handleAdbPayload } from '../handlers/adbPayloadHandler'; import { GlobalEventBus } from '../utils/eventBus'; import { adbWriter } 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'; @@ -340,6 +341,7 @@ export async function connectRecipeMenuDeviceByCred( } export async function reconnectAndroidRecipeMenuServer() { + adbWriter.set(null); await connectToAndroidRecipeMenuServer(true); } @@ -665,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(); } @@ -707,13 +712,23 @@ async function connectToAndroidServer(maxRetries = 5) { addNotification('INFO:Enable Brewing Mode T on machine'); (async () => { - while (true) { - const { value, done } = await reader.read(); - if (done) break; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; - // 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]); + // 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('Android server read error', e); + } finally { + adbWriter.set(null); + addNotification('WARN:Android server channel offline ...'); + reader.cancel().catch(() => {}); + // Prompt the user to reconnect instead of auto-reconnecting + adbReconnect.requestReconnect('Android server disconnected'); } })(); return; @@ -827,18 +842,16 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO adbWriter.set(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/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/utils/logger.ts b/src/lib/core/utils/logger.ts index 3d422d2..c035eae 100644 --- a/src/lib/core/utils/logger.ts +++ b/src/lib/core/utils/logger.ts @@ -15,18 +15,26 @@ * inspector.error('auth failure', uid) // always visible */ import { configureSync, getConfig, getConsoleSink, withFilter, getLogger } from '@logtape/logtape'; - -// --------------------------------------------------------------------------- -// Environment helpers -// --------------------------------------------------------------------------- - -const IS_DEV = import.meta.env.DEV; -const ENV_LOG_LEVEL = (import.meta.env.PUBLIC_APP_LOG_LEVEL as string) || (IS_DEV ? 'trace' : 'info'); +import type { LogLevel } from '@logtape/logtape'; // --------------------------------------------------------------------------- // LogTape configuration (idempotent — uses getConfig() not a local flag) // --------------------------------------------------------------------------- +/** + * 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 @@ -41,7 +49,7 @@ function ensureConfigured() { { category: ['supra-app'], sinks: ['console'], - lowestLevel: ENV_LOG_LEVEL as 'trace' | 'debug' | 'info' | 'warning' | 'error' | 'fatal', + lowestLevel: resolveLogLevel(), }, { category: ['supra-app', 'inspector'], diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte index 058b71c..fce9306 100644 --- a/src/routes/(authed)/+layout.svelte +++ b/src/routes/(authed)/+layout.svelte @@ -22,6 +22,7 @@ import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager'; import { browser } from '$app/environment'; import { onMount } from 'svelte'; + import AdbReconnectBanner from '$lib/components/adb-reconnect-banner.svelte'; let { children } = $props(); let websocketConnectedForUid = $state(''); @@ -144,3 +145,5 @@ {/if} + + diff --git a/src/routes/(authed)/tools/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte index 42485f9..eb19457 100644 --- a/src/routes/(authed)/tools/brew/+page.svelte +++ b/src/routes/(authed)/tools/brew/+page.svelte @@ -32,7 +32,8 @@ import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager'; import { afterNavigate, goto } from '$app/navigation'; import { env } from '$env/dynamic/public'; - import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter'; + import { adbWriter, isAdbWriterAlive, wasAndroidSocketEverAlive, sendToAndroid } from '$lib/core/stores/adbWriter'; + import { adbReconnect } from '$lib/core/stores/adbReconnectStore'; import { AdbInstance } from '../../../state.svelte'; import { setOnMenuSavedCallback, @@ -68,7 +69,7 @@ let recipeLoading = $state(false); let recipeAutoLoadAttempted = $state(false); let isAdbConnected = $derived(Boolean(AdbInstance.instance)); - let isAndroidSocketConnected = $derived(Boolean($adbWriter)); + let isAndroidSocketConnected = $derived(isAdbWriterAlive()); let isRecipeLoaded = $derived(Boolean(devRecipe)); // clear out event @@ -208,7 +209,7 @@ async function connectAdb() { try { if (adb.getAdbInstance()) { - if (!isAdbWriterAvailable()) { + if (!isAdbWriterAlive()) { await adb.reconnectAndroidServer(); } await loadBrewDataFromConnectedAdb(); @@ -240,7 +241,7 @@ async function tryAutoConnect() { try { if (adb.getAdbInstance()) { - if (!isAdbWriterAvailable()) { + if (!isAdbWriterAlive()) { await adb.reconnectAndroidServer(); } return true; @@ -335,7 +336,8 @@ } async function ensureAndroidSocket() { - if (isAdbWriterAvailable()) return true; + // Use the liveness check, not just "is object in store" + if (isAdbWriterAlive()) return true; if (!adb.getAdbInstance()) { addNotification('ERR:ADB is not connected'); @@ -344,7 +346,7 @@ await adb.reconnectAndroidServer(); - if (!isAdbWriterAvailable()) { + if (!isAdbWriterAlive()) { addNotification('ERR:Android socket is not connected'); return false; } @@ -354,7 +356,9 @@ afterNavigate(async () => { logger.info('after navigate brew'); - await startFetchRecipeFromMachine(); + // Data loading is handled by the $effect below — no need to + // duplicate it here. This hook is kept for future navigation- + // specific side effects only. }); function getMenuStatus(ms: number): RecipeOverview['status'] { @@ -838,14 +842,40 @@ $effect(() => { if (!isAdbConnected) { - recipeAutoLoadAttempted = false; + // Don't reset recipeAutoLoadAttempted here — that causes + // duplicate loads on back-and-forth navigation. return; } if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return; recipeAutoLoadAttempted = true; - void loadBrewDataFromConnectedAdb(); + + void (async () => { + if (!isAdbWriterAlive()) { + // Writer is null or stale. Decide what to do: + if (wasAndroidSocketEverAlive()) { + // Socket was alive before and died → show reconnect + // banner. isAdbWriterAlive() already triggered it + // if the writer was stale. + adbReconnect.requestReconnect('Android socket disconnected'); + return; + } + // First visit — auto-connect the Android server. + if (adb.getAdbInstance()) { + try { + await adb.reconnectAndroidServer(); + } catch { + // Connection failed — user can retry manually. + } + } + // Re-check after connect attempt + if (!isAdbWriterAlive()) { + return; + } + } + await loadBrewDataFromConnectedAdb(); + })(); }); diff --git a/src/routes/(authed)/tools/create-menu/+page.svelte b/src/routes/(authed)/tools/create-menu/+page.svelte index 209e78e..f1b1b84 100644 --- a/src/routes/(authed)/tools/create-menu/+page.svelte +++ b/src/routes/(authed)/tools/create-menu/+page.svelte @@ -14,7 +14,7 @@ import { addNotification } from '$lib/core/stores/noti'; import { referenceFromPage } from '$lib/core/stores/recipeStore'; import { env } from '$env/dynamic/public'; - import { adbWriter, isAdbWriterAvailable } from '$lib/core/stores/adbWriter'; + import { adbWriter, isAdbWriterAlive, wasAndroidSocketEverAlive } from '$lib/core/stores/adbWriter'; import { AdbInstance } from '../../../state.svelte'; import { categoryOptions, @@ -61,7 +61,7 @@ // ADB connection state let isAdbConnected = $derived(Boolean(AdbInstance.instance)); - let isAndroidSocketConnected = $derived(Boolean($adbWriter)); + let isAndroidSocketConnected = $derived(isAdbWriterAlive()); let isRecipeLoaded = $derived(Boolean(devRecipe)); // Setup popup state @@ -523,7 +523,7 @@ try { const connected = await adb.ensureAdbConnection(); if (connected) { - if (!isAdbWriterAvailable()) { + if (!isAdbWriterAlive()) { await adb.reconnectAndroidRecipeMenuServer(); } await loadRecipeFromMachine(); @@ -546,7 +546,7 @@ async function reconnectAndroidSocket() { try { await adb.reconnectAndroidRecipeMenuServer(); - if (isAdbWriterAvailable()) { + if (isAdbWriterAlive()) { addNotification('INFO:Android socket connected'); } else { addNotification('WARN:Android socket not connected'); @@ -622,11 +622,11 @@ } async function ensureAndroidSocket(): Promise { - if (isAdbWriterAvailable()) return true; + if (isAdbWriterAlive()) return true; try { await adb.reconnectAndroidRecipeMenuServer(); - if (isAdbWriterAvailable()) return true; + if (isAdbWriterAlive()) return true; } catch {} addNotification('WARN:Android socket not connected'); @@ -1447,7 +1447,8 @@ // Auto-load when ADB is connected $effect(() => { if (!isAdbConnected) { - recipeAutoLoadAttempted = false; + // Don't reset recipeAutoLoadAttempted — prevents duplicate loads + // on back-and-forth navigation. detectedCountry = ''; return; } @@ -1458,8 +1459,23 @@ // Auto-load recipe data and country (async () => { - if (!isAdbWriterAvailable()) { - await adb.reconnectAndroidRecipeMenuServer(); + if (!isAdbWriterAlive()) { + if (wasAndroidSocketEverAlive()) { + // Socket was alive before and died → reconnect banner + // already triggered by isAdbWriterAlive() if stale. + return; + } + // First visit — auto-connect the Android recipe menu server. + if (adb.getAdbInstance()) { + try { + await adb.reconnectAndroidRecipeMenuServer(); + } catch { + // Connection failed — user can retry manually. + } + } + if (!isAdbWriterAlive()) { + return; + } } await loadCountryFromMachine(); await loadStagedMenusFromAndroid(); diff --git a/src/routes/api/sheet/stream/+server.ts b/src/routes/api/sheet/stream/+server.ts index 909771e..112aaa7 100644 --- a/src/routes/api/sheet/stream/+server.ts +++ b/src/routes/api/sheet/stream/+server.ts @@ -38,9 +38,10 @@ export async function POST({ request }: RequestEvent) { // Initialize or update batch if (!streamCache.has(batch_id)) { - const cappedTotal = typeof total_chunks === 'number' && total_chunks > 0 - ? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH) - : MAX_CHUNKS_PER_BATCH; + const cappedTotal = + typeof total_chunks === 'number' && total_chunks > 0 + ? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH) + : MAX_CHUNKS_PER_BATCH; streamCache.set(batch_id, { chunks: [], status: 'collecting', @@ -63,7 +64,9 @@ export async function POST({ request }: RequestEvent) { logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`); } else if (msg === 'end') { batch.status = 'complete'; - logger.info(`[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`); + logger.info( + `[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}` + ); } else if (msg === 'error') { batch.status = 'error'; batch.error = content;