import { Link, Stack } from "@fluentui/react"; import { makeStyles } from "@griffel/react"; import { AdbSocket } from "@yume-chan/adb"; import { Consumable, ReadableStreamDefaultReader, WritableStreamDefaultWriter, } from "@yume-chan/stream-extra"; import { Agent, Client, Duplex, Pool, Symbols, WebSocket, request, setGlobalDispatcher, } from "@yume-chan/undici-browser"; import { action, makeAutoObservable, observable, reaction, runInAction, } from "mobx"; import { observer } from "mobx-react-lite"; import { NextPage } from "next"; import getConfig from "next/config"; import Head from "next/head"; import type { Socket } from "node:net"; import { useCallback, useEffect } from "react"; import { GLOBAL_STATE } from "../state"; import { RouteStackProps } from "../utils"; class AdbUndiciSocket extends Duplex { private _socket: AdbSocket; private _reader: ReadableStreamDefaultReader; private _writer: WritableStreamDefaultWriter>; constructor(socket: AdbSocket) { super(); this._socket = socket; this._reader = this._socket.readable.getReader(); this._writer = this._socket.writable.getWriter(); this._socket.end.then(() => this.emit("end")); } async _read(size: number): Promise { try { const result = await this._reader.read(); if (result.done) { this.emit("end"); } else { this.push(result.value); } } catch { //ignore } } async _write( chunk: any, encoding: BufferEncoding, callback: (error?: Error | null | undefined) => void ): Promise { const consumable = new Consumable(chunk); try { await this._writer.write(consumable); callback(); } catch (e) { callback(e as Error); } } async _final( callback: (error?: Error | null | undefined) => void ): Promise { await this._socket.close(); callback(); } async _destroy( error: Error | null, callback: (error: Error | null) => void ): Promise { await this._socket.close(); callback(error); } } const agent = new Agent({ factory(origin, opts) { const pool = new Pool(origin, { ...opts, factory(origin, opts) { const client = new Client(origin, opts); // Remote debugging validates `Host` header to defend against DNS rebinding attacks. // But we can only pass socket name using hostname, so we need to override it. (client as any)[Symbols.kHostHeader] = "Host: localhost\r\n"; return client; }, }); return pool; }, async connect(options, callback) { const socket = await GLOBAL_STATE.device!.createSocket( "localabstract:" + options.hostname ); callback(null, new AdbUndiciSocket(socket) as unknown as Socket); }, }); // WebSocket only uses global dispatcher setGlobalDispatcher(agent); interface Page { description: string; devtoolsFrontendUrl: string; id: string; title: string; type: string; url: string; webSocketDebuggerUrl: string; } interface Version { "Android-Package": string; Browser: string; "Protocol-Version": string; "User-Agent": string; "V8-Version": string; "WebKit-Version": string; webSocketDebuggerUrl: string; } // https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:chrome/browser/devtools/device/devtools_device_discovery.cc;l=36;drc=4651cec294d1542d6673a89190e192e20de03240 async function getPages(socket: string) { const response = await request(`http://${socket}/json`); const body = await response.body.json(); return body as Page[]; } async function getVersion(socket: string) { const response = await request(`http://${socket}/json/version`); const body = await response.body.json(); return body as Version; } async function focusPage(socket: string, page: Page) { await request(`http://${socket}/json/activate/${page.id}`); } async function closePage(socket: string, page: Page) { await request(`http://${socket}/json/close/${page.id}`); } const { publicRuntimeConfig: { basePath }, } = getConfig(); function getPopupParams(page: Page) { const frontendUrl = page.devtoolsFrontendUrl; const [frontendBase, params] = frontendUrl.split("?"); let script: string; if ( frontendBase.startsWith("https://chrome-devtools-frontend.appspot.com") ) { // For Chrome, use the specified version. script = frontendBase.replace( "inspector.html", "front_end/entrypoints/inspector/inspector.js" ); } else { // Otherwise wse a fixed version from Chrome's distribution, updated regularly. // Can't find Opera's own distribution. // Edge's distribution has only nightly versions. script = "https://chrome-devtools-frontend.appspot.com/serve_internal_file/@7edf0130cbb9f0611d524fe4870b2d4aa7f8279f/front_end/entrypoints/inspector/inspector.js"; } return { script, params, }; } interface Browser { socket: string; version: Version; pages: Page[]; } const STATE = makeAutoObservable( { browsers: [] as Browser[], intervalId: null as NodeJS.Timeout | null, visible: false, }, { browsers: observable.deep, } ); async function getBrowsers() { const device = GLOBAL_STATE.device!; const sockets = await device.subprocess.spawnAndWaitLegacy( `cat /proc/net/unix | grep -E "@chrome_devtools_remote|@chrome_devtools_remote_[0-9]+|@com.opera.browser.devtools|@com.opera.browser.beta.devtools" | awk '{print substr($8, 2)}'` ); const browsers: Browser[] = []; for (const socket of sockets.split("\n").filter(Boolean)) { if (browsers.some((browser) => browser.socket == socket)) { continue; } try { const version = await getVersion(socket); const pages = await getPages(socket); console.log(socket, version, pages); browsers.push({ socket, version, pages }); } catch (e) { console.error(socket, e); } } runInAction(() => { STATE.browsers = browsers; }); } reaction( () => [GLOBAL_STATE.device, STATE.visible] as const, ([device, visible]) => { if (!device || !visible) { STATE.browsers = []; if (STATE.intervalId) { clearInterval(STATE.intervalId); STATE.intervalId = null; } return; } STATE.intervalId = setInterval(() => { getBrowsers(); }, 5000); getBrowsers(); } ); function getBrowserName(version: Version) { const [name, versionNumber] = version.Browser.split("/"); return `${name} (${versionNumber})`; } const useClasses = makeStyles({ header: { marginTop: "4px", marginBottom: "4px", }, url: { marginLeft: "8px", color: "#999", }, link: { marginRight: "12px", }, }); const ChromeDevToolsPage: NextPage = observer(function ChromeDevTools() { const classes = useClasses(); useEffect(() => { runInAction(() => { STATE.visible = true; }); return action(() => { STATE.visible = false; }); }, []); const handleInspectClick = useCallback((socket: string, page: Page) => { const { script, params } = getPopupParams(page); const childWindow = window.open( `${basePath}/chrome-devtools-frame?script=${script}&${params}`, "_blank", "popup" )!; childWindow.addEventListener("message", (e) => { if ( typeof e.data !== "object" || !"type in e.data" || e.data.type !== "AdbWebSocket" ) { return; } const url = new URL(e.data.url as string); url.host = socket; const port = e.ports[0]; const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; ws.onopen = () => { port.postMessage({ type: "open" }); }; ws.onclose = () => { port.postMessage({ type: "close" }); port.close(); }; ws.onmessage = (e) => { const { data } = e; port.postMessage({ type: "message", message: data, }); }; port.onmessage = (e) => { switch (e.data.type) { case "message": ws.send(e.data.message); break; case "close": ws.close(); break; } }; childWindow.addEventListener("close", () => { ws.close(); }); window.addEventListener("beforeunload", () => { port.postMessage({ type: "close" }); port.close(); }); }); }, []); const handleFocusClick = useCallback((socket: string, page: Page) => { focusPage(socket, page); }, []); const handleCloseClick = useCallback((socket: string, page: Page) => { closePage(socket, page); getBrowsers(); }, []); return ( Chrome Remote Debugging - Tango {STATE.browsers.map((browser) => ( <> {browser.version && (

{getBrowserName(browser.version)}

)} {browser.pages.map((page) => (
{page.title ? ( ) : ( No Title )} {page.url || No URL}
handleInspectClick(browser.socket, page) } > Inspect handleFocusClick(browser.socket, page) } > Focus handleCloseClick(browser.socket, page) } > Close
))} ))}
); }); export default ChromeDevToolsPage;