diff --git a/.vscode/settings.json b/.vscode/settings.json index 3797c7bd..39fbab02 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "DESERIALIZERS", "ebml", "Embedder", + "entrypoints", "fflate", "fluentui", "genymobile", @@ -49,6 +50,7 @@ "transferables", "tsbuildinfo", "typeof", + "undici", "webadb", "webcodecs", "webm", diff --git a/apps/demo/next.config.js b/apps/demo/next.config.js index 4ccc643c..b7e13d26 100644 --- a/apps/demo/next.config.js +++ b/apps/demo/next.config.js @@ -64,7 +64,7 @@ module.exports = withPwa( }, { key: "Cross-Origin-Embedder-Policy", - value: "require-corp", + value: "credentialless", }, ], }, diff --git a/apps/demo/package.json b/apps/demo/package.json index cd566d31..c457bf62 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -32,6 +32,7 @@ "@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-saver": "^2.0.6", "@yume-chan/struct": "workspace:^0.0.19", + "@yume-chan/undici-browser": "5.21.2-mod.9", "fflate": "^0.7.4", "mobx": "^6.7.0", "mobx-react-lite": "^3.4.3", diff --git a/apps/demo/src/pages/_app.tsx b/apps/demo/src/pages/_app.tsx index 50294b01..1d15dd24 100644 --- a/apps/demo/src/pages/_app.tsx +++ b/apps/demo/src/pages/_app.tsx @@ -8,6 +8,7 @@ import { } from "@fluentui/react"; import { makeStyles, mergeClasses, shorthands } from "@griffel/react"; import type { AppProps } from "next/app"; +import getConfig from "next/config"; import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -16,7 +17,6 @@ import { Connect, ErrorDialogProvider } from "../components"; import "../styles/globals.css"; import { Icons } from "../utils"; import { register as registerIcons } from "../utils/icons"; -import getConfig from "next/config"; registerIcons(); @@ -71,6 +71,11 @@ const ROUTES = [ icon: Icons.Power, name: "Power Menu", }, + { + url: "/chrome-devtools", + icon: Icons.WindowDevTools, + name: "Chrome Remote Debugging", + }, { url: "/bug-report", icon: Icons.Bug, @@ -136,6 +141,10 @@ function App({ Component, pageProps }: AppProps) { const router = useRouter(); + if ("noLayout" in Component) { + return ; + } + return ( diff --git a/apps/demo/src/pages/chrome-devtools-frame.tsx b/apps/demo/src/pages/chrome-devtools-frame.tsx new file mode 100644 index 00000000..5e10b3d9 --- /dev/null +++ b/apps/demo/src/pages/chrome-devtools-frame.tsx @@ -0,0 +1,98 @@ +import { useEffect } from "react"; + +function ChromeDevToolsFrame() { + useEffect(() => { + var WebSocketOriginal = globalThis.WebSocket; + globalThis.WebSocket = class WebSocket extends EventTarget { + public static readonly CONNECTING: 0 = 0; + public static readonly OPEN: 1 = 1; + public static readonly CLOSING: 2 = 2; + public static readonly CLOSED: 3 = 3; + + public readonly CONNECTING: 0 = 0; + public readonly OPEN: 1 = 1; + public readonly CLOSING: 2 = 2; + public readonly CLOSED: 3 = 3; + + public binaryType: BinaryType = "arraybuffer"; + public readonly bufferedAmount: number = 0; + public readonly extensions: string = ""; + + public readonly protocol: string = ""; + public readonly readyState: number = 1; + public readonly url: string; + + private _port: MessagePort; + + public onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = + null; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/error_event) */ + public onerror: ((this: WebSocket, ev: Event) => any) | null = null; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/message_event) */ + public onmessage: + | ((this: WebSocket, ev: MessageEvent) => any) + | null = null; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/open_event) */ + public onopen: ((this: WebSocket, ev: Event) => any) | null = null; + + constructor(url: string) { + super(); + + console.log("WebSocket constructor", url); + this.url = url; + + var channel = new MessageChannel(); + this._port = channel.port1; + + if (url.includes("/_next/")) { + this._port.close(); + // @ts-ignore + return new WebSocketOriginal(url); + } + + this._port.onmessage = (e) => { + switch (e.data.type) { + case "open": + this.onopen?.(new Event("open")); + break; + case "message": + this.onmessage?.( + new MessageEvent("message", { + data: e.data.message, + }) + ); + break; + case "close": + this.onclose?.(new CloseEvent("close")); + this._port.close(); + break; + } + }; + window.postMessage({ type: "AdbWebSocket", url }, "*", [ + channel.port2, + ]); + } + + send(data: ArrayBuffer) { + this._port.postMessage({ type: "message", message: data }); + } + + public close() { + this._port.postMessage({ type: "close" }); + this._port.close(); + } + } as typeof WebSocket; + console.log("WebSocket hooked"); + + const script = document.createElement("script"); + script.type = "module"; + script.src = new URLSearchParams(location.search).get( + "script" + ) as string; + document.body.appendChild(script); + }, []); + + return null; +} +ChromeDevToolsFrame.noLayout = true; +export default ChromeDevToolsFrame; diff --git a/apps/demo/src/pages/chrome-devtools.tsx b/apps/demo/src/pages/chrome-devtools.tsx new file mode 100644 index 00000000..793c91d5 --- /dev/null +++ b/apps/demo/src/pages/chrome-devtools.tsx @@ -0,0 +1,406 @@ +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 { + const result = await this._reader.read(); + if (result.done) { + this.emit("end"); + } else { + this.push(result.value); + } + } + + 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("?"); + const script = frontendBase.startsWith( + "https://aka.ms/docs-landing-page/serve_rev/" + ) + ? // Edge + frontendBase + .replace( + "https://aka.ms/docs-landing-page/serve_rev/", + "https://devtools.azureedge.net/serve_file/" + ) + .replace("inspector.html", "entrypoints/inspector/inspector.js") + : // Chrome + frontendBase.replace( + "inspector.html", + "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]+" | 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; diff --git a/apps/demo/src/utils/icons.tsx b/apps/demo/src/utils/icons.tsx index 79b05a2f..459a7fbe 100644 --- a/apps/demo/src/utils/icons.tsx +++ b/apps/demo/src/utils/icons.tsx @@ -1,6 +1,7 @@ import { registerIcons } from "@fluentui/react"; import { AddCircleRegular, + WindowDevToolsRegular, ArrowClockwiseRegular, ArrowRotateClockwiseRegular, ArrowRotateCounterclockwiseRegular, @@ -108,6 +109,7 @@ export function register() { Warning: , WifiSettings: , WindowConsole: , + WindowDevTools: , // Required by @fluentui/react Checkmark: , @@ -179,6 +181,8 @@ const Icons = { Warning: "Warning", WifiSettings: "WifiSettings", WindowConsole: "WindowConsole", + WindowDevTools: "WindowDevTools", + Document20: "Document20", }; diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a42efaea..b3f4017a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -35,6 +35,7 @@ importers: '@yume-chan/stream-extra': workspace:^0.0.19 '@yume-chan/stream-saver': ^2.0.6 '@yume-chan/struct': workspace:^0.0.19 + '@yume-chan/undici-browser': 5.21.2-mod.9 eslint: ^8.36.0 eslint-config-next: 13.2.4 fflate: ^0.7.4 @@ -74,6 +75,7 @@ importers: '@yume-chan/stream-extra': link:../../libraries/stream-extra '@yume-chan/stream-saver': 2.0.6 '@yume-chan/struct': link:../../libraries/struct + '@yume-chan/undici-browser': 5.21.2-mod.9 fflate: 0.7.4 mobx: 6.8.0 mobx-react-lite: 3.4.3_woojb62cqeyk443mbl7msrwu2e @@ -2950,6 +2952,13 @@ packages: resolution: {integrity: sha512-DzRADjLoHcz18ocgGHvLIanapxygX3o9dlWwE32EUZqhyAsopfdvZ79ttR9+7pqAXIQamP9M4mbDy8hHgFKOIA==} dev: false + /@yume-chan/undici-browser/5.21.2-mod.9: + resolution: {integrity: sha512-rkpYsC6E9o26D4d38FYCT2I+9NgQPyMzIRCSjo9ZKNpnpP/QL02U9h57LqXGaY1l/nBRgoAlK6ctdMwVClAQjw==} + engines: {node: '>=12.18'} + dependencies: + busboy: 1.6.0 + dev: false + /abab/2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} dev: true @@ -3361,6 +3370,13 @@ packages: engines: {node: '>=6'} dev: true + /busboy/1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: false + /cacheable-request/2.1.4: resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==} dependencies: @@ -7293,6 +7309,11 @@ packages: internal-slot: 1.0.5 dev: true + /streamsearch/1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: false + /strict-uri-encode/1.1.0: resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==} engines: {node: '>=0.10.0'} diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 77113f1f..cdca8b52 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "6c01283b613e0e32d56a30dc621fbddac47bd437", + "pnpmShrinkwrapHash": "e7166d9b8db90548f97ba0dfa755b9b6a1334125", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/libraries/adb/src/socket/socket.ts b/libraries/adb/src/socket/socket.ts index 46325f7c..15af0cff 100644 --- a/libraries/adb/src/socket/socket.ts +++ b/libraries/adb/src/socket/socket.ts @@ -68,6 +68,11 @@ export class AdbSocketController return this._closed; } + private _endPromiseResolver = new PromiseResolver(); + public get end() { + return this._endPromiseResolver.promise; + } + private _socket: AdbSocket; public get socket() { return this._socket; @@ -99,6 +104,7 @@ export class AdbSocketController dispose: () => { // Error out the pending writes this._writePromise?.reject(new Error("Socket closed")); + this._endPromiseResolver.resolve(); }, }); @@ -196,6 +202,14 @@ export class AdbSocket return this._controller.writable; } + public get closed(): boolean { + return this._controller.closed; + } + + public get end(): Promise { + return this._controller.end; + } + public constructor(controller: AdbSocketController) { this._controller = controller; }