feat(demo): add chrome remote debugger (#537)

This commit is contained in:
Simon Chan 2023-04-12 13:52:37 +08:00 committed by GitHub
parent 094b859791
commit c6bd9e5304
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 558 additions and 3 deletions

View file

@ -17,6 +17,7 @@
"DESERIALIZERS", "DESERIALIZERS",
"ebml", "ebml",
"Embedder", "Embedder",
"entrypoints",
"fflate", "fflate",
"fluentui", "fluentui",
"genymobile", "genymobile",
@ -49,6 +50,7 @@
"transferables", "transferables",
"tsbuildinfo", "tsbuildinfo",
"typeof", "typeof",
"undici",
"webadb", "webadb",
"webcodecs", "webcodecs",
"webm", "webm",

View file

@ -64,7 +64,7 @@ module.exports = withPwa(
}, },
{ {
key: "Cross-Origin-Embedder-Policy", key: "Cross-Origin-Embedder-Policy",
value: "require-corp", value: "credentialless",
}, },
], ],
}, },

View file

@ -32,6 +32,7 @@
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/stream-saver": "^2.0.6", "@yume-chan/stream-saver": "^2.0.6",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"@yume-chan/undici-browser": "5.21.2-mod.9",
"fflate": "^0.7.4", "fflate": "^0.7.4",
"mobx": "^6.7.0", "mobx": "^6.7.0",
"mobx-react-lite": "^3.4.3", "mobx-react-lite": "^3.4.3",

View file

@ -8,6 +8,7 @@ import {
} from "@fluentui/react"; } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react"; import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import getConfig from "next/config";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -16,7 +17,6 @@ import { Connect, ErrorDialogProvider } from "../components";
import "../styles/globals.css"; import "../styles/globals.css";
import { Icons } from "../utils"; import { Icons } from "../utils";
import { register as registerIcons } from "../utils/icons"; import { register as registerIcons } from "../utils/icons";
import getConfig from "next/config";
registerIcons(); registerIcons();
@ -71,6 +71,11 @@ const ROUTES = [
icon: Icons.Power, icon: Icons.Power,
name: "Power Menu", name: "Power Menu",
}, },
{
url: "/chrome-devtools",
icon: Icons.WindowDevTools,
name: "Chrome Remote Debugging",
},
{ {
url: "/bug-report", url: "/bug-report",
icon: Icons.Bug, icon: Icons.Bug,
@ -136,6 +141,10 @@ function App({ Component, pageProps }: AppProps) {
const router = useRouter(); const router = useRouter();
if ("noLayout" in Component) {
return <Component {...pageProps} />;
}
return ( return (
<ErrorDialogProvider> <ErrorDialogProvider>
<Head> <Head>

View file

@ -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;

View file

@ -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<Uint8Array>;
private _writer: WritableStreamDefaultWriter<Consumable<Uint8Array>>;
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<void> {
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<void> {
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<void> {
await this._socket.close();
callback();
}
async _destroy(
error: Error | null,
callback: (error: Error | null) => void
): Promise<void> {
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 (
<Stack {...RouteStackProps}>
<Head>
<title>Chrome Remote Debugging - Tango</title>
</Head>
{STATE.browsers.map((browser) => (
<>
{browser.version && (
<h3 className={classes.header}>
{getBrowserName(browser.version)}
</h3>
)}
{browser.pages.map((page) => (
<div key={page.id}>
<div>
{page.title ? (
<span
dangerouslySetInnerHTML={{
__html: page.title,
}}
/>
) : (
<i>No Title</i>
)}
<span className={classes.url}>
{page.url || <i>No URL</i>}
</span>
</div>
<div>
<Link
className={classes.link}
onClick={() =>
handleInspectClick(browser.socket, page)
}
>
Inspect
</Link>
<Link
className={classes.link}
onClick={() =>
handleFocusClick(browser.socket, page)
}
>
Focus
</Link>
<Link
className={classes.link}
onClick={() =>
handleCloseClick(browser.socket, page)
}
>
Close
</Link>
</div>
</div>
))}
</>
))}
</Stack>
);
});
export default ChromeDevToolsPage;

View file

@ -1,6 +1,7 @@
import { registerIcons } from "@fluentui/react"; import { registerIcons } from "@fluentui/react";
import { import {
AddCircleRegular, AddCircleRegular,
WindowDevToolsRegular,
ArrowClockwiseRegular, ArrowClockwiseRegular,
ArrowRotateClockwiseRegular, ArrowRotateClockwiseRegular,
ArrowRotateCounterclockwiseRegular, ArrowRotateCounterclockwiseRegular,
@ -108,6 +109,7 @@ export function register() {
Warning: <WarningRegular style={STYLE} />, Warning: <WarningRegular style={STYLE} />,
WifiSettings: <WifiSettingsRegular style={STYLE} />, WifiSettings: <WifiSettingsRegular style={STYLE} />,
WindowConsole: <WindowConsoleRegular style={STYLE} />, WindowConsole: <WindowConsoleRegular style={STYLE} />,
WindowDevTools: <WindowDevToolsRegular style={STYLE} />,
// Required by @fluentui/react // Required by @fluentui/react
Checkmark: <CheckmarkRegular style={STYLE} />, Checkmark: <CheckmarkRegular style={STYLE} />,
@ -179,6 +181,8 @@ const Icons = {
Warning: "Warning", Warning: "Warning",
WifiSettings: "WifiSettings", WifiSettings: "WifiSettings",
WindowConsole: "WindowConsole", WindowConsole: "WindowConsole",
WindowDevTools: "WindowDevTools",
Document20: "Document20", Document20: "Document20",
}; };

View file

@ -35,6 +35,7 @@ importers:
'@yume-chan/stream-extra': workspace:^0.0.19 '@yume-chan/stream-extra': workspace:^0.0.19
'@yume-chan/stream-saver': ^2.0.6 '@yume-chan/stream-saver': ^2.0.6
'@yume-chan/struct': workspace:^0.0.19 '@yume-chan/struct': workspace:^0.0.19
'@yume-chan/undici-browser': 5.21.2-mod.9
eslint: ^8.36.0 eslint: ^8.36.0
eslint-config-next: 13.2.4 eslint-config-next: 13.2.4
fflate: ^0.7.4 fflate: ^0.7.4
@ -74,6 +75,7 @@ importers:
'@yume-chan/stream-extra': link:../../libraries/stream-extra '@yume-chan/stream-extra': link:../../libraries/stream-extra
'@yume-chan/stream-saver': 2.0.6 '@yume-chan/stream-saver': 2.0.6
'@yume-chan/struct': link:../../libraries/struct '@yume-chan/struct': link:../../libraries/struct
'@yume-chan/undici-browser': 5.21.2-mod.9
fflate: 0.7.4 fflate: 0.7.4
mobx: 6.8.0 mobx: 6.8.0
mobx-react-lite: 3.4.3_woojb62cqeyk443mbl7msrwu2e mobx-react-lite: 3.4.3_woojb62cqeyk443mbl7msrwu2e
@ -2950,6 +2952,13 @@ packages:
resolution: {integrity: sha512-DzRADjLoHcz18ocgGHvLIanapxygX3o9dlWwE32EUZqhyAsopfdvZ79ttR9+7pqAXIQamP9M4mbDy8hHgFKOIA==} resolution: {integrity: sha512-DzRADjLoHcz18ocgGHvLIanapxygX3o9dlWwE32EUZqhyAsopfdvZ79ttR9+7pqAXIQamP9M4mbDy8hHgFKOIA==}
dev: false 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: /abab/2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true dev: true
@ -3361,6 +3370,13 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true 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: /cacheable-request/2.1.4:
resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==} resolution: {integrity: sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==}
dependencies: dependencies:
@ -7293,6 +7309,11 @@ packages:
internal-slot: 1.0.5 internal-slot: 1.0.5
dev: true 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: /strict-uri-encode/1.1.0:
resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==} resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}

View file

@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{ {
"pnpmShrinkwrapHash": "6c01283b613e0e32d56a30dc621fbddac47bd437", "pnpmShrinkwrapHash": "e7166d9b8db90548f97ba0dfa755b9b6a1334125",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
} }

View file

@ -68,6 +68,11 @@ export class AdbSocketController
return this._closed; return this._closed;
} }
private _endPromiseResolver = new PromiseResolver<void>();
public get end() {
return this._endPromiseResolver.promise;
}
private _socket: AdbSocket; private _socket: AdbSocket;
public get socket() { public get socket() {
return this._socket; return this._socket;
@ -99,6 +104,7 @@ export class AdbSocketController
dispose: () => { dispose: () => {
// Error out the pending writes // Error out the pending writes
this._writePromise?.reject(new Error("Socket closed")); this._writePromise?.reject(new Error("Socket closed"));
this._endPromiseResolver.resolve();
}, },
}); });
@ -196,6 +202,14 @@ export class AdbSocket
return this._controller.writable; return this._controller.writable;
} }
public get closed(): boolean {
return this._controller.closed;
}
public get end(): Promise<void> {
return this._controller.end;
}
public constructor(controller: AdbSocketController) { public constructor(controller: AdbSocketController) {
this._controller = controller; this._controller = controller;
} }