Supra_App/src/lib/core/adb/adb.ts

684 lines
18 KiB
TypeScript
Raw Normal View History

2026-02-17 14:30:02 +07:00
import { Adb, AdbDaemonTransport, encodeUtf8 } from '@yume-chan/adb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import {
AdbDaemonWebUsbDevice,
2026-02-17 14:30:02 +07:00
AdbDaemonWebUsbDeviceManager,
AdbDaemonWebUsbDeviceObserver
2026-02-17 14:30:02 +07:00
} from '@yume-chan/adb-daemon-webusb';
import { AdbInstance } from '../../../routes/state.svelte';
import { deviceCredentialManager } from './deviceCredManager';
import { Consumable, MaybeConsumable, ReadableStream } from '@yume-chan/stream-extra';
import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
import { addNotification } from '../stores/noti';
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
import { adbWriter } from '../stores/adbWriter';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
2026-06-09 10:50:59 +07:00
import { get } from 'svelte/store';
let syncConnection: any = null;
2026-06-09 10:50:59 +07:00
let syncOperation: Promise<unknown> = Promise.resolve();
let recipeMenuAdbConnectPromise: Promise<Adb | undefined> | null = null;
let recipeMenuAndroidServerConnectPromise: Promise<void> | null = null;
let recipeMenuAndroidServerRetryTimer: ReturnType<typeof setTimeout> | null = null;
async function runSyncOperation<T>(operation: () => Promise<T>) {
const run = syncOperation.then(operation, operation);
syncOperation = run.catch(() => {});
return await run;
}
function clearRecipeMenuAndroidServerRetry() {
if (recipeMenuAndroidServerRetryTimer) {
clearTimeout(recipeMenuAndroidServerRetryTimer);
recipeMenuAndroidServerRetryTimer = null;
}
}
function scheduleRecipeMenuAndroidServerReconnect(delayMs = 2000) {
if (recipeMenuAndroidServerRetryTimer || !getAdbInstance()) return;
recipeMenuAndroidServerRetryTimer = setTimeout(() => {
recipeMenuAndroidServerRetryTimer = null;
void connectToAndroidRecipeMenuServer(false);
}, delayMs);
}
async function connectRecipeMenuWebUsbDevice(
device: AdbDaemonWebUsbDevice,
credentialStore: AdbWebCredentialStore
) {
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credentialStore
});
const adb = new Adb(transport);
await saveAdbInstance(adb);
await connectToAndroidRecipeMenuServer();
return adb;
}
2026-02-17 14:30:02 +07:00
function isRecoverableError(error: any): boolean {
if (!error) return false;
const errorMessage = error.message ? String(error.message).toLowerCase() : '';
const errorName = error.name ? String(error.name).toLowerCase() : '';
// Network-related errors that are typically recoverable
const recoverablePatterns = [
'connection refused',
'connection reset',
'connection timeout',
'network is unreachable',
'host is unreachable',
'temporary failure',
'operation timed out',
'failed to connect',
'connection lost',
'broken pipe',
'socket closed',
'eof',
'end of file',
'disconnected'
];
for (const pattern of recoverablePatterns) {
if (errorMessage.includes(pattern) || errorName.includes(pattern)) {
return true;
}
}
if (
(error.name && error.name.includes('Error')) ||
error.name.includes('Exception') ||
error.name === 'IOError' ||
error.name === 'NetworkError'
) {
return true;
}
return false;
}
async function connectWithRetry<T>(
connectionFn: () => Promise<T>,
description: string,
maxRetries: number = 5,
baseDelayMs: number = 1000
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Connection timeout for ${description}`)), 10000);
});
const result = await Promise.race([connectionFn(), timeoutPromise]);
return result;
} catch (e) {
lastError = e;
if (attempt === maxRetries - 1) {
break;
}
if (!isRecoverableError(e)) {
break;
}
const delay = Math.min(baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error(
`failed to ${description} after ${maxRetries} attempts. Last error: ${lastError.message}`
);
}
2026-06-09 10:50:59 +07:00
export async function connnectViaWebUSB(connectAndroidServer = true) {
2026-02-17 14:30:02 +07:00
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
2026-02-17 14:30:02 +07:00
if (device) {
console.log('connect ', device.name);
try {
const credentialStore = new AdbWebCredentialStore();
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credentialStore
});
const adb = new Adb(transport);
await saveAdbInstance(adb);
2026-06-09 10:50:59 +07:00
if (connectAndroidServer) {
await connectToAndroidServer();
}
2026-02-17 14:30:02 +07:00
// save device info
await deviceCredentialManager.saveDeviceInfo(device);
} catch (e: any) {
console.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'
);
}
throw e;
2026-02-17 14:30:02 +07:00
}
}
}
export async function connectDeviceByCred(
device: AdbDaemonWebUsbDevice,
2026-06-09 10:50:59 +07:00
credStore: AdbWebCredentialStore,
connectAndroidServer = true
2026-02-17 14:30:02 +07:00
) {
try {
const connection = await device.connect();
const transport = await AdbDaemonTransport.authenticate({
connection: connection,
serial: device.serial,
credentialStore: credStore
});
const adb = new Adb(transport);
await saveAdbInstance(adb);
2026-06-09 10:50:59 +07:00
if (connectAndroidServer) {
await connectToAndroidServer();
}
2026-02-17 14:30:02 +07:00
return true;
} catch (error) {
throw error;
}
}
export async function saveAdbInstance(adb: Adb | undefined) {
await cleanupSync();
2026-02-17 14:30:02 +07:00
AdbInstance.instance = adb;
}
export function getAdbInstance() {
return AdbInstance.instance;
}
2026-06-09 10:50:59 +07:00
export async function connectRecipeMenuViaWebUSB() {
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
return currentInstance;
}
if (recipeMenuAdbConnectPromise) return await recipeMenuAdbConnectPromise;
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
console.log('recipe menu usb ok', 'usb' in globalThis.navigator);
if (device) {
console.log('recipe menu connect ', device.name);
try {
const credentialStore = new AdbWebCredentialStore();
recipeMenuAdbConnectPromise = connectRecipeMenuWebUsbDevice(device, credentialStore);
const adb = await recipeMenuAdbConnectPromise;
await deviceCredentialManager.saveDeviceInfo(device);
return adb;
} catch (e: any) {
console.error('recipe menu connect error', e);
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
'ERR:Device is already in use by another program, please close the program and try again'
);
}
throw e;
} finally {
recipeMenuAdbConnectPromise = null;
}
}
}
export async function connectRecipeMenuDeviceByCred(
device: AdbDaemonWebUsbDevice,
credStore: AdbWebCredentialStore
) {
const currentInstance = getAdbInstance();
if (currentInstance) {
await connectToAndroidRecipeMenuServer();
return true;
}
if (recipeMenuAdbConnectPromise) {
await recipeMenuAdbConnectPromise;
return Boolean(getAdbInstance());
}
try {
recipeMenuAdbConnectPromise = connectRecipeMenuWebUsbDevice(device, credStore);
await recipeMenuAdbConnectPromise;
return true;
} catch (error) {
throw error;
} finally {
recipeMenuAdbConnectPromise = null;
}
}
export async function reconnectAndroidRecipeMenuServer() {
await connectToAndroidRecipeMenuServer(true);
}
export async function sendRecipeMenuFileToAndroid(recipe: any) {
return await sendRecipeMenuMessageToAndroid({
type: 'save_recipe_menu_file',
payload: {
time: new Date().toLocaleTimeString(),
data: recipe
}
});
}
export async function sendRecipeMenuMessageToAndroid(message: any) {
let writer: any = get(adbWriter);
if (!writer) {
if (getAdbInstance()) {
await connectToAndroidRecipeMenuServer(false);
} else {
await connectRecipeMenuViaWebUSB();
}
writer = get(adbWriter);
}
if (!writer) {
addNotification('ERR:No active Android recipe connection');
return false;
}
try {
const encoder = new TextEncoder();
await writer.write(encoder.encode(JSON.stringify(message) + '\n'));
console.log('recipe menu sent! ', JSON.stringify(message).length);
return true;
} catch (error) {
console.error('recipe menu write failed', error);
addNotification(`ERR:Failed to send recipe menu\n${error}`);
return false;
}
}
2026-02-17 14:30:02 +07:00
export async function executeCmd(command: string) {
let instance = getAdbInstance();
if (!instance) {
console.error('instance not found');
return {};
}
try {
if (instance?.subprocess.shellProtocol?.isSupported) {
const result = await instance.subprocess.shellProtocol.spawnWaitText(command);
return {
output: result.stdout,
error: result.stderr,
exitCode: result.exitCode
};
} else {
const process = await instance.subprocess.noneProtocol.spawn(command);
const reader = process.output.getReader();
const chunks = [];
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
return {
output: chunks.join('')
};
}
} catch (e: any) {
// console.log(e.message);
2026-02-17 14:30:02 +07:00
//ExactReadable ended
if (e.message.includes('ExactReadable ended')) {
return {
output: '',
exitCode: 1,
2026-02-17 14:30:02 +07:00
error: 'ExactReadableEndedError'
};
}
console.error('error while execute command', e);
return {};
}
}
2026-06-16 11:30:23 +07:00
export async function goToMachineHome() {
if (!getAdbInstance()) return;
try {
await executeCmd('input keyevent KEYCODE_HOME');
} catch (e) {
console.error('[goToMachineHome] error', e);
}
}
2026-02-17 14:30:02 +07:00
export async function disconnect() {
let instance = getAdbInstance();
if (instance) {
try {
await instance.close();
console.log('close instance');
} finally {
await saveAdbInstance(undefined);
}
2026-02-17 14:30:02 +07:00
}
}
export async function cleanupSync() {
if (syncConnection) {
try {
await syncConnection.dispose();
} catch (e) {
console.error('error on dispose sync', e);
}
}
syncConnection = null;
}
export async function pull(filename: string, timeoutMs: number = 5000) {
2026-06-09 10:50:59 +07:00
return await runSyncOperation(async () => {
let instance = getAdbInstance();
2026-02-17 14:30:02 +07:00
2026-06-09 10:50:59 +07:00
await cleanupSync();
2026-02-17 14:30:02 +07:00
2026-06-09 10:50:59 +07:00
try {
if (instance) {
let chunkList: Uint8Array<ArrayBufferLike>[] = [];
const syncProm = instance.sync();
const timeoutProm = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('sync timeout')), timeoutMs);
});
syncConnection = await Promise.race([syncProm, timeoutProm]);
const content = syncConnection.read(filename);
let result_string = '';
for await (const chunk of content) {
result_string += new TextDecoder().decode(chunk);
}
return result_string;
}
} catch (pull_error: any) {
console.log('pulling error', pull_error);
} finally {
await cleanupSync();
}
});
}
export async function push(path: string, obj: string) {
return await runSyncOperation(async () => {
let instance = getAdbInstance();
if (instance) {
2026-06-09 10:50:59 +07:00
let sync = await instance.sync();
const encoder = new TextEncoder();
2026-06-09 10:50:59 +07:00
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(encoder.encode(obj)));
controller.close();
}
});
2026-06-09 10:50:59 +07:00
try {
console.log('support push v2', sync.supportsSendReceiveV2);
await sync.write({
filename: path,
file
});
} catch (error) {
console.log('error while trying to write to machine', error);
} finally {
await sync.dispose();
2026-02-17 14:30:02 +07:00
}
}
2026-06-09 10:50:59 +07:00
});
2026-02-17 14:30:02 +07:00
}
2026-06-09 10:50:59 +07:00
// Push a binary file (e.g. an .mp4 video) to the machine. Unlike push() which
// text-encodes a string, this streams raw bytes in chunks so binary data is not
// corrupted, and reports progress.
export async function pushBinary(
path: string,
data: Uint8Array,
onProgress?: (sent: number, total: number) => void
): Promise<boolean> {
return await runSyncOperation(async () => {
let instance = getAdbInstance();
if (!instance) return false;
const total = data.byteLength;
onProgress?.(0, total);
2026-02-17 14:30:02 +07:00
let sync = await instance.sync();
2026-06-09 10:50:59 +07:00
// Mirror the working text push(): a single enqueue then close. @yume-chan
// packetizes internally for the ADB protocol; a multi-chunk or pull-based
// stream can stall the transfer.
2026-02-17 14:30:02 +07:00
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
start(controller) {
2026-06-09 10:50:59 +07:00
controller.enqueue(data);
2026-02-17 14:30:02 +07:00
controller.close();
}
});
try {
2026-06-09 10:50:59 +07:00
const writeProm = sync.write({ filename: path, file });
// Safety net so a stalled transfer can't hang the UI forever.
const timeoutProm = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('push write timeout (120s)')), 120000)
);
await Promise.race([writeProm, timeoutProm]);
onProgress?.(total, total);
return true;
2026-02-17 14:30:02 +07:00
} catch (error) {
2026-06-09 10:50:59 +07:00
console.log('error while pushing binary to machine', error);
return false;
2026-02-17 14:30:02 +07:00
} finally {
await sync.dispose();
}
2026-06-09 10:50:59 +07:00
});
2026-02-17 14:30:02 +07:00
}
// NOTE: adb reverse is not work by unavailable features support
2026-06-09 10:50:59 +07:00
export async function reconnectAndroidServer() {
await connectToAndroidServer();
}
async function connectToAndroidServer(maxRetries = 5) {
let lastError: any;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
let inst = getAdbInstance();
if (!inst) {
console.warn('adb instance not found');
return;
}
2026-06-09 10:50:59 +07:00
const brewConnectionPort = env.PUBLIC_BREW_CONN_PORT || 'tcp:36588';
// add retry mechanism
const stream = await connectWithRetry(
2026-06-09 10:50:59 +07:00
async () => inst.transport.connect(brewConnectionPort),
`connect to Android server port ${brewConnectionPort}`,
3,
500
);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
console.log('checking on writer ', writer);
adbWriter.set(writer);
if (writer) {
addNotification('INFO:Enable Brewing Mode T on machine');
2026-06-09 10:50:59 +07:00
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
handleAdbPayload(new TextDecoder().decode(value));
}
} catch (e) {
console.error('read error', e);
if (isRecoverableError(e)) {
void connectToAndroidServer();
}
} finally {
adbWriter.set(null);
addNotification('WARN:Brewing Mode T Offline ...');
}
2026-06-09 10:50:59 +07:00
})();
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);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
} else {
throw new Error('Brewing Mode T unavailable after all retries');
}
}
} catch (err) {
lastError = err;
if (attempt == maxRetries - 1) {
break;
}
if (!isRecoverableError(err)) {
break;
}
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
if (lastError) {
console.error('Connection failed. Suspect java running or not', lastError);
addNotification(`ERR:Fail to enable brewing mode T\n${lastError.message ?? ''}`);
}
}
2026-06-09 10:50:59 +07:00
async function connectToAndroidRecipeMenuServer(notifyFailure = true, retryOnFailure = false) {
if (recipeMenuAndroidServerConnectPromise) return recipeMenuAndroidServerConnectPromise;
recipeMenuAndroidServerConnectPromise = connectToAndroidRecipeMenuServerOnce(
notifyFailure,
retryOnFailure
).finally(() => {
recipeMenuAndroidServerConnectPromise = null;
});
return recipeMenuAndroidServerConnectPromise;
}
async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryOnFailure = false) {
try {
let inst = getAdbInstance();
if (!inst) {
console.warn('recipe menu adb instance not found');
return;
}
const brewConnectionPort = env.PUBLIC_BREW_CONN_PORT || 'tcp:36588';
clearRecipeMenuAndroidServerRetry();
const stream = await inst.transport.connect(brewConnectionPort);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
console.log('checking recipe menu writer ', writer);
adbWriter.set(writer);
if (writer) {
addNotification('INFO:Enable Android recipe menu channel');
} else {
addNotification('WARN:Android recipe menu channel unavailable');
setTimeout(async () => {
console.log('reconnecting android recipe menu server');
await connectToAndroidRecipeMenuServer();
}, 5000);
}
(async () => {
try {
let messageBuffer = '';
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const decoded = decoder.decode(value, { stream: true });
console.log('[ADB Reader] Received raw:', decoded.slice(0, 200));
messageBuffer += decoded;
const messages = messageBuffer.split('\n');
messageBuffer = messages.pop() ?? '';
for (const message of messages) {
const trimmedMessage = message.trim();
if (trimmedMessage) {
console.log('[ADB Reader] Processing message:', trimmedMessage.slice(0, 200));
handleAdbPayload(trimmedMessage);
}
}
}
const remainingMessage = messageBuffer.trim();
if (remainingMessage) {
handleAdbPayload(remainingMessage);
}
} catch (e) {
console.error('recipe menu read error', e);
} finally {
adbWriter.set(null);
addNotification('WARN:Android recipe menu channel offline ...');
if (retryOnFailure) {
scheduleRecipeMenuAndroidServerReconnect();
}
}
})();
} catch (err) {
console.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();
}
}
}
2026-02-17 14:30:02 +07:00
// logcat stream
// TODO: screen mirror
export function getScrcpyBinaryFromSource() {
//https://github.com/Genymobile/scrcpy/releases
}