blob: 8ef7d88ac5c396830c467fad9ea66666228db2f0 [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {assertUnreachable} from '../../../base/assert';
import {showPopupWindow} from '../../../base/popup_window';
import {exists} from '../../../base/utils';
import {Button, ButtonVariant} from '../../../widgets/button';
import {Intent} from '../../../widgets/common';
import {TextInput} from '../../../widgets/text_input';
import {RadioGroup} from '../../../widgets/radio_group';
import type {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
import {
WDP_TRACK_DEVICES_SCHEMA,
type WdpDevice,
} from '../../dev.perfetto.RecordTraceV2/adb/web_device_proxy/wdp_schema';
import {AdbWebsocketDevice} from '../../dev.perfetto.RecordTraceV2/adb/websocket/adb_websocket_device';
import {adbCmdAndWait} from '../../dev.perfetto.RecordTraceV2/adb/websocket/adb_websocket_utils';
import {AdbKeyManager} from '../../dev.perfetto.RecordTraceV2/adb/webusb/adb_key_manager';
import {AdbWebusbDevice} from '../../dev.perfetto.RecordTraceV2/adb/webusb/adb_webusb_device';
import {
ADB_DEVICE_FILTER,
getAdbWebUsbInterface,
} from '../../dev.perfetto.RecordTraceV2/adb/webusb/adb_webusb_utils';
import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';
import {AsyncWebsocket} from '../../dev.perfetto.RecordTraceV2/websocket/async_websocket';
import {Page} from '../components/page';
import {Hero} from '../components/hero';
export interface ConnectionResult {
device?: AdbDevice;
deviceName: string;
linuxTarget?: TracedWebsocketTarget;
}
interface ConnectionPageAttrs {
onConnected: (result: ConnectionResult) => void;
}
type ConnectionMethod = 'usb' | 'websocket' | 'web_proxy' | 'linux';
interface WsDevice {
serial: string;
model: string;
}
export class ConnectionPage implements m.ClassComponent<ConnectionPageAttrs> {
private adbKeyMgr = new AdbKeyManager();
private error?: string;
private connectionMethod: ConnectionMethod = 'usb';
// USB state.
private usbConnecting = false;
// WebSocket bridge state.
private wsDevices: WsDevice[] = [];
private wsConnecting = false;
private wsConnected = false;
private wsDeviceConnecting = false;
// WDP (Web Device Proxy) state.
private wdpDevices: WdpDevice[] = [];
private wdpSocket?: WebSocket;
private wdpConnecting = false;
private wdpConnected = false;
private wdpDeviceConnecting = false;
// Linux state.
private linuxConnecting = false;
private linuxUrl = '127.0.0.1:8037';
onremove() {
this.disconnectWdp();
}
view({attrs}: m.CVnode<ConnectionPageAttrs>) {
return m(
Page,
m(Page.Title, 'Memscope'),
m(
Hero,
m(Hero.Icon, {icon: 'memory'}),
m(
Hero.Text,
'Connect to an Android device or Linux host to monitor ' +
'per-process memory usage in real time via traced.',
),
m(
RadioGroup,
{
intent: Intent.Primary,
selectedValue: this.connectionMethod,
onValueChange: (value) => {
this.connectionMethod = value as ConnectionMethod;
this.error = undefined;
},
},
m(RadioGroup.Button, {value: 'usb', icon: 'usb'}, 'USB'),
m(RadioGroup.Button, {value: 'websocket', icon: 'lan'}, 'WebSocket'),
m(
RadioGroup.Button,
{value: 'web_proxy', icon: 'corporate_fare'},
'Web Proxy',
),
m(RadioGroup.Button, {value: 'linux', icon: 'computer'}, 'Linux'),
),
this.renderConnectBox(attrs),
this.error && m('.pf-memscope-error', this.error),
),
);
}
private renderConnectBox(attrs: ConnectionPageAttrs): m.Children {
switch (this.connectionMethod) {
case 'usb':
return this.renderUsbConnect(attrs);
case 'websocket':
return this.renderWsConnect(attrs);
case 'web_proxy':
return this.renderWdpConnect(attrs);
case 'linux':
return this.renderLinuxConnect(attrs);
default:
assertUnreachable(this.connectionMethod);
}
}
private renderUsbConnect(attrs: ConnectionPageAttrs): m.Children {
return [
m(Button, {
label: this.usbConnecting ? 'Connecting...' : 'Connect USB device',
icon: 'usb',
variant: ButtonVariant.Filled,
intent: Intent.Primary,
disabled: this.usbConnecting || !exists(navigator.usb),
onclick: () => this.connectDevice(attrs),
}),
!exists(navigator.usb) &&
m('.pf-memscope-error', 'WebUSB is not available in this browser.'),
];
}
private renderWsConnect(attrs: ConnectionPageAttrs): m.Children {
if (!this.wsConnected) {
return m(Button, {
label: this.wsConnecting
? 'Connecting...'
: 'Connect to WebSocket bridge',
icon: 'lan',
variant: ButtonVariant.Filled,
intent: Intent.Primary,
disabled: this.wsConnecting,
onclick: () => this.connectWebsocket(),
});
}
if (this.wsDevices.length === 0) {
return m(
'.pf-memscope-hero__text',
'No devices found. Connect an Android device via ADB.',
);
}
return m(
'.pf-memscope-device-list',
this.wsDevices.map((dev) =>
m(Button, {
key: dev.serial,
label: this.wsDeviceConnecting
? 'Connecting...'
: `${dev.model} [${dev.serial}]`,
icon: 'smartphone',
variant: ButtonVariant.Outlined,
disabled: this.wsDeviceConnecting,
onclick: () => this.connectWsDevice(attrs, dev),
}),
),
);
}
private renderWdpConnect(attrs: ConnectionPageAttrs): m.Children {
if (!this.wdpConnected) {
return m(Button, {
label: this.wdpConnecting
? 'Connecting to proxy...'
: 'Connect to Web Device Proxy',
icon: 'corporate_fare',
variant: ButtonVariant.Filled,
intent: Intent.Primary,
disabled: this.wdpConnecting,
onclick: () => this.connectWdp(),
});
}
if (this.wdpDevices.length === 0) {
return m(
'.pf-memscope-hero__text',
'No devices found. Connect an Android device and authorize it.',
);
}
return m(
'.pf-memscope-device-list',
this.wdpDevices.map((dev) => {
const ready = dev.proxyStatus === 'ADB' && dev.adbStatus === 'DEVICE';
const model =
dev.proxyStatus === 'ADB' ? dev.adbProps?.model ?? '?' : '?';
const label = ready
? `${model} [${dev.serialNumber}]`
: `${dev.proxyStatus}/${dev.adbStatus} [${dev.serialNumber}]`;
return m(Button, {
key: dev.serialNumber,
label: this.wdpDeviceConnecting ? 'Connecting...' : label,
icon: ready ? 'smartphone' : 'lock',
variant: ButtonVariant.Outlined,
disabled: this.wdpDeviceConnecting,
onclick: () => this.connectWdpDevice(attrs, dev),
});
}),
);
}
private renderLinuxConnect(attrs: ConnectionPageAttrs): m.Children {
return [
m(TextInput, {
placeholder: 'hostname:8037',
value: this.linuxUrl,
leftIcon: 'computer',
onInput: (value: string) => {
this.linuxUrl = value;
},
disabled: this.linuxConnecting,
}),
m(Button, {
label: this.linuxConnecting ? 'Connecting...' : 'Connect to traced',
icon: 'computer',
variant: ButtonVariant.Filled,
intent: Intent.Primary,
disabled: this.linuxConnecting || this.linuxUrl.trim() === '',
onclick: () => this.connectLinux(attrs),
}),
];
}
private async connectDevice(attrs: ConnectionPageAttrs) {
this.usbConnecting = true;
this.error = undefined;
m.redraw();
try {
const usbdev = await navigator.usb.requestDevice({
filters: [ADB_DEVICE_FILTER],
});
const usbiface = getAdbWebUsbInterface(usbdev);
if (!usbiface) {
this.error = 'Could not find ADB interface on selected device.';
this.usbConnecting = false;
m.redraw();
return;
}
const result = await AdbWebusbDevice.connect(usbdev, this.adbKeyMgr);
if (!result.ok) {
this.error = result.error;
this.usbConnecting = false;
m.redraw();
return;
}
this.usbConnecting = false;
attrs.onConnected({
device: result.value,
deviceName: `${usbdev.productName} [${usbdev.serialNumber}]`,
});
m.redraw();
} catch (e) {
if (`${(e as {name?: string}).name}` === 'NotFoundError') {
this.usbConnecting = false;
m.redraw();
return;
}
this.error = `Connection failed: ${e}`;
this.usbConnecting = false;
m.redraw();
}
}
private parseLinuxWsUrl(userInput: string): string | undefined {
const trimmed = userInput.trim();
if (trimmed.match(/^wss?:\/\//)) {
return trimmed;
} else if (trimmed.match(/^[^:/]+:\d+$/)) {
return `ws://${trimmed}/traced`;
} else if (trimmed.match(/^[^:/]+$/)) {
return `ws://${trimmed}:8037/traced`;
}
return undefined;
}
private async connectLinux(attrs: ConnectionPageAttrs) {
const wsUrl = this.parseLinuxWsUrl(this.linuxUrl);
if (wsUrl === undefined) {
this.error = 'Invalid URL. Use hostname:port or ws://hostname:port/path';
return;
}
this.linuxConnecting = true;
this.error = undefined;
m.redraw();
const target = new TracedWebsocketTarget(wsUrl);
try {
for await (const check of target.runPreflightChecks()) {
if (!check.status.ok) {
this.error = `${check.name}: ${check.status.error}`;
this.linuxConnecting = false;
m.redraw();
return;
}
}
} catch (e) {
this.error = `Connection failed: ${e}`;
this.linuxConnecting = false;
m.redraw();
return;
}
this.linuxConnecting = false;
const host = this.linuxUrl
.trim()
.replace(/^wss?:\/\//, '')
.split('/')[0];
attrs.onConnected({
linuxTarget: target,
deviceName: `Linux (${host})`,
});
m.redraw();
}
private async connectWebsocket() {
this.wsConnecting = true;
this.error = undefined;
this.wsDevices = [];
m.redraw();
const wsUrl = 'ws://127.0.0.1:8037/adb';
using sock = await AsyncWebsocket.connect(wsUrl);
if (!sock) {
this.error =
'Failed to connect to websocket_bridge at ' +
wsUrl +
'. Make sure websocket_bridge is running.';
this.wsConnecting = false;
m.redraw();
return;
}
const status = await adbCmdAndWait(sock, 'host:devices-l', true);
if (!status.ok) {
this.error = `Failed to list devices: ${status.error}`;
this.wsConnecting = false;
m.redraw();
return;
}
const devices: WsDevice[] = [];
for (const line of status.value.trimEnd().split('\n')) {
if (line === '') continue;
const match = line.match(/^([^\s]+)\s+.*model:([^ ]+)/);
if (!match) continue;
devices.push({serial: match[1], model: match[2]});
}
this.wsDevices = devices;
this.wsConnected = true;
this.wsConnecting = false;
m.redraw();
}
private async connectWsDevice(attrs: ConnectionPageAttrs, dev: WsDevice) {
this.wsDeviceConnecting = true;
this.error = undefined;
m.redraw();
try {
const wsUrl = 'ws://127.0.0.1:8037/adb';
const result = await AdbWebsocketDevice.connect(
wsUrl,
dev.serial,
'WEBSOCKET_BRIDGE',
);
if (!result.ok) {
this.error = result.error;
this.wsDeviceConnecting = false;
m.redraw();
return;
}
this.wsDeviceConnecting = false;
attrs.onConnected({
device: result.value,
deviceName: `${dev.model} [${dev.serial}]`,
});
m.redraw();
} catch (e) {
this.error = `WebSocket connection failed: ${e}`;
this.wsDeviceConnecting = false;
m.redraw();
}
}
private async connectWdp() {
this.wdpConnecting = true;
this.error = undefined;
this.wdpDevices = [];
m.redraw();
const wsUrl = 'ws://127.0.0.1:9167/track-devices-json';
for (let attempt = 0; attempt < 2; attempt++) {
const aws = await AsyncWebsocket.connect(wsUrl);
if (aws === undefined) {
this.error =
'Failed to connect to Web Device Proxy. ' +
'Make sure it is running (see go/web-device-proxy).';
this.wdpConnecting = false;
m.redraw();
return;
}
const respStr = await aws.waitForString();
const respJson = JSON.parse(respStr);
const respSchema = WDP_TRACK_DEVICES_SCHEMA.safeParse(respJson);
if (!respSchema.success) {
this.error = `Invalid WDP response: ${respSchema.error}`;
this.wdpConnecting = false;
m.redraw();
return;
}
const resp = respSchema.data;
if (
resp.error?.type === 'ORIGIN_NOT_ALLOWLISTED' &&
resp.error.approveUrl !== undefined
) {
const popup = await showPopupWindow({url: resp.error.approveUrl});
if (popup === false) {
this.error = 'You need to enable popups and try again.';
this.wdpConnecting = false;
m.redraw();
return;
}
continue; // Retry after user approved.
} else if (resp.error !== undefined) {
this.error = resp.error.message ?? 'Unknown WDP error';
this.wdpConnecting = false;
m.redraw();
return;
}
// Success — listen for device updates.
const ws = aws.release();
this.wdpSocket = ws;
this.wdpConnected = true;
this.wdpConnecting = false;
this.wdpDevices = resp.device ?? [];
ws.onmessage = (e: MessageEvent<string>) => {
const parsed = WDP_TRACK_DEVICES_SCHEMA.safeParse(JSON.parse(e.data));
if (parsed.success && parsed.data.error === undefined) {
this.wdpDevices = parsed.data.device ?? [];
}
m.redraw();
};
ws.onclose = () => {
this.wdpConnected = false;
this.wdpSocket = undefined;
m.redraw();
};
ws.onerror = () => {
this.wdpConnected = false;
this.wdpSocket = undefined;
m.redraw();
};
m.redraw();
return;
}
this.error =
'Failed to authenticate with WDP. ' +
'Click allow on the popup and try again.';
this.wdpConnecting = false;
m.redraw();
}
private async connectWdpDevice(attrs: ConnectionPageAttrs, dev: WdpDevice) {
this.wdpDeviceConnecting = true;
this.error = undefined;
m.redraw();
try {
if (dev.proxyStatus === 'PROXY_UNAUTHORIZED') {
const res = await showPopupWindow({url: dev.approveUrl});
if (!res) {
this.error = 'Enable popups and try again.';
this.wdpDeviceConnecting = false;
m.redraw();
return;
}
}
if (dev.proxyStatus !== 'ADB' || dev.adbStatus !== 'DEVICE') {
this.error =
`Device not ready: proxyStatus=${dev.proxyStatus}` +
` adbStatus=${dev.adbStatus}`;
this.wdpDeviceConnecting = false;
m.redraw();
return;
}
const wsUrl = 'ws://127.0.0.1:9167/adb-json';
const result = await AdbWebsocketDevice.connect(
wsUrl,
dev.serialNumber,
'WEB_DEVICE_PROXY',
);
if (!result.ok) {
this.error = result.error;
this.wdpDeviceConnecting = false;
m.redraw();
return;
}
const model =
dev.proxyStatus === 'ADB' ? dev.adbProps?.model ?? '?' : '?';
this.wdpDeviceConnecting = false;
attrs.onConnected({
device: result.value,
deviceName: `${model} [${dev.serialNumber}]`,
});
m.redraw();
} catch (e) {
this.error = `WDP connection failed: ${e}`;
this.wdpDeviceConnecting = false;
m.redraw();
}
}
private disconnectWdp() {
if (this.wdpSocket) {
this.wdpSocket.close();
this.wdpSocket = undefined;
}
this.wdpConnected = false;
this.wdpDevices = [];
}
}