- Add /tools/video-mainpage page (main + brewing-page advertisement videos, date-gated, per-country, push to machine over ADB) + api/video-mainpage create/list/update proxies; sidebar entry "Main & Brewing Video" - Add catalog API proxies (catalog-create, catalog-list, catalog-banner, catalog-banner-image) - Sheet: overview/edit/add/priceslot/price updates, stores & services - Misc: adb, websocket/message handlers, crypto, recipe & brew tweaks Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
683 lines
18 KiB
TypeScript
683 lines
18 KiB
TypeScript
import { Adb, AdbDaemonTransport, encodeUtf8 } from '@yume-chan/adb';
|
|
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
|
import {
|
|
AdbDaemonWebUsbDevice,
|
|
AdbDaemonWebUsbDeviceManager,
|
|
AdbDaemonWebUsbDeviceObserver
|
|
} 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';
|
|
import { get } from 'svelte/store';
|
|
|
|
let syncConnection: any = null;
|
|
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;
|
|
}
|
|
|
|
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}`
|
|
);
|
|
}
|
|
|
|
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
|
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
|
console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
|
|
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);
|
|
if (connectAndroidServer) {
|
|
await connectToAndroidServer();
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function connectDeviceByCred(
|
|
device: AdbDaemonWebUsbDevice,
|
|
credStore: AdbWebCredentialStore,
|
|
connectAndroidServer = true
|
|
) {
|
|
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);
|
|
if (connectAndroidServer) {
|
|
await connectToAndroidServer();
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function saveAdbInstance(adb: Adb | undefined) {
|
|
await cleanupSync();
|
|
AdbInstance.instance = adb;
|
|
}
|
|
|
|
export function getAdbInstance() {
|
|
return AdbInstance.instance;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
//ExactReadable ended
|
|
if (e.message.includes('ExactReadable ended')) {
|
|
return {
|
|
output: '',
|
|
exitCode: 1,
|
|
error: 'ExactReadableEndedError'
|
|
};
|
|
}
|
|
|
|
console.error('error while execute command', e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export async function goToMachineHome() {
|
|
if (!getAdbInstance()) return;
|
|
try {
|
|
await executeCmd('input keyevent KEYCODE_HOME');
|
|
} catch (e) {
|
|
console.error('[goToMachineHome] error', e);
|
|
}
|
|
}
|
|
|
|
export async function disconnect() {
|
|
let instance = getAdbInstance();
|
|
if (instance) {
|
|
try {
|
|
await instance.close();
|
|
console.log('close instance');
|
|
} finally {
|
|
await saveAdbInstance(undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return await runSyncOperation(async () => {
|
|
let instance = getAdbInstance();
|
|
|
|
await cleanupSync();
|
|
|
|
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) {
|
|
let sync = await instance.sync();
|
|
const encoder = new TextEncoder();
|
|
|
|
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(encoder.encode(obj)));
|
|
controller.close();
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
|
|
let sync = await instance.sync();
|
|
|
|
// 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.
|
|
const file: ReadableStream<MaybeConsumable<Uint8Array>> = new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(data);
|
|
controller.close();
|
|
}
|
|
});
|
|
|
|
try {
|
|
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;
|
|
} catch (error) {
|
|
console.log('error while pushing binary to machine', error);
|
|
return false;
|
|
} finally {
|
|
await sync.dispose();
|
|
}
|
|
});
|
|
}
|
|
|
|
// NOTE: adb reverse is not work by unavailable features support
|
|
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;
|
|
}
|
|
|
|
const brewConnectionPort = env.PUBLIC_BREW_CONN_PORT || 'tcp:36588';
|
|
|
|
// add retry mechanism
|
|
const stream = await connectWithRetry(
|
|
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');
|
|
|
|
(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 ...');
|
|
}
|
|
})();
|
|
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 ?? ''}`);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// logcat stream
|
|
|
|
// TODO: screen mirror
|
|
export function getScrcpyBinaryFromSource() {
|
|
//https://github.com/Genymobile/scrcpy/releases
|
|
}
|