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}
+
+
+
+
+
+
+ {pending.reason}
+
+
+ Press Reconnect to restore the connection.
+
+
+
+
+
+
+
+
+
+{/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;