chore: remove deprecated packages

This commit is contained in:
Simon Chan 2023-09-15 18:26:34 +08:00
parent 3a14c162d6
commit ec46a1730e
No known key found for this signature in database
GPG key ID: A8B69F750B9BCEDD
136 changed files with 134 additions and 18955 deletions

View file

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

41
apps/demo/.gitignore vendored
View file

@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
public/manifest.json
public/fallback-*.js
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map

View file

@ -1,34 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -1,79 +0,0 @@
const withMDX = require("@next/mdx")({
extension: /\.mdx?$/,
options: {
// Disable MDX createElement hack
// because we don't need rendering custom elements
jsx: true,
},
});
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
const basePath = process.env.BASE_PATH ?? "";
const withPwa = require("@yume-chan/next-pwa")({
dest: "public",
});
function pipe(value, ...callbacks) {
for (const callback of callbacks) {
value = callback(value);
}
return value;
}
module.exports = pipe(
/** @type {import('next').NextConfig} */ ({
basePath,
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
reactStrictMode: false,
productionBrowserSourceMaps: true,
experimental: {
// Workaround https://github.com/vercel/next.js/issues/33914
esmExternals: "loose",
},
publicRuntimeConfig: {
basePath,
},
webpack(config) {
config.module.rules.push({
test: /.*\.m?js$/,
// disable these modules because they generate a lot of warnings about
// non existing source maps
// we cannot filter these warnings via config.stats.warningsFilter
// because Next.js doesn't allow it
// https://github.com/vercel/next.js/pull/7550#issuecomment-512861158
// https://github.com/vercel/next.js/issues/12861
exclude: [/next/],
use: ["source-map-loader"],
enforce: "pre",
});
return config;
},
// Enable Direct Sockets API
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin",
},
{
key: "Cross-Origin-Embedder-Policy",
value: "credentialless",
},
],
},
];
},
poweredByHeader: false,
}),
withBundleAnalyzer,
withPwa,
withMDX
);

View file

@ -1,64 +0,0 @@
{
"name": "demo",
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "fetch-scrcpy-server 2.1.1 && node scripts/manifest.mjs",
"dev": "next dev -p 5000",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@fluentui/react": "^8.110.7",
"@fluentui/react-file-type-icons": "^8.9.3",
"@fluentui/react-hooks": "^8.6.29",
"@fluentui/react-icons": "^2.0.206",
"@fluentui/style-utilities": "^8.9.16",
"@griffel/react": "^1.5.10",
"@yume-chan/adb": "workspace:^0.0.21",
"@yume-chan/adb-credential-web": "workspace:^0.0.21",
"@yume-chan/adb-daemon-direct-sockets": "workspace:^0.0.9",
"@yume-chan/adb-daemon-webusb": "workspace:^0.0.21",
"@yume-chan/adb-daemon-ws": "workspace:^0.0.9",
"@yume-chan/adb-scrcpy": "workspace:^0.0.21",
"@yume-chan/android-bin": "workspace:^0.0.21",
"@yume-chan/aoa": "workspace:^0.0.21",
"@yume-chan/async": "^2.2.0",
"@yume-chan/b-tree": "workspace:^0.0.21",
"@yume-chan/event": "workspace:^0.0.21",
"@yume-chan/fetch-scrcpy-server": "workspace:^0.0.21",
"@yume-chan/pcm-player": "workspace:^0.0.21",
"@yume-chan/scrcpy": "workspace:^0.0.21",
"@yume-chan/scrcpy-decoder-tinyh264": "workspace:^0.0.21",
"@yume-chan/scrcpy-decoder-webcodecs": "workspace:^0.0.21",
"@yume-chan/stream-extra": "workspace:^0.0.21",
"@yume-chan/stream-saver": "^2.0.6",
"@yume-chan/struct": "workspace:^0.0.21",
"@yume-chan/tabby-launcher": "workspace:^1.0.197-nightly.1",
"@yume-chan/undici-browser": "5.22.1-mod.7",
"comlink": "^4.4.1",
"fflate": "^0.7.4",
"mobx": "^6.9.0",
"mobx-react-lite": "^3.4.3",
"next": "13.4.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webm-muxer": "^3.1.1"
},
"devDependencies": {
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/bundle-analyzer": "^13.4.9",
"@next/mdx": "^13.4.9",
"@types/dom-webcodecs": "^0.1.8",
"@types/node": "^20.4.0",
"@types/react": "18.2.14",
"@yume-chan/next-pwa": "5.6.0-mod.2",
"eslint": "^8.44.0",
"eslint-config-next": "13.4.9",
"prettier": "^3.0.0",
"source-map-loader": "^4.0.1",
"typescript": "^5.1.6"
}
}

View file

@ -1,170 +0,0 @@
<!--
mitm.html is the lite "man in the middle"
This is only meant to signal the opener's messageChannel to
the service worker - when that is done this mitm can be closed
but it's better to keep it alive since this also stops the sw
from restarting
The service worker is capable of intercepting all request and fork their
own "fake" response - wish we are going to craft
when the worker then receives a stream then the worker will tell the opener
to open up a link that will start the download
-->
<script>
// This will prevent the sw from restarting
let keepAlive = () => {
keepAlive = () => { }
var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'
var interval = setInterval(() => {
if (sw) {
sw.postMessage('ping')
} else {
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))
}
}, 10000)
}
// message event is the first thing we need to setup a listner for
// don't want the opener to do a random timeout - instead they can listen for
// the ready event
// but since we need to wait for the Service Worker registration, we store the
// message for later
let messages = []
window.onmessage = evt => messages.push(evt)
let sw = null
let scope = ''
function registerWorker() {
return navigator.serviceWorker.getRegistration('./').then(swReg => {
let baseUrl = location.protocol + "//" + location.host + location.pathname;
baseUrl = baseUrl.substring(0, baseUrl.lastIndexOf("/"));
if (swReg.scope === baseUrl) {
return swReg;
}
return navigator.serviceWorker.register('sw.js', { scope: './' })
}).then(swReg => {
const swRegTmp = swReg.installing || swReg.waiting
scope = swReg.scope
return (sw = swReg.active) || new Promise(resolve => {
swRegTmp.addEventListener('statechange', fn = () => {
if (swRegTmp.state === 'activated') {
swRegTmp.removeEventListener('statechange', fn)
sw = swReg.active
resolve()
}
})
})
})
}
// Now that we have the Service Worker registered we can process messages
function onMessage(event) {
let { data, ports, origin } = event
// It's important to have a messageChannel, don't want to interfere
// with other simultaneous downloads
if (!ports || !ports.length) {
console.error("[StreamSaver] You didn't send a messageChannel")
return;
}
if (typeof data !== 'object') {
console.error("[StreamSaver] You didn't send a object")
return;
}
// the default public service worker for StreamSaver is shared among others.
// so all download links needs to be prefixed to avoid any other conflict
data.origin = origin
// if we ever (in some feature versoin of streamsaver) would like to
// redirect back to the page of who initiated a http request
data.referrer = data.referrer || document.referrer || origin
// pass along version for possible backwards compatibility in sw.js
data.streamSaverVersion = new URLSearchParams(location.search).get('version')
if (data.streamSaverVersion === '1.2.0') {
console.warn('[StreamSaver] please update streamsaver')
}
/** @since v2.0.0 */
if (!data.headers) {
console.warn("[StreamSaver] pass `data.headers` that you would like to pass along to the service worker\nit should be a 2D array or a key/val object that fetch's Headers api accepts")
} else {
// test if it's correct
// should thorw a typeError if not
new Headers(data.headers)
}
/** @since v2.0.0 */
if (typeof data.filename === 'string') {
console.warn("[StreamSaver] You shouldn't send `data.filename` anymore. It should be included in the Content-Disposition header option")
// Do what File constructor do with fileNames
data.filename = data.filename.replace(/\//g, ':')
}
/** @since v2.0.0 */
if (data.size) {
console.warn("[StreamSaver] You shouldn't send `data.size` anymore. It should be included in the content-length header option")
}
/** @since v2.0.0 */
if (data.readableStream) {
console.warn("[StreamSaver] You should send the readableStream in the messageChannel, not throught mitm")
}
/** @since v2.0.0 */
if (!data.pathname) {
console.warn("[StreamSaver] Please send `data.pathname` (eg: /pictures/summer.jpg)")
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename
}
// remove all leading slashes
data.pathname = data.pathname.replace(/^\/+/g, '')
// set the absolute pathname to the download url.
data.url = new URL(`${scope}/${data.pathname}`).toString()
if (!data.url.startsWith(`${scope}/`)) {
throw new TypeError('[StreamSaver] bad `data.pathname`')
}
// This sends the message data as well as transferring
// messageChannel.port2 to the service worker. The service worker can
// then use the transferred port to reply via postMessage(), which
// will in turn trigger the onmessage handler on messageChannel.port1.
const transferable = data.readableStream
? [ports[0], data.readableStream]
: [ports[0]]
if (!(data.readableStream || data.transferringReadable)) {
keepAlive()
}
return sw.postMessage(data, transferable)
}
if (window.opener) {
// The opener can't listen to onload event, so we need to help em out!
// (telling them that we are ready to accept postMessage's)
window.opener.postMessage('StreamSaver::loadedPopup', '*')
}
if (navigator.serviceWorker) {
registerWorker().then(() => {
window.onmessage = onMessage
messages.forEach(window.onmessage)
})
} else {
// FF can ping sw with fetch from a secure hidden iframe
// shouldn't really be possible?
keepAlive()
}
</script>

View file

@ -1,156 +0,0 @@
/* global self ReadableStream Response */
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
const url = serviceWorker.scriptURL;
const baseUrl = url.substring(0, url.lastIndexOf("/"));
event.waitUntil(
(async () => {
const cache = await caches.open("StreamSaver");
await cache.add(baseUrl + "/mitm.html");
await clients.claim();
})()
);
});
const map = new Map();
// This should be called once per download
// Each event has a dataChannel that the data will be piped through
self.onmessage = (event) => {
// We send a heartbeat every x second to keep the
// service worker alive if a transferable stream is not sent
if (event.data === "ping") {
return;
}
const data = event.data;
const downloadUrl =
data.url ||
Math.random() + "/" + (typeof data === "string" ? data : data.filename);
const port = event.ports[0];
const metadata = new Array(3); // [stream, data, port]
metadata[1] = data;
metadata[2] = port;
// Note to self:
// old streamsaver v1.2.0 might still use `readableStream`...
// but v2.0.0 will always transfer the stream through MessageChannel #94
if (event.data.readableStream) {
metadata[0] = event.data.readableStream;
} else if (event.data.transferringReadable) {
port.onmessage = (evt) => {
port.onmessage = null;
metadata[0] = evt.data.readableStream;
};
} else {
metadata[0] = createStream(port);
}
map.set(downloadUrl, metadata);
port.postMessage({ download: downloadUrl });
};
function createStream(port) {
// ReadableStream is only supported by chrome 52
return new ReadableStream({
start(controller) {
// When we receive data on the messageChannel, we write
port.onmessage = ({ data }) => {
if (data === "end") {
return controller.close();
}
if (data === "abort") {
controller.error("Aborted the download");
return;
}
controller.enqueue(data);
};
},
cancel(reason) {
console.log("user aborted", reason);
port.postMessage({ abort: true });
},
});
}
self.onfetch = async (event) => {
event.respondWith(
(async () => {
const url = event.request.url;
const cache = await caches.open("StreamSaver");
const response = await cache.match(event.request);
if (response) {
return response;
}
// this only works for Firefox
if (url.endsWith("/ping")) {
return new Response("pong");
}
const hijacked = map.get(url);
if (!hijacked) return null;
map.delete(url);
const [stream, data, port] = hijacked;
// Not comfortable letting any user control all headers
// so we only copy over the length & disposition
const responseHeaders = new Headers({
"Content-Type": "application/octet-stream; charset=utf-8",
// To be on the safe side, The link can be opened in a iframe.
// but octet-stream should stop it.
"Content-Security-Policy": "default-src 'none'",
"X-Content-Security-Policy": "default-src 'none'",
"Cross-Origin-Embedder-Policy": "require-corp",
"X-WebKit-CSP": "default-src 'none'",
"X-XSS-Protection": "1; mode=block",
});
const headers = new Headers(data.headers || {});
if (headers.has("Content-Length")) {
responseHeaders.set(
"Content-Length",
headers.get("Content-Length")
);
}
if (headers.has("Content-Disposition")) {
responseHeaders.set(
"Content-Disposition",
headers.get("Content-Disposition")
);
}
// data, data.filename and size should not be used anymore
if (data.size) {
console.warn("Deprecated");
responseHeaders.set("Content-Length", data.size);
}
const fileName = typeof data === "string" ? data : data.filename;
if (fileName) {
console.warn("Deprecated");
// Make filename RFC5987 compatible
fileName = encodeURIComponent(fileName)
.replace(/['()]/g, escape)
.replace(/\*/g, "%2A");
responseHeaders.set(
"Content-Disposition",
"attachment; filename*=UTF-8''" + fileName
);
}
port.postMessage({ debug: "Download started" });
return new Response(stream, { headers: responseHeaders });
})()
);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,29 +0,0 @@
import fs from "node:fs";
const baseUrl = (process.env.BASE_PATH ?? "") + "/";
fs.writeFileSync(
new URL("../public/manifest.json", import.meta.url),
JSON.stringify(
{
name: "Tango",
short_name: "Tango",
categories: ["utilities", "developer"],
description: "ADB in your browser",
scope: baseUrl,
start_url: baseUrl,
background_color: "#ffffff",
display: "standalone",
icons: [
{
src: "favicon-256.png",
type: "image/png",
sizes: "256x256",
},
],
},
undefined,
4
),
"utf8"
);

View file

@ -1,20 +0,0 @@
import { useEffect, useState } from "react";
export function CommandBarSpacerItem() {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
useEffect(() => {
if (!container) {
return;
}
const parent = container.parentElement!;
const originalFlexGrow = parent.style.flexGrow;
parent.style.flexGrow = "1";
return () => {
parent.style.flexGrow = originalFlexGrow;
};
}, [container]);
return <div ref={setContainer} />;
}

View file

@ -1,16 +0,0 @@
import { CommandBar as FluentCommandBar, ICommandBarProps, StackItem } from '@fluentui/react';
import { withDisplayName } from '../utils/with-display-name';
const ContainerStyles = {
root: {
borderBottom: '1px solid rgb(243, 242, 241)',
}
} as const;
export const CommandBar = withDisplayName('CommandBar')((props: ICommandBarProps) => {
return (
<StackItem styles={ContainerStyles}>
<FluentCommandBar {...props} />
</StackItem>
);
});

View file

@ -1,389 +0,0 @@
import {
DefaultButton,
Dialog,
Dropdown,
IDropdownOption,
PrimaryButton,
ProgressIndicator,
Stack,
StackItem,
} from "@fluentui/react";
import {
Adb,
AdbDaemonDevice,
AdbDaemonTransport,
AdbPacketData,
AdbPacketInit,
} from "@yume-chan/adb";
import AdbWebCredentialStore from "@yume-chan/adb-credential-web";
import AdbDaemonDirectSocketsDevice from "@yume-chan/adb-daemon-direct-sockets";
import {
AdbDaemonWebUsbDeviceManager,
AdbDaemonWebUsbDeviceWatcher,
} from "@yume-chan/adb-daemon-webusb";
import AdbDaemonWebSocketDevice from "@yume-chan/adb-daemon-ws";
import {
Consumable,
InspectStream,
ReadableStream,
WritableStream,
pipeFrom,
} from "@yume-chan/stream-extra";
import { observer } from "mobx-react-lite";
import { useCallback, useEffect, useMemo, useState } from "react";
import { GLOBAL_STATE } from "../state";
import { CommonStackTokens, Icons } from "../utils";
const DropdownStyles = { dropdown: { width: "100%" } };
const CredentialStore = new AdbWebCredentialStore();
function ConnectCore(): JSX.Element | null {
const [selected, setSelected] = useState<AdbDaemonDevice | undefined>();
const [connecting, setConnecting] = useState(false);
const [usbSupported, setUsbSupported] = useState(true);
const [usbDeviceList, setUsbDeviceList] = useState<AdbDaemonDevice[]>([]);
const updateUsbDeviceList = useCallback(async () => {
const devices: AdbDaemonDevice[] =
await AdbDaemonWebUsbDeviceManager.BROWSER!.getDevices();
setUsbDeviceList(devices);
return devices;
}, []);
useEffect(
() => {
// Only run on client
const supported = !!AdbDaemonWebUsbDeviceManager.BROWSER;
setUsbSupported(supported);
if (!supported) {
GLOBAL_STATE.showErrorDialog(
"Your browser does not support WebUSB standard, which is required for this site to work.\n\nLatest version of Google Chrome, Microsoft Edge, or other Chromium-based browsers are required."
);
return;
}
updateUsbDeviceList();
const watcher = new AdbDaemonWebUsbDeviceWatcher(
async (serial?: string) => {
const list = await updateUsbDeviceList();
if (serial) {
setSelected(
list.find((device) => device.serial === serial)
);
return;
}
},
globalThis.navigator.usb
);
return () => watcher.dispose();
},
/* eslint-disable-next-line react-hooks/exhaustive-deps */
[]
);
const [webSocketDeviceList, setWebSocketDeviceList] = useState<
AdbDaemonWebSocketDevice[]
>([]);
useEffect(() => {
const savedList = localStorage.getItem("ws-backend-list");
if (!savedList) {
return;
}
const parsed = JSON.parse(savedList) as { address: string }[];
setWebSocketDeviceList(
parsed.map((x) => new AdbDaemonWebSocketDevice(x.address))
);
}, []);
const addWebSocketDevice = useCallback(() => {
const address = window.prompt("Enter the address of WebSockify server");
if (!address) {
return;
}
setWebSocketDeviceList((list) => {
const copy = list.slice();
copy.push(new AdbDaemonWebSocketDevice(address));
globalThis.localStorage.setItem(
"ws-backend-list",
JSON.stringify(copy.map((x) => ({ address: x.serial })))
);
return copy;
});
}, []);
const [tcpDeviceList, setTcpDeviceList] = useState<
AdbDaemonDirectSocketsDevice[]
>([]);
useEffect(() => {
if (!AdbDaemonDirectSocketsDevice.isSupported()) {
return;
}
const savedList = localStorage.getItem("tcp-backend-list");
if (!savedList) {
return;
}
const parsed = JSON.parse(savedList) as {
address: string;
port: number;
}[];
setTcpDeviceList(
parsed.map(
(x) => new AdbDaemonDirectSocketsDevice(x.address, x.port)
)
);
}, []);
const addTcpDevice = useCallback(() => {
const host = window.prompt("Enter the address of device");
if (!host) {
return;
}
const port = window.prompt("Enter the port of device", "5555");
if (!port) {
return;
}
const portNumber = Number.parseInt(port, 10);
setTcpDeviceList((list) => {
const copy = list.slice();
copy.push(new AdbDaemonDirectSocketsDevice(host, portNumber));
globalThis.localStorage.setItem(
"tcp-backend-list",
JSON.stringify(
copy.map((x) => ({
address: x.host,
port: x.port,
}))
)
);
return copy;
});
}, []);
const handleSelectedChange = (
e: React.FormEvent<HTMLDivElement>,
option?: IDropdownOption
) => {
setSelected(option?.data as AdbDaemonDevice);
};
const addUsbDevice = useCallback(async () => {
const device =
await AdbDaemonWebUsbDeviceManager.BROWSER!.requestDevice();
setSelected(device);
await updateUsbDeviceList();
}, [updateUsbDeviceList]);
const connect = useCallback(async () => {
if (!selected) {
return;
}
setConnecting(true);
let readable: ReadableStream<AdbPacketData>;
let writable: WritableStream<Consumable<AdbPacketInit>>;
try {
const streams = await selected.connect();
// Use `InspectStream`s to intercept and log packets
readable = streams.readable.pipeThrough(
new InspectStream((packet) => {
GLOBAL_STATE.appendLog("in", packet);
})
);
writable = pipeFrom(
streams.writable,
new InspectStream((packet: Consumable<AdbPacketInit>) => {
GLOBAL_STATE.appendLog("out", packet.value);
})
);
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
setConnecting(false);
return;
}
async function dispose() {
// Adb won't close the streams,
// so manually close them.
try {
readable.cancel();
} catch {}
try {
await writable.close();
} catch {}
GLOBAL_STATE.setDevice(undefined, undefined);
}
try {
const device = new Adb(
await AdbDaemonTransport.authenticate({
serial: selected.serial,
connection: { readable, writable },
credentialStore: CredentialStore,
})
);
device.disconnected.then(
async () => {
await dispose();
},
async (e) => {
GLOBAL_STATE.showErrorDialog(e);
await dispose();
}
);
GLOBAL_STATE.setDevice(selected, device);
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
await dispose();
} finally {
setConnecting(false);
}
}, [selected]);
const disconnect = useCallback(async () => {
try {
await GLOBAL_STATE.adb!.close();
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
}
}, []);
const deviceList = useMemo(
() =>
([] as AdbDaemonDevice[]).concat(
usbDeviceList,
webSocketDeviceList,
tcpDeviceList
),
[usbDeviceList, webSocketDeviceList, tcpDeviceList]
);
const deviceOptions = useMemo(() => {
return deviceList.map((device) => ({
key: device.serial,
text: `${device.serial} ${device.name ? `(${device.name})` : ""}`,
data: device,
}));
}, [deviceList]);
useEffect(() => {
setSelected((old) => {
if (old) {
const current = deviceList.find(
(device) => device.serial === old.serial
);
if (current) {
return current;
}
}
return deviceList.length ? deviceList[0] : undefined;
});
}, [deviceList]);
const addMenuProps = useMemo(() => {
const items = [];
if (usbSupported) {
items.push({
key: "usb",
text: "USB",
onClick: addUsbDevice,
});
}
items.push({
key: "websocket",
text: "WebSocket",
onClick: addWebSocketDevice,
});
if (AdbDaemonDirectSocketsDevice.isSupported()) {
items.push({
key: "direct-sockets",
text: "Direct Sockets TCP",
onClick: addTcpDevice,
});
}
return {
items,
};
}, [usbSupported, addUsbDevice, addWebSocketDevice, addTcpDevice]);
return (
<Stack tokens={{ childrenGap: 8, padding: "0 0 8px 8px" }}>
<Dropdown
disabled={!!GLOBAL_STATE.adb || deviceOptions.length === 0}
label="Available devices"
placeholder="No available devices"
options={deviceOptions}
styles={DropdownStyles}
dropdownWidth={300}
selectedKey={selected?.serial}
onChange={handleSelectedChange}
/>
{!GLOBAL_STATE.adb ? (
<Stack horizontal tokens={CommonStackTokens}>
<StackItem grow shrink>
<PrimaryButton
iconProps={{ iconName: Icons.PlugConnected }}
text="Connect"
disabled={!selected}
primary={!!selected}
styles={{ root: { width: "100%" } }}
onClick={connect}
/>
</StackItem>
<StackItem grow shrink>
<DefaultButton
iconProps={{ iconName: Icons.AddCircle }}
text="Add"
split
splitButtonAriaLabel="Add other connection type"
menuProps={addMenuProps}
disabled={!usbSupported}
primary={!selected}
styles={{ root: { width: "100%" } }}
onClick={addUsbDevice}
/>
</StackItem>
</Stack>
) : (
<DefaultButton
iconProps={{ iconName: Icons.PlugDisconnected }}
text="Disconnect"
onClick={disconnect}
/>
)}
<Dialog
hidden={!connecting}
dialogContentProps={{
title: "Connecting...",
subText: "Please authorize the connection on your device",
}}
>
<ProgressIndicator />
</Dialog>
</Stack>
);
}
export const Connect = observer(ConnectCore);

View file

@ -1,407 +0,0 @@
import {
Dropdown,
IDropdownOption,
Position,
Separator,
SpinButton,
Toggle,
} from "@fluentui/react";
import {
DemoMode,
DemoModeMobileDataType,
DemoModeMobileDataTypes,
DemoModeSignalStrength,
DemoModeStatusBarMode,
DemoModeStatusBarModes,
} from "@yume-chan/android-bin";
import { autorun, makeAutoObservable, reaction, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { CSSProperties, useCallback } from "react";
import { GLOBAL_STATE } from "../state";
const SignalStrengthOptions = Object.values(DemoModeSignalStrength).map(
(key) => ({
key,
text: {
[DemoModeSignalStrength.Hidden]: "Hidden",
[DemoModeSignalStrength.Level0]: "Level 0",
[DemoModeSignalStrength.Level1]: "Level 1",
[DemoModeSignalStrength.Level2]: "Level 2",
[DemoModeSignalStrength.Level3]: "Level 3",
[DemoModeSignalStrength.Level4]: "Level 4",
}[key],
})
);
const MobileDataTypeOptions = DemoModeMobileDataTypes.map((key) => ({
key,
text: {
"1x": "1X",
"3g": "3G",
"4g": "4G",
"4g+": "4G+",
"5g": "5G",
"5ge": "5GE",
"5g+": "5G+",
e: "EDGE",
// cspell: disable-next-line
g: "GPRS",
// cspell: disable-next-line
h: "HSPA",
// cspell: disable-next-line
"h+": "HSPA+",
lte: "LTE",
"lte+": "LTE+",
dis: "Disabled",
not: "Not default SIM",
null: "Unknown",
}[key],
}));
const StatusBarModeOptions = DemoModeStatusBarModes.map((key) => ({
key,
text: {
opaque: "Opaque",
translucent: "Translucent",
"semi-transparent": "Semi-transparent",
transparent: "Transparent",
warning: "Warning",
}[key],
}));
class DemoModePanelState {
demoMode: DemoMode | undefined;
allowed = false;
enabled = false;
features: Map<string, unknown> = new Map();
constructor() {
makeAutoObservable(this);
reaction(
() => GLOBAL_STATE.adb,
async (device) => {
if (device) {
runInAction(() => (this.demoMode = new DemoMode(device)));
const allowed = await this.demoMode!.getAllowed();
runInAction(() => (this.allowed = allowed));
if (allowed) {
const enabled = await this.demoMode!.getEnabled();
runInAction(() => (this.enabled = enabled));
}
} else {
this.demoMode = undefined;
this.allowed = false;
this.enabled = false;
this.features.clear();
}
},
{ fireImmediately: true }
);
// Apply all features when enable
autorun(() => {
if (this.enabled) {
for (const group of FEATURES) {
for (const feature of group) {
feature.onChange(
this.features.get(feature.key) ?? feature.initial
);
}
}
}
});
}
}
const state = new DemoModePanelState();
interface FeatureDefinition {
key: string;
label: string;
type: string;
min?: number;
max?: number;
step?: number;
options?: { key: string; text: string }[];
initial: unknown;
onChange: (value: unknown) => void;
}
const FEATURES: FeatureDefinition[][] = [
[
{
key: "batteryLevel",
label: "Battery Level",
type: "number",
min: 0,
max: 100,
step: 1,
initial: 100,
onChange: (value) =>
state.demoMode!.setBatteryLevel(value as number),
},
{
key: "batteryCharging",
label: "Battery Charging",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setBatteryCharging(value as boolean),
},
{
key: "powerSaveMode",
label: "Power Save Mode",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setPowerSaveMode(value as boolean),
},
],
[
{
key: "wifiSignalStrength",
label: "Wifi Signal Strength",
type: "select",
options: SignalStrengthOptions,
initial: DemoModeSignalStrength.Level4,
onChange: (value) =>
state.demoMode!.setWifiSignalStrength(
value as DemoModeSignalStrength
),
},
{
key: "airplaneMode",
label: "Airplane Mode",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setAirplaneMode(value as boolean),
},
{
key: "mobileDataType",
label: "Mobile Data Type",
type: "select",
options: MobileDataTypeOptions,
initial: "lte",
onChange: (value) =>
state.demoMode!.setMobileDataType(
value as DemoModeMobileDataType
),
},
{
key: "mobileSignalStrength",
label: "Mobile Signal Strength",
type: "select",
options: SignalStrengthOptions,
initial: DemoModeSignalStrength.Level4,
onChange: (value) =>
state.demoMode!.setMobileSignalStrength(
value as DemoModeSignalStrength
),
},
],
[
{
key: "statusBarMode",
label: "Status Bar Mode",
type: "select",
options: StatusBarModeOptions,
initial: "transparent",
onChange: (value) =>
state.demoMode!.setStatusBarMode(
value as DemoModeStatusBarMode
),
},
{
key: "vibrateMode",
label: "Vibrate Mode Indicator",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setVibrateModeEnabled(value as boolean),
},
{
key: "bluetoothConnected",
label: "Bluetooth Indicator",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setBluetoothConnected(value as boolean),
},
{
key: "locatingIcon",
label: "Locating Icon",
type: "boolean",
initial: false,
onChange: (value) =>
state.demoMode!.setLocatingIcon(value as boolean),
},
{
key: "alarmIcon",
label: "Alarm Icon",
type: "boolean",
initial: false,
onChange: (value) => state.demoMode!.setAlarmIcon(value as boolean),
},
{
key: "notificationsVisibility",
label: "Notifications Visibility",
type: "boolean",
initial: true,
onChange: (value) =>
state.demoMode!.setNotificationsVisibility(value as boolean),
},
{
key: "hour",
label: "Clock Hour",
type: "number",
min: 0,
max: 23,
step: 1,
initial: 12,
onChange: (value) =>
state.demoMode!.setTime(
value as number,
(state.features.get("minute") as number | undefined) ?? 34
),
},
{
key: "minute",
label: "Clock Minute",
type: "number",
min: 0,
max: 59,
step: 1,
initial: 34,
onChange: (value) =>
state.demoMode!.setTime(
(state.features.get("hour") as number | undefined) ?? 34,
value as number
),
},
],
];
const FeatureBase = ({ feature }: { feature: FeatureDefinition }) => {
const handleChange = useCallback(
(e: unknown, value: unknown) => {
switch (feature.type) {
case "select":
value = (value as IDropdownOption).key;
break;
case "number":
value = parseFloat(value as string);
default:
break;
}
feature.onChange(value);
runInAction(() => {
state.features.set(feature.key, value);
state.enabled = true;
});
},
[feature]
);
const value = state.features.get(feature.key) ?? feature.initial;
switch (feature.type) {
case "boolean":
return (
<Toggle
label={feature.label}
disabled={!state.allowed}
checked={value as boolean}
onChange={handleChange}
/>
);
case "number":
return (
<SpinButton
label={feature.label}
labelPosition={Position.top}
disabled={!state.allowed}
min={feature.min}
max={feature.max}
step={feature.step}
value={value as string}
onChange={handleChange}
/>
);
case "select":
return (
<Dropdown
label={feature.label}
disabled={!state.allowed}
options={feature.options!}
selectedKey={value as string}
onChange={handleChange}
/>
);
default:
return null;
}
};
const Feature = observer(FeatureBase);
export interface DemoModePanelProps {
style?: CSSProperties;
}
export const DemoModePanel = observer(({ style }: DemoModePanelProps) => {
const handleAllowedChange = useCallback(
async (e: unknown, value?: boolean) => {
await state.demoMode!.setAllowed(value!);
runInAction(() => {
state.allowed = value!;
state.enabled = false;
});
},
[]
);
const handleEnabledChange = useCallback(
async (e: unknown, value?: boolean) => {
await state.demoMode!.setEnabled(value!);
runInAction(() => (state.enabled = value!));
},
[]
);
return (
<div style={{ padding: 12, overflow: "hidden auto", ...style }}>
<Toggle
label="Allowed"
disabled={!GLOBAL_STATE.adb}
checked={state.allowed}
onChange={handleAllowedChange}
/>
<Toggle
label="Enabled"
disabled={!state.allowed}
checked={state.enabled}
onChange={handleEnabledChange}
/>
<div>
<strong>Note:</strong>
</div>
<div>Device may not support all options.</div>
{FEATURES.map((group, index) => (
<div key={index}>
<Separator />
{group.map((feature) => (
<Feature key={feature.key} feature={feature} />
))}
</div>
))}
</div>
);
});

View file

@ -1,161 +0,0 @@
import { StackItem } from "@fluentui/react";
import { makeStyles } from "@griffel/react";
import {
CSSProperties,
ComponentType,
HTMLAttributes,
ReactNode,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { forwardRef } from "../utils/with-display-name";
import { ResizeObserver, Size } from "./resize-observer";
export interface DeviceViewProps extends HTMLAttributes<HTMLDivElement> {
width: number;
height: number;
BottomElement?: ComponentType<{
className: string;
style: CSSProperties;
children: ReactNode;
}>;
children?: ReactNode;
}
export interface DeviceViewRef {
enterFullscreen(): void;
}
const useClasses = makeStyles({
outer: {
width: "100%",
height: "100%",
backgroundColor: "black",
},
inner: {
position: "absolute",
transformOrigin: "top left",
},
bottom: {
position: "absolute",
},
});
export const DeviceView = forwardRef<DeviceViewRef>("DeviceView")(
(
{ width, height, BottomElement, children, ...props }: DeviceViewProps,
ref
) => {
const classes = useClasses();
const [containerSize, setContainerSize] = useState<Size>({
width: 0,
height: 0,
});
const [bottomSize, setBottomSize] = useState<Size>({
width: 0,
height: 0,
});
// Container size minus bottom element size
const usableSize = useMemo(
() => ({
width: containerSize.width,
height: containerSize.height - bottomSize.height,
}),
[containerSize, bottomSize]
);
// Compute sizes after scaling
const childrenStyle = useMemo(() => {
let scale: number;
let childrenWidth: number;
let childrenHeight: number;
let childrenTop: number;
let childrenLeft: number;
if (width === 0 || usableSize.width === 0) {
scale = 1;
childrenWidth = 0;
childrenHeight = 0;
childrenTop = 0;
childrenLeft = 0;
} else {
const videoRatio = width / height;
const containerRatio = usableSize.width / usableSize.height;
if (videoRatio > containerRatio) {
scale = usableSize.width / width;
childrenWidth = usableSize.width;
childrenHeight = height * scale;
childrenTop = (usableSize.height - childrenHeight) / 2;
childrenLeft = 0;
} else {
scale = usableSize.height / height;
childrenWidth = width * scale;
childrenHeight = usableSize.height;
childrenTop = 0;
childrenLeft = (usableSize.width - childrenWidth) / 2;
}
}
return {
scale,
width: childrenWidth,
height: childrenHeight,
top: childrenTop,
left: childrenLeft,
};
}, [width, height, usableSize]);
const containerRef = useRef<HTMLDivElement | null>(null);
useImperativeHandle(
ref,
() => ({
enterFullscreen() {
containerRef.current!.requestFullscreen();
},
}),
[]
);
return (
<StackItem grow styles={{ root: { position: "relative" } }}>
<div ref={containerRef} className={classes.outer} {...props}>
<ResizeObserver onResize={setContainerSize} />
<div
className={classes.inner}
style={{
top: childrenStyle.top,
left: childrenStyle.left,
width,
height,
transform: `scale(${childrenStyle.scale})`,
}}
>
{children}
</div>
{!!width && !!BottomElement && (
<BottomElement
className={classes.bottom}
style={{
top: childrenStyle.top + childrenStyle.height,
left: childrenStyle.left,
width: childrenStyle.width,
}}
>
<ResizeObserver onResize={setBottomSize} />
</BottomElement>
)}
</div>
</StackItem>
);
}
);

View file

@ -1,33 +0,0 @@
import {
Dialog,
DialogFooter,
DialogType,
PrimaryButton,
} from "@fluentui/react";
import { observer } from "mobx-react-lite";
import { PropsWithChildren } from "react";
import { GLOBAL_STATE } from "../state";
export const ErrorDialogProvider = observer((props: PropsWithChildren<{}>) => {
return (
<>
{props.children}
<Dialog
hidden={!GLOBAL_STATE.errorDialogVisible}
dialogContentProps={{
type: DialogType.normal,
title: "Error",
subText: GLOBAL_STATE.errorDialogMessage,
}}
>
<DialogFooter>
<PrimaryButton
text="OK"
onClick={GLOBAL_STATE.hideErrorDialog}
/>
</DialogFooter>
</Dialog>
</>
);
});

View file

@ -1,25 +0,0 @@
import { Link } from '@fluentui/react';
import { ReactNode } from 'react';
import { withDisplayName } from '../utils/with-display-name';
export interface ExternalLinkProps {
href: string;
spaceBefore?: boolean;
spaceAfter?: boolean;
children?: ReactNode;
}
export const ExternalLink = withDisplayName('ExternalLink')(({
href,
spaceBefore,
spaceAfter,
children,
}: ExternalLinkProps) => {
return (
<>
{spaceBefore && ' '}
<Link href={href} target="_blank" rel="noopener">{children ?? href}</Link>
{spaceAfter && ' '}
</>
);
});

View file

@ -1,384 +0,0 @@
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import {
CSSProperties,
ComponentType,
HTMLAttributes,
useEffect,
useMemo,
useState,
} from "react";
import { useStableCallback, withDisplayName } from "../utils";
import { ResizeObserver, Size } from "./resize-observer";
const useClasses = makeStyles({
container: {
display: "flex",
flexDirection: "column",
outlineStyle: "none",
...shorthands.overflow("hidden"),
},
header: {
position: "relative",
},
body: {
position: "relative",
flexGrow: 1,
height: 0,
...shorthands.overflow("auto"),
},
placeholder: {
// make horizontal scrollbar visible
minHeight: "1px",
},
row: {
position: "absolute",
top: 0,
left: 0,
right: 0,
willChange: "transform",
},
cell: {
position: "absolute",
top: 0,
left: 0,
willChange: "transform",
},
});
export interface GridCellProps {
className: string;
style: CSSProperties;
rowIndex: number;
columnIndex: number;
}
export interface GridCellWrapperProps {
CellComponent: ComponentType<GridCellProps>;
rowIndex: number;
rowHeight: number;
columnIndex: number;
columnWidth: number;
columnOffset: number;
}
const GridCellWrapper = withDisplayName("GridCellWrapper")(
({
CellComponent,
rowIndex,
rowHeight,
columnIndex,
columnWidth,
columnOffset,
}: GridCellWrapperProps) => {
const classes = useClasses();
const styles = useMemo(
() => ({
width: columnWidth,
height: rowHeight,
transform: `translateX(${columnOffset}px)`,
}),
[rowHeight, columnWidth, columnOffset]
);
return (
<CellComponent
className={classes.cell}
style={styles}
rowIndex={rowIndex}
columnIndex={columnIndex}
/>
);
}
);
export interface GridRowProps {
className: string;
style: CSSProperties;
rowIndex: number;
children: React.ReactNode;
}
export interface GridColumn {
width: number;
minWidth?: number;
maxWidth?: number;
flexGrow?: number;
flexShrink?: number;
CellComponent: ComponentType<GridCellProps>;
}
interface GridRowWrapperProps {
RowComponent: ComponentType<GridRowProps>;
rowIndex: number;
rowHeight: number;
columns: (GridColumn & { offset: number })[];
}
const GridRowWrapper = withDisplayName("GridRowWrapper")(
({ RowComponent, rowIndex, rowHeight, columns }: GridRowWrapperProps) => {
const classes = useClasses();
const styles = useMemo(
() => ({
height: rowHeight,
transform: `translateY(${rowIndex * rowHeight}px)`,
}),
[rowIndex, rowHeight]
);
return (
<RowComponent
className={classes.row}
style={styles}
rowIndex={rowIndex}
>
{columns.map((column, columnIndex) => (
<GridCellWrapper
key={columnIndex}
rowIndex={rowIndex}
rowHeight={rowHeight}
columnIndex={columnIndex}
columnWidth={column.width}
columnOffset={column.offset}
CellComponent={column.CellComponent}
/>
))}
</RowComponent>
);
}
);
export interface GridHeaderProps {
className: string;
columnIndex: number;
style: CSSProperties;
}
export interface GridProps extends HTMLAttributes<HTMLDivElement> {
rowCount: number;
rowHeight: number;
columns: GridColumn[];
HeaderComponent: ComponentType<GridHeaderProps>;
RowComponent: ComponentType<GridRowProps>;
}
export const Grid = withDisplayName("Grid")(
({
className,
rowCount,
rowHeight,
columns,
HeaderComponent,
RowComponent,
...props
}: GridProps) => {
const classes = useClasses();
const [scrollLeft, setScrollLeft] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [bodyRef, setBodyRef] = useState<HTMLDivElement | null>(null);
const [bodySize, setBodySize] = useState<Size>({ width: 0, height: 0 });
const [autoScroll, setAutoScroll] = useState(true);
const handleScroll = useStableCallback(() => {
if (bodyRef && bodyRef.scrollTop !== scrollTop) {
if (autoScroll) {
if (
scrollTop <
bodyRef.scrollHeight - bodyRef.clientHeight &&
bodyRef.scrollTop < scrollTop
) {
setAutoScroll(false);
}
} else if (
bodyRef.scrollTop + bodyRef.offsetHeight >=
bodyRef.scrollHeight - 10
) {
setAutoScroll(true);
}
setScrollLeft(bodyRef.scrollLeft);
setScrollTop(bodyRef.scrollTop);
}
});
useEffect(() => {
if (bodyRef) {
setScrollLeft(bodyRef.scrollLeft);
setScrollTop(bodyRef.scrollTop);
}
}, [bodyRef]);
const rowRange = useMemo(() => {
const start = Math.min(rowCount, Math.floor(scrollTop / rowHeight));
const end = Math.min(
rowCount,
Math.ceil((scrollTop + bodySize.height) / rowHeight)
);
return { start, end, offset: scrollTop - start * rowHeight };
}, [scrollTop, bodySize.height, rowCount, rowHeight]);
const columnMetadata = useMemo(() => {
if (bodySize.width === 0) {
return {
columns: [],
totalWidth: 0,
};
}
const result = [];
let requestedWidth = 0;
let columnsCanGrow = [];
let totalFlexGrow = 0;
let columnsCanShrink = [];
let totalFlexShrink = 0;
for (const column of columns) {
const copy = { ...column, offset: 0 };
result.push(copy);
requestedWidth += copy.width;
if (copy.flexGrow !== undefined) {
columnsCanGrow.push(copy);
totalFlexGrow += copy.flexGrow;
}
if (copy.flexShrink !== 0) {
if (copy.flexShrink === undefined) {
copy.flexShrink = 1;
}
if (copy.minWidth === undefined) {
copy.minWidth = 0;
}
columnsCanShrink.push(copy);
totalFlexShrink += copy.flexShrink;
}
}
let extraWidth = bodySize.width - requestedWidth;
while (extraWidth > 1 && columnsCanGrow.length > 0) {
const growPerRatio = extraWidth / totalFlexGrow;
columnsCanGrow = columnsCanGrow.filter((column) => {
let canGrowFurther = true;
const initialWidth = column.width;
column.width += column.flexGrow! * growPerRatio;
if (
column.maxWidth !== undefined &&
column.width > column.maxWidth
) {
column.width = column.maxWidth;
canGrowFurther = false;
}
extraWidth -= column.width - initialWidth;
return canGrowFurther;
});
}
while (extraWidth < -1 && columnsCanShrink.length > 0) {
const shrinkPerRatio = -extraWidth / totalFlexShrink;
columnsCanShrink = columnsCanShrink.filter((column) => {
let canShrinkFurther = true;
const initialWidth = column.width;
column.width -= column.flexShrink! * shrinkPerRatio;
if (column.width < column.minWidth!) {
column.width = column.minWidth!;
canShrinkFurther = false;
}
extraWidth += initialWidth - column.width;
return canShrinkFurther;
});
}
let offset = 0;
for (const column of result) {
column.offset = offset;
offset += column.width;
}
return {
columns: result,
totalWidth: offset,
};
}, [columns, bodySize.width]);
useEffect(() => {
if (autoScroll && bodyRef) {
void bodyRef.offsetLeft;
bodyRef.scrollTop = bodyRef.scrollHeight;
}
});
const headers = useMemo(
() =>
columnMetadata.columns.map((column, index) => (
<HeaderComponent
key={index}
columnIndex={index}
className={classes.cell}
style={{
width: column.width,
height: rowHeight,
transform: `translateX(${column.offset}px)`,
}}
/>
)),
[columnMetadata, HeaderComponent, classes, rowHeight]
);
const headerStyle = useMemo(
() => ({
height: rowHeight,
transform: `translateX(-${scrollLeft}px)`,
}),
[rowHeight, scrollLeft]
);
const placeholder = useMemo(
() => (
<div
className={classes.placeholder}
style={{
width: columnMetadata.totalWidth,
height: rowCount * rowHeight,
}}
/>
),
[classes, columnMetadata, rowCount, rowHeight]
);
return (
<div
className={mergeClasses(classes.container, className)}
tabIndex={-1}
{...props}
>
<div className={classes.header} style={headerStyle}>
{headers}
</div>
<div
ref={setBodyRef}
className={classes.body}
onScroll={handleScroll}
>
<ResizeObserver onResize={setBodySize} />
{placeholder}
{Array.from(
{ length: rowRange.end - rowRange.start },
(_, rowIndex) => (
<GridRowWrapper
key={rowRange.start + rowIndex}
RowComponent={RowComponent}
rowIndex={rowRange.start + rowIndex}
rowHeight={rowHeight}
columns={columnMetadata.columns}
/>
)
)}
</div>
</div>
);
}
);

View file

@ -1,113 +0,0 @@
import { makeStyles, mergeClasses } from "@griffel/react";
import { ReactNode, useMemo } from "react";
import { withDisplayName } from "../utils";
const useClasses = makeStyles({
root: {
width: "100%",
height: "100%",
overflowY: "auto",
},
flex: {
display: "flex",
},
cell: {
fontFamily: '"Cascadia Code", Consolas, monospace',
},
lineNumber: {
textAlign: "right",
},
hex: {
marginLeft: "40px",
fontVariantLigatures: "none",
},
});
const PRINTABLE_CHARACTERS: [number, number][] = [
[33, 126],
[161, 172],
[174, 255],
];
export function isPrintableCharacter(code: number) {
return PRINTABLE_CHARACTERS.some(
([start, end]) => code >= start && code <= end
);
}
export function toCharacter(code: number) {
if (isPrintableCharacter(code)) {
return String.fromCharCode(code);
}
return ".";
}
export function toText(data: Uint8Array) {
let result = "";
for (const code of data) {
result += toCharacter(code);
}
return result;
}
const PER_ROW = 16;
export interface HexViewerProps {
className?: string;
data: Uint8Array;
}
export const HexViewer = withDisplayName("HexViewer")(
({ className, data }: HexViewerProps) => {
const classes = useClasses();
// Because ADB packets are usually small,
// so don't add virtualization now.
const children = useMemo(() => {
const lineNumbers: ReactNode[] = [];
const hexRows: ReactNode[] = [];
const textRows: ReactNode[] = [];
for (let i = 0; i < data.length; i += PER_ROW) {
lineNumbers.push(<div key={i}>{i.toString(16)}</div>);
let hex = "";
for (let j = i; j < i + PER_ROW && j < data.length; j++) {
hex += data[j].toString(16).padStart(2, "0") + " ";
}
hexRows.push(<div key={i}>{hex}</div>);
textRows.push(
<div key={i}>{toText(data.slice(i, i + PER_ROW))}</div>
);
}
return {
lineNumbers,
hexRows,
textRows,
};
}, [data]);
return (
<div className={mergeClasses(classes.root, className)}>
<div className={classes.flex}>
<div
className={mergeClasses(
classes.cell,
classes.lineNumber
)}
>
{children.lineNumbers}
</div>
<div className={mergeClasses(classes.cell, classes.hex)}>
{children.hexRows}
</div>
<div className={mergeClasses(classes.cell, classes.hex)}>
{children.textRows}
</div>
</div>
</div>
);
}
);

View file

@ -1,12 +0,0 @@
export * from "./command-bar";
export * from "./connect";
export * from "./demo-mode-panel";
export * from "./device-view";
export * from "./error-dialog";
export * from "./external-link";
export * from "./grid";
export * from "./hex-viewer";
export * from "./list-selection";
export * from "./log-view";
export * from "./resize-observer";
export * from "./tabby-frame-manager";

View file

@ -1,150 +0,0 @@
import { BTree, BTreeNode } from "@yume-chan/b-tree";
import {
IAtom,
IObservableValue,
createAtom,
makeAutoObservable,
observable,
onBecomeUnobserved,
} from "mobx";
const IS_MAC =
typeof window != "undefined" &&
/Mac|iPod|iPhone|iPad/.test(globalThis.navigator.platform);
export function isModKey(e: { metaKey: boolean; ctrlKey: boolean }): boolean {
if (IS_MAC) {
return e.metaKey;
} else {
return e.ctrlKey;
}
}
export class ObservableBTree implements Omit<BTree, never> {
data: BTree;
hasMap: Map<number, IObservableValue<boolean>>;
keys: IAtom;
constructor(order: number) {
this.data = new BTree(order);
this.hasMap = new Map();
this.keys = createAtom("ObservableBTree.keys");
}
get order(): number {
return this.data.order;
}
get size(): number {
this.keys.reportObserved();
return this.data.size;
}
has(value: number): boolean {
if (!this.hasMap.has(value)) {
const observableHasValue = observable.box(this.data.has(value));
onBecomeUnobserved(observableHasValue, () =>
this.hasMap.delete(value)
);
this.hasMap.set(value, observableHasValue);
}
return this.hasMap.get(value)!.get();
}
add(value: number): boolean {
if (this.data.add(value)) {
this.hasMap.get(value)?.set(true);
this.keys.reportChanged();
return true;
}
return false;
}
delete(value: number): boolean {
if (this.data.delete(value)) {
this.hasMap.get(value)?.set(false);
this.keys.reportChanged();
return true;
}
return false;
}
clear(): void {
if (this.data.size === 0) {
return;
}
this.data.clear();
for (const entry of this.hasMap) {
entry[1].set(false);
}
this.keys.reportChanged();
}
[Symbol.iterator](): Generator<number, void, void> {
this.keys.reportObserved();
return this.data[Symbol.iterator]();
}
}
export class ObservableListSelection {
selected = new ObservableBTree(6);
rangeStart = 0;
selectedIndex: number | null = null;
constructor() {
makeAutoObservable(this);
}
get size() {
return this.selected.size;
}
has(index: number) {
return this.selected.has(index);
}
select(index: number, ctrlKey: boolean, shiftKey: boolean) {
if (this.rangeStart !== null && shiftKey) {
if (!ctrlKey) {
this.selected.clear();
}
let [start, end] = [this.rangeStart, index];
if (start > end) {
[start, end] = [end, start];
}
for (let i = start; i <= end; i += 1) {
this.selected.add(i);
}
this.selectedIndex = index;
return;
}
if (ctrlKey) {
if (this.selected.has(index)) {
this.selected.delete(index);
this.selectedIndex = null;
} else {
this.selected.add(index);
this.selectedIndex = index;
}
this.rangeStart = index;
return;
}
this.selected.clear();
this.selected.add(index);
this.rangeStart = index;
this.selectedIndex = index;
}
clear() {
this.selected.clear();
this.rangeStart = 0;
this.selectedIndex = null;
}
[Symbol.iterator]() {
return this.selected[Symbol.iterator]();
}
}

View file

@ -1,16 +0,0 @@
import { PropsWithChildren, useEffect, useState } from 'react';
export function NoSsr({ children }: PropsWithChildren<{}>) {
const [showChild, setShowChild] = useState(false);
// Wait until after client-side hydration to show
useEffect(() => {
setShowChild(true);
}, []);
if (!showChild) {
return null;
}
return <>{children}</>;
}

View file

@ -1,49 +0,0 @@
import { makeStyles } from "@griffel/react";
import { useEffect, useState } from 'react';
import { useStableCallback, withDisplayName } from '../utils';
export interface Size {
width: number;
height: number;
}
export interface ResizeObserverProps {
onResize: (size: Size) => void;
}
const useClasses = makeStyles({
observer: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
visibility: 'hidden',
}
});
export const ResizeObserver = withDisplayName('ResizeObserver')(({
onResize,
}: ResizeObserverProps): JSX.Element | null => {
const classes = useClasses();
const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null);
const handleResize = useStableCallback(() => {
const { width, height } = iframe!.getBoundingClientRect();
onResize({ width, height });
});
useEffect(() => {
if (iframe) {
void iframe.offsetLeft;
iframe.contentWindow!.addEventListener('resize', handleResize);
handleResize();
}
}, [iframe, handleResize]);
return (
<iframe ref={setIframe} className={classes.observer} />
);
});

View file

@ -1,119 +0,0 @@
import { ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy";
import { TransformStream } from "@yume-chan/stream-extra";
export class AacDecodeStream extends TransformStream<
ScrcpyMediaStreamPacket,
Float32Array[]
> {
constructor(config: AudioDecoderConfig) {
let decoder: AudioDecoder;
super({
start(controller) {
decoder = new AudioDecoder({
error(error) {
console.log("audio decoder error: ", error);
controller.error(error);
},
output(output) {
controller.enqueue(
Array.from({ length: 2 }, (_, i) => {
const options: AudioDataCopyToOptions = {
// AAC decodes to "f32-planar",
// converting to another format may cause audio glitches on Chrome.
format: "f32-planar",
planeIndex: i,
};
const buffer = new Float32Array(
output.allocationSize(options) /
Float32Array.BYTES_PER_ELEMENT
);
output.copyTo(buffer, options);
return buffer;
})
);
},
});
},
transform(chunk) {
switch (chunk.type) {
case "configuration":
// https://www.w3.org/TR/webcodecs-aac-codec-registration/#audiodecoderconfig-description
// Raw AAC stream needs `description` to be set.
decoder.configure({
...config,
description: chunk.data,
});
break;
case "data":
decoder.decode(
new EncodedAudioChunk({
data: chunk.data,
type: "key",
timestamp: 0,
})
);
}
},
async flush() {
await decoder!.flush();
},
});
}
}
export class OpusDecodeStream extends TransformStream<
ScrcpyMediaStreamPacket,
Float32Array
> {
constructor(config: AudioDecoderConfig) {
let decoder: AudioDecoder;
super({
start(controller) {
decoder = new AudioDecoder({
error(error) {
console.log("audio decoder error: ", error);
controller.error(error);
},
output(output) {
// Opus decodes to "f32",
// converting to another format may cause audio glitches on Chrome.
const options: AudioDataCopyToOptions = {
format: "f32",
planeIndex: 0,
};
const buffer = new Float32Array(
output.allocationSize(options) /
Float32Array.BYTES_PER_ELEMENT
);
output.copyTo(buffer, options);
controller.enqueue(buffer);
},
});
decoder.configure(config);
},
transform(chunk) {
switch (chunk.type) {
case "configuration":
// configuration data is a opus-in-ogg identification header,
// but stream data is raw opus,
// so it has no use here.
break;
case "data":
if (chunk.data.length === 0) {
break;
}
decoder.decode(
new EncodedAudioChunk({
type: "key",
timestamp: 0,
data: chunk.data,
})
);
}
},
async flush() {
await decoder!.flush();
},
});
}
}

View file

@ -1,290 +0,0 @@
import {
CommandBar,
ContextualMenuItemType,
ICommandBarItemProps,
} from "@fluentui/react";
import {
AndroidKeyCode,
AndroidKeyEventAction,
AndroidScreenPowerMode,
} from "@yume-chan/scrcpy";
import { action, computed } from "mobx";
import { observer } from "mobx-react-lite";
import { Icons } from "../../utils";
import { CommandBarSpacerItem } from "../command-bar-spacer-item";
import { RECORD_STATE } from "./recorder";
import { STATE } from "./state";
const ITEMS = computed(() => {
const result: ICommandBarItemProps[] = [];
result.push({
key: "stop",
iconProps: { iconName: Icons.Stop },
text: "Stop",
onClick: STATE.stop as VoidFunction,
});
result.push(
RECORD_STATE.recording
? {
key: "Record",
iconProps: {
iconName: Icons.Record,
style: { color: "red" },
},
// prettier-ignore
text: `${
RECORD_STATE.hours ? `${RECORD_STATE.hours}:` : ""
}${
RECORD_STATE.minutes.toString().padStart(2, "0")
}:${
RECORD_STATE.seconds.toString().padStart(2, "0")
}`,
onClick: action(() => {
STATE.fullScreenContainer!.focus();
RECORD_STATE.recorder.stop();
RECORD_STATE.recording = false;
}),
}
: {
key: "Record",
disabled: !STATE.running,
iconProps: { iconName: Icons.Record },
text: "Record",
onClick: action(() => {
STATE.fullScreenContainer!.focus();
RECORD_STATE.recorder.start();
RECORD_STATE.recording = true;
}),
}
);
result.push({
key: "fullscreen",
disabled: !STATE.running,
iconProps: { iconName: Icons.FullScreenMaximize },
iconOnly: true,
text: "Fullscreen",
onClick: action(() => {
STATE.fullScreenContainer!.focus();
STATE.fullScreenContainer!.requestFullscreen();
STATE.isFullScreen = true;
}),
});
result.push(
{
key: "volumeUp",
disabled: !STATE.running,
iconProps: { iconName: Icons.Speaker2 },
iconOnly: true,
text: "Volume Up",
onClick: (async () => {
STATE.fullScreenContainer!.focus();
// TODO: Auto repeat when holding
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.VolumeUp,
repeat: 0,
metaState: 0,
});
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.VolumeUp,
repeat: 0,
metaState: 0,
});
}) as () => void,
},
{
key: "volumeDown",
disabled: !STATE.running,
iconProps: { iconName: Icons.Speaker1 },
iconOnly: true,
text: "Volume Down",
onClick: (async () => {
STATE.fullScreenContainer!.focus();
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.VolumeDown,
repeat: 0,
metaState: 0,
});
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.VolumeDown,
repeat: 0,
metaState: 0,
});
}) as () => void,
},
{
key: "volumeMute",
disabled: !STATE.running,
iconProps: { iconName: Icons.SpeakerOff },
iconOnly: true,
text: "Toggle Mute",
onClick: (async () => {
STATE.fullScreenContainer!.focus();
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.VolumeMute,
repeat: 0,
metaState: 0,
});
await STATE.client?.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.VolumeMute,
repeat: 0,
metaState: 0,
});
}) as () => void,
}
);
result.push(
{
key: "rotateDevice",
disabled: !STATE.running,
iconProps: { iconName: Icons.Orientation },
iconOnly: true,
text: "Rotate Device",
onClick: () => {
STATE.fullScreenContainer!.focus();
STATE.client!.controlMessageWriter!.rotateDevice();
},
},
{
key: "rotateVideoLeft",
disabled: !STATE.running,
iconProps: { iconName: Icons.RotateLeft },
iconOnly: true,
text: "Rotate Video Left",
onClick: action(() => {
STATE.fullScreenContainer!.focus();
STATE.rotation -= 1;
if (STATE.rotation < 0) {
STATE.rotation = 3;
}
}),
},
{
key: "rotateVideoRight",
disabled: !STATE.running,
iconProps: { iconName: Icons.RotateRight },
iconOnly: true,
text: "Rotate Video Right",
onClick: action(() => {
STATE.fullScreenContainer!.focus();
STATE.rotation = (STATE.rotation + 1) & 3;
}),
}
);
result.push(
{
key: "turnScreenOff",
disabled: !STATE.running,
iconProps: { iconName: Icons.Lightbulb },
iconOnly: true,
text: "Turn Screen Off",
onClick: () => {
STATE.fullScreenContainer!.focus();
STATE.client!.controlMessageWriter!.setScreenPowerMode(
AndroidScreenPowerMode.Off
);
},
},
{
key: "turnScreenOn",
disabled: !STATE.running,
iconProps: { iconName: Icons.LightbulbFilament },
iconOnly: true,
text: "Turn Screen On",
onClick: () => {
STATE.fullScreenContainer!.focus();
STATE.client!.controlMessageWriter!.setScreenPowerMode(
AndroidScreenPowerMode.Normal
);
},
}
);
if (STATE.running) {
result.push({
key: "fps",
text: `FPS: ${STATE.fps}`,
disabled: true,
});
}
result.push(
{
// HACK: make CommandBar overflow on far items
// https://github.com/microsoft/fluentui/issues/11842
key: "spacer",
onRender: () => <CommandBarSpacerItem />,
},
{
// HACK: add a separator in CommandBar overflow menu
// https://github.com/microsoft/fluentui/issues/10035
key: "separator",
disabled: true,
itemType: ContextualMenuItemType.Divider,
}
);
result.push(
{
key: "NavigationBar",
iconProps: { iconName: Icons.PanelBottom },
canCheck: true,
checked: STATE.navigationBarVisible,
text: "Navigation Bar",
iconOnly: true,
onClick: action(() => {
STATE.navigationBarVisible = !STATE.navigationBarVisible;
}),
},
{
key: "Log",
iconProps: { iconName: Icons.TextGrammarError },
canCheck: true,
checked: STATE.logVisible,
text: "Log",
iconOnly: true,
onClick: action(() => {
STATE.logVisible = !STATE.logVisible;
}),
},
{
key: "DemoMode",
iconProps: { iconName: Icons.Wand },
canCheck: true,
checked: STATE.demoModeVisible,
text: "Demo Mode",
iconOnly: true,
onClick: action(() => {
STATE.demoModeVisible = !STATE.demoModeVisible;
}),
}
);
return result;
});
export const ScrcpyCommandBar = observer(function ScrcpyCommandBar() {
return <CommandBar items={ITEMS.get()} />;
});

View file

@ -1,79 +0,0 @@
import { EventEmitter } from "@yume-chan/event";
import { BIN } from "@yume-chan/fetch-scrcpy-server";
class FetchWithProgress {
public readonly promise: Promise<Uint8Array>;
private _downloaded = 0;
public get downloaded() {
return this._downloaded;
}
private _total = 0;
public get total() {
return this._total;
}
private progressEvent = new EventEmitter<
[download: number, total: number]
>();
public get onProgress() {
return this.progressEvent.event;
}
public constructor(url: string | URL) {
this.promise = this.fetch(url);
}
private async fetch(url: string | URL) {
const response = await globalThis.fetch(url);
this._total = Number.parseInt(
response.headers.get("Content-Length") ?? "0",
10
);
this.progressEvent.fire([this._downloaded, this._total]);
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const result = await reader.read();
if (result.done) {
break;
}
chunks.push(result.value);
this._downloaded += result.value.byteLength;
this.progressEvent.fire([this._downloaded, this._total]);
}
this._total = chunks.reduce(
(result, item) => result + item.byteLength,
0
);
const result = new Uint8Array(this._total);
let position = 0;
for (const chunk of chunks) {
result.set(chunk, position);
position += chunk.byteLength;
}
return result;
}
}
let cachedValue: FetchWithProgress | undefined;
export function fetchServer(
onProgress?: (e: [downloaded: number, total: number]) => void
) {
if (!cachedValue) {
cachedValue = new FetchWithProgress(BIN);
cachedValue.promise.catch(() => {
cachedValue = undefined;
});
}
if (onProgress) {
cachedValue.onProgress(onProgress);
onProgress([cachedValue.downloaded, cachedValue.total]);
}
return cachedValue.promise;
}

View file

@ -1,6 +0,0 @@
export * from "./command-bar";
export * from "./fetch-server";
export * from "./navigation-bar";
export * from "./settings";
export * from "./state";
export * from "./video-container";

View file

@ -1,224 +0,0 @@
import { AdbScrcpyClient } from "@yume-chan/adb-scrcpy";
import { AoaHidDevice, HidKeyCode, HidKeyboard } from "@yume-chan/aoa";
import { Disposable } from "@yume-chan/event";
import {
AndroidKeyCode,
AndroidKeyEventAction,
AndroidKeyEventMeta,
} from "@yume-chan/scrcpy";
export interface KeyboardInjector extends Disposable {
down(key: string): Promise<void>;
up(key: string): Promise<void>;
reset(): Promise<void>;
}
export class ScrcpyKeyboardInjector implements KeyboardInjector {
private readonly client: AdbScrcpyClient;
private _controlLeft = false;
private _controlRight = false;
private _shiftLeft = false;
private _shiftRight = false;
private _altLeft = false;
private _altRight = false;
private _metaLeft = false;
private _metaRight = false;
private _capsLock = false;
private _numLock = true;
private _keys: Set<AndroidKeyCode> = new Set();
public constructor(client: AdbScrcpyClient) {
this.client = client;
}
private setModifier(keyCode: AndroidKeyCode, value: boolean) {
switch (keyCode) {
case AndroidKeyCode.ControlLeft:
this._controlLeft = value;
break;
case AndroidKeyCode.ControlRight:
this._controlRight = value;
break;
case AndroidKeyCode.ShiftLeft:
this._shiftLeft = value;
break;
case AndroidKeyCode.ShiftRight:
this._shiftRight = value;
break;
case AndroidKeyCode.AltLeft:
this._altLeft = value;
break;
case AndroidKeyCode.AltRight:
this._altRight = value;
break;
case AndroidKeyCode.MetaLeft:
this._metaLeft = value;
break;
case AndroidKeyCode.MetaRight:
this._metaRight = value;
break;
case AndroidKeyCode.CapsLock:
if (value) {
this._capsLock = !this._capsLock;
}
break;
case AndroidKeyCode.NumLock:
if (value) {
this._numLock = !this._numLock;
}
break;
}
}
private getMetaState(): AndroidKeyEventMeta {
let metaState = 0;
if (this._altLeft) {
metaState |=
AndroidKeyEventMeta.AltOn | AndroidKeyEventMeta.AltLeftOn;
}
if (this._altRight) {
metaState |=
AndroidKeyEventMeta.AltOn | AndroidKeyEventMeta.AltRightOn;
}
if (this._shiftLeft) {
metaState |=
AndroidKeyEventMeta.ShiftOn | AndroidKeyEventMeta.ShiftLeftOn;
}
if (this._shiftRight) {
metaState |=
AndroidKeyEventMeta.ShiftOn | AndroidKeyEventMeta.ShiftRightOn;
}
if (this._controlLeft) {
metaState |=
AndroidKeyEventMeta.CtrlOn | AndroidKeyEventMeta.CtrlLeftOn;
}
if (this._controlRight) {
metaState |=
AndroidKeyEventMeta.CtrlOn | AndroidKeyEventMeta.CtrlRightOn;
}
if (this._metaLeft) {
metaState |=
AndroidKeyEventMeta.MetaOn | AndroidKeyEventMeta.MetaLeftOn;
}
if (this._metaRight) {
metaState |=
AndroidKeyEventMeta.MetaOn | AndroidKeyEventMeta.MetaRightOn;
}
if (this._capsLock) {
metaState |= AndroidKeyEventMeta.CapsLockOn;
}
if (this._numLock) {
metaState |= AndroidKeyEventMeta.NumLockOn;
}
return metaState;
}
public async down(key: string): Promise<void> {
const keyCode = AndroidKeyCode[key as keyof typeof AndroidKeyCode];
if (!keyCode) {
return;
}
this.setModifier(keyCode, true);
this._keys.add(keyCode);
await this.client.controlMessageWriter?.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode,
metaState: this.getMetaState(),
repeat: 0,
});
}
public async up(key: string): Promise<void> {
const keyCode = AndroidKeyCode[key as keyof typeof AndroidKeyCode];
if (!keyCode) {
return;
}
this.setModifier(keyCode, false);
this._keys.delete(keyCode);
await this.client.controlMessageWriter?.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode,
metaState: this.getMetaState(),
repeat: 0,
});
}
public async reset(): Promise<void> {
this._controlLeft = false;
this._controlRight = false;
this._shiftLeft = false;
this._shiftRight = false;
this._altLeft = false;
this._altRight = false;
this._metaLeft = false;
this._metaRight = false;
for (const key of this._keys) {
this.up(AndroidKeyCode[key]);
}
this._keys.clear();
}
public dispose(): void {
// do nothing
}
}
export class AoaKeyboardInjector implements KeyboardInjector {
public static async register(
device: USBDevice
): Promise<AoaKeyboardInjector> {
const keyboard = await AoaHidDevice.register(
device,
0,
HidKeyboard.DESCRIPTOR
);
return new AoaKeyboardInjector(keyboard);
}
private readonly aoaKeyboard: AoaHidDevice;
private readonly hidKeyboard = new HidKeyboard();
public constructor(aoaKeyboard: AoaHidDevice) {
this.aoaKeyboard = aoaKeyboard;
}
public async down(key: string): Promise<void> {
const keyCode = HidKeyCode[key as keyof typeof HidKeyCode];
if (!keyCode) {
return;
}
this.hidKeyboard.down(keyCode);
await this.aoaKeyboard.sendInputReport(
this.hidKeyboard.serializeInputReport()
);
}
public async up(key: string): Promise<void> {
const keyCode = HidKeyCode[key as keyof typeof HidKeyCode];
if (!keyCode) {
return;
}
this.hidKeyboard.up(keyCode);
await this.aoaKeyboard.sendInputReport(
this.hidKeyboard.serializeInputReport()
);
}
public async reset(): Promise<void> {
this.hidKeyboard.reset();
await this.aoaKeyboard.sendInputReport(
this.hidKeyboard.serializeInputReport()
);
}
public async dispose(): Promise<void> {
await this.aoaKeyboard.unregister();
}
}

View file

@ -1,183 +0,0 @@
import { IconButton, Stack } from "@fluentui/react";
import { makeStyles, mergeClasses } from "@griffel/react";
import { AndroidKeyCode, AndroidKeyEventAction } from "@yume-chan/scrcpy";
import { observer } from "mobx-react-lite";
import { CSSProperties, PointerEvent, ReactNode } from "react";
import { Icons } from "../../utils";
import { STATE } from "./state";
const useClasses = makeStyles({
container: {
height: "40px",
backgroundColor: "#999",
},
bar: {
width: "100%",
maxWidth: "300px",
},
icon: {
color: "white",
},
back: {
transform: "rotate(180deg)",
},
});
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return false;
}
if (e.button !== 0) {
return false;
}
STATE.fullScreenContainer!.focus();
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
return true;
}
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return false;
}
if (e.button !== 0) {
return false;
}
return true;
}
function handleBackPointerDown(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerDown(e)) {
return;
}
STATE.client!.controlMessageWriter!.backOrScreenOn(
AndroidKeyEventAction.Down
);
}
function handleBackPointerUp(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerUp(e)) {
return;
}
STATE.client!.controlMessageWriter!.backOrScreenOn(
AndroidKeyEventAction.Up
);
}
function handleHomePointerDown(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerDown(e)) {
return;
}
STATE.client!.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.AndroidHome,
repeat: 0,
metaState: 0,
});
}
function handleHomePointerUp(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerUp(e)) {
return;
}
STATE.client!.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.AndroidHome,
repeat: 0,
metaState: 0,
});
}
function handleAppSwitchPointerDown(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerDown(e)) {
return;
}
STATE.client!.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.AndroidAppSwitch,
repeat: 0,
metaState: 0,
});
}
function handleAppSwitchPointerUp(e: PointerEvent<HTMLDivElement>) {
if (!handlePointerUp(e)) {
return;
}
STATE.client!.controlMessageWriter!.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.AndroidAppSwitch,
repeat: 0,
metaState: 0,
});
}
export const NavigationBar = observer(function NavigationBar({
className,
style,
children,
}: {
className: string;
style: CSSProperties;
children: ReactNode;
}) {
const classes = useClasses();
if (!STATE.navigationBarVisible) {
return (
<div className={className} style={style}>
{children}
</div>
);
}
return (
<Stack
className={mergeClasses(classes.container, className)}
verticalFill
horizontalAlign="center"
style={style}
>
{children}
<Stack
className={classes.bar}
verticalFill
horizontal
horizontalAlign="space-evenly"
verticalAlign="center"
>
<IconButton
className={mergeClasses(classes.back, classes.icon)}
iconProps={{ iconName: Icons.Play }}
onPointerDown={handleBackPointerDown}
onPointerUp={handleBackPointerUp}
/>
<IconButton
className={classes.icon}
iconProps={{ iconName: Icons.Circle }}
onPointerDown={handleHomePointerDown}
onPointerUp={handleHomePointerUp}
/>
<IconButton
className={classes.icon}
iconProps={{ iconName: Icons.Stop }}
onPointerDown={handleAppSwitchPointerDown}
onPointerUp={handleAppSwitchPointerUp}
/>
</Stack>
</Stack>
);
});

View file

@ -1,456 +0,0 @@
// cspell: ignore MPEGH
// cspell: ignore rbsp
// cspell: ignore Nalus
import {
H265NaluRaw,
ScrcpyAudioCodec,
ScrcpyMediaStreamDataPacket,
ScrcpyMediaStreamPacket,
ScrcpyVideoCodecId,
ScrcpyVideoStreamMetadata,
annexBSplitNalu,
h264SearchConfiguration,
h265ParseSequenceParameterSet,
h265ParseVideoParameterSet,
h265SearchConfiguration,
} from "@yume-chan/scrcpy";
import { action, makeAutoObservable, reaction } from "mobx";
import { ArrayBufferTarget, Muxer as WebMMuxer } from "webm-muxer";
import { saveFile } from "../../utils";
// https://ffmpeg.org/doxygen/0.11/avc_8c-source.html#l00106
function h264ConfigurationToAvcDecoderConfigurationRecord(
sequenceParameterSet: Uint8Array,
pictureParameterSet: Uint8Array
) {
const buffer = new Uint8Array(
11 + sequenceParameterSet.byteLength + pictureParameterSet.byteLength
);
buffer[0] = 1;
buffer[1] = sequenceParameterSet[1];
buffer[2] = sequenceParameterSet[2];
buffer[3] = sequenceParameterSet[3];
buffer[4] = 0xff;
buffer[5] = 0xe1;
buffer[6] = sequenceParameterSet.byteLength >> 8;
buffer[7] = sequenceParameterSet.byteLength & 0xff;
buffer.set(sequenceParameterSet, 8);
buffer[8 + sequenceParameterSet.byteLength] = 1;
buffer[9 + sequenceParameterSet.byteLength] =
pictureParameterSet.byteLength >> 8;
buffer[10 + sequenceParameterSet.byteLength] =
pictureParameterSet.byteLength & 0xff;
buffer.set(pictureParameterSet, 11 + sequenceParameterSet.byteLength);
return buffer;
}
function h265ConfigurationToHevcDecoderConfigurationRecord(
videoParameterSet: H265NaluRaw,
sequenceParameterSet: H265NaluRaw,
pictureParameterSet: H265NaluRaw
) {
const {
profileTierLevel: {
generalProfileTier: {
profile_space: general_profile_space,
tier_flag: general_tier_flag,
profile_idc: general_profile_idc,
profileCompatibilitySet: generalProfileCompatibilitySet,
constraintSet: generalConstraintSet,
},
general_level_idc,
},
vps_max_layers_minus1,
vps_temporal_id_nesting_flag,
} = h265ParseVideoParameterSet(videoParameterSet.rbsp);
const {
chroma_format_idc,
bit_depth_luma_minus8,
bit_depth_chroma_minus8,
vuiParameters: { min_spatial_segmentation_idc = 0 } = {},
} = h265ParseSequenceParameterSet(sequenceParameterSet.rbsp);
const buffer = new Uint8Array(
23 +
5 * 3 +
videoParameterSet.data.length +
sequenceParameterSet.data.length +
pictureParameterSet.data.length
);
/* unsigned int(8) configurationVersion = 1; */
buffer[0] = 1;
/*
* unsigned int(2) general_profile_space;
* unsigned int(1) general_tier_flag;
* unsigned int(5) general_profile_idc;
*/
buffer[1] =
(general_profile_space << 6) |
(Number(general_tier_flag) << 5) |
general_profile_idc;
/* unsigned int(32) general_profile_compatibility_flags; */
buffer[2] = generalProfileCompatibilitySet[0];
buffer[3] = generalProfileCompatibilitySet[1];
buffer[4] = generalProfileCompatibilitySet[2];
buffer[5] = generalProfileCompatibilitySet[3];
/* unsigned int(48) general_constraint_indicator_flags; */
buffer[6] = generalConstraintSet[0];
buffer[7] = generalConstraintSet[1];
buffer[8] = generalConstraintSet[2];
buffer[9] = generalConstraintSet[3];
buffer[10] = generalConstraintSet[4];
buffer[11] = generalConstraintSet[5];
/* unsigned int(8) general_level_idc; */
buffer[12] = general_level_idc;
/*
* bit(4) reserved = '1111'b;
* unsigned int(12) min_spatial_segmentation_idc;
*/
buffer[13] = 0xf0 | (min_spatial_segmentation_idc >> 8);
buffer[14] = min_spatial_segmentation_idc;
/*
* bit(6) reserved = '111111'b;
* unsigned int(2) parallelismType;
*/
buffer[15] = 0xfc;
/*
* bit(6) reserved = '111111'b;
* unsigned int(2) chromaFormat;
*/
buffer[16] = 0xfc | chroma_format_idc;
/*
* bit(5) reserved = '11111'b;
* unsigned int(3) bitDepthLumaMinus8;
*/
buffer[17] = 0xf8 | bit_depth_luma_minus8;
/*
* bit(5) reserved = '11111'b;
* unsigned int(3) bitDepthChromaMinus8;
*/
buffer[18] = 0xf8 | bit_depth_chroma_minus8;
/* bit(16) avgFrameRate; */
buffer[19] = 0;
buffer[20] = 0;
/*
* bit(2) constantFrameRate;
* bit(3) numTemporalLayers;
* bit(1) temporalIdNested;
* unsigned int(2) lengthSizeMinusOne;
*/
buffer[21] =
((vps_max_layers_minus1 + 1) << 3) |
(Number(vps_temporal_id_nesting_flag) << 2) |
3;
/* unsigned int(8) numOfArrays; */
buffer[22] = 3;
let i = 23;
for (const nalu of [
videoParameterSet,
sequenceParameterSet,
pictureParameterSet,
]) {
/*
* bit(1) array_completeness;
* unsigned int(1) reserved = 0;
* unsigned int(6) NAL_unit_type;
*/
buffer[i] = nalu.nal_unit_type;
i += 1;
/* unsigned int(16) numNalus; */
buffer[i] = 0;
i += 1;
buffer[i] = 1;
i += 1;
/* unsigned int(16) nalUnitLength; */
buffer[i] = nalu.data.length >> 8;
i += 1;
buffer[i] = nalu.data.length;
i += 1;
buffer.set(nalu.data, i);
i += nalu.data.length;
}
return buffer;
}
function h264StreamToAvcSample(buffer: Uint8Array) {
const nalUnits: Uint8Array[] = [];
let totalLength = 0;
for (const unit of annexBSplitNalu(buffer)) {
nalUnits.push(unit);
totalLength += unit.byteLength + 4;
}
const sample = new Uint8Array(totalLength);
let offset = 0;
for (const nalu of nalUnits) {
sample[offset] = nalu.byteLength >> 24;
sample[offset + 1] = nalu.byteLength >> 16;
sample[offset + 2] = nalu.byteLength >> 8;
sample[offset + 3] = nalu.byteLength & 0xff;
sample.set(nalu, offset + 4);
offset += 4 + nalu.byteLength;
}
return sample;
}
// https://github.com/FFmpeg/FFmpeg/blob/adb5f7b41faf354a3e0bf722f44aeb230aefa310/libavformat/matroska.c
const MatroskaVideoCodecNameMap: Record<ScrcpyVideoCodecId, string> = {
[ScrcpyVideoCodecId.H264]: "V_MPEG4/ISO/AVC",
[ScrcpyVideoCodecId.H265]: "V_MPEGH/ISO/HEVC",
[ScrcpyVideoCodecId.AV1]: "V_AV1",
};
const MatroskaAudioCodecNameMap: Record<string, string> = {
[ScrcpyAudioCodec.RAW.mimeType]: "A_PCM/INT/LIT",
[ScrcpyAudioCodec.AAC.mimeType]: "A_AAC",
[ScrcpyAudioCodec.OPUS.mimeType]: "A_OPUS",
};
export class MatroskaMuxingRecorder {
public running = false;
public videoMetadata: ScrcpyVideoStreamMetadata | undefined;
public audioCodec: ScrcpyAudioCodec | undefined;
private muxer: WebMMuxer<ArrayBufferTarget> | undefined;
private videoCodecDescription: Uint8Array | undefined;
private configurationWritten = false;
private _firstTimestamp = -1;
private _packetsFromLastKeyframe: {
type: "video" | "audio";
packet: ScrcpyMediaStreamDataPacket;
}[] = [];
private addVideoChunk(packet: ScrcpyMediaStreamDataPacket) {
if (this._firstTimestamp === -1) {
this._firstTimestamp = Number(packet.pts!);
}
const sample = h264StreamToAvcSample(packet.data);
this.muxer!.addVideoChunkRaw(
sample,
packet.keyframe ? "key" : "delta",
Number(packet.pts) - this._firstTimestamp,
this.configurationWritten
? undefined
: {
decoderConfig: {
// Not used
codec: "",
description: this.videoCodecDescription,
},
}
);
this.configurationWritten = true;
}
public addVideoPacket(packet: ScrcpyMediaStreamPacket) {
if (!this.videoMetadata) {
throw new Error("videoMetadata must be set");
}
try {
if (packet.type === "configuration") {
switch (this.videoMetadata.codec) {
case ScrcpyVideoCodecId.H264: {
const { sequenceParameterSet, pictureParameterSet } =
h264SearchConfiguration(packet.data);
this.videoCodecDescription =
h264ConfigurationToAvcDecoderConfigurationRecord(
sequenceParameterSet,
pictureParameterSet
);
this.configurationWritten = false;
break;
}
case ScrcpyVideoCodecId.H265: {
const {
videoParameterSet,
sequenceParameterSet,
pictureParameterSet,
} = h265SearchConfiguration(packet.data);
this.videoCodecDescription =
h265ConfigurationToHevcDecoderConfigurationRecord(
videoParameterSet,
sequenceParameterSet,
pictureParameterSet
);
this.configurationWritten = false;
break;
}
}
return;
}
// To ensure the first frame is a keyframe
// save the last keyframe and the following frames
if (packet.keyframe === true) {
this._packetsFromLastKeyframe.length = 0;
}
this._packetsFromLastKeyframe.push({ type: "video", packet });
if (!this.muxer) {
return;
}
this.addVideoChunk(packet);
} catch (e) {
console.error(e);
}
}
private addAudioChunk(chunk: ScrcpyMediaStreamDataPacket) {
if (this._firstTimestamp === -1) {
return;
}
const timestamp = Number(chunk.pts) - this._firstTimestamp;
if (timestamp < 0) {
return;
}
if (!this.muxer) {
return;
}
this.muxer.addAudioChunkRaw(chunk.data, "key", timestamp);
}
public addAudioPacket(packet: ScrcpyMediaStreamDataPacket) {
this._packetsFromLastKeyframe.push({ type: "audio", packet });
this.addAudioChunk(packet);
}
public start() {
if (!this.videoMetadata) {
throw new Error("videoMetadata must be set");
}
this.running = true;
const options: ConstructorParameters<typeof WebMMuxer>[0] = {
target: new ArrayBufferTarget(),
type: "matroska",
firstTimestampBehavior: "permissive",
video: {
codec: MatroskaVideoCodecNameMap[this.videoMetadata.codec!],
width: this.videoMetadata.width ?? 0,
height: this.videoMetadata.height ?? 0,
},
};
if (this.audioCodec) {
options.audio = {
codec: MatroskaAudioCodecNameMap[this.audioCodec.mimeType!],
sampleRate: 48000,
numberOfChannels: 2,
bitDepth:
this.audioCodec === ScrcpyAudioCodec.RAW ? 16 : undefined,
};
}
this.muxer = new WebMMuxer(options as any);
if (this._packetsFromLastKeyframe.length > 0) {
for (const { type, packet } of this._packetsFromLastKeyframe) {
if (type === "video") {
this.addVideoChunk(packet);
} else {
this.addAudioChunk(packet);
}
}
}
}
public stop() {
if (!this.muxer) {
return;
}
this.muxer.finalize()!;
const buffer = this.muxer.target.buffer;
const now = new Date();
const stream = saveFile(
// prettier-ignore
`Recording ${
now.getFullYear()
}-${
(now.getMonth() + 1).toString().padStart(2, '0')
}-${
now.getDate().toString().padStart(2, '0')
} ${
now.getHours().toString().padStart(2, '0')
}-${
now.getMinutes().toString().padStart(2, '0')
}-${
now.getSeconds().toString().padStart(2, '0')
}.mkv`
);
const writer = stream.getWriter();
writer.write(new Uint8Array(buffer));
writer.close();
this.muxer = undefined;
this.configurationWritten = false;
this.running = false;
}
}
export const RECORD_STATE = makeAutoObservable({
recorder: new MatroskaMuxingRecorder(),
recording: false,
intervalId: -1,
hours: 0,
minutes: 0,
seconds: 0,
});
reaction(
() => RECORD_STATE.recording,
(recording) => {
if (recording) {
RECORD_STATE.intervalId = globalThis.setInterval(
action(() => {
RECORD_STATE.seconds += 1;
if (RECORD_STATE.seconds >= 60) {
RECORD_STATE.seconds = 0;
RECORD_STATE.minutes += 1;
}
if (RECORD_STATE.minutes >= 60) {
RECORD_STATE.minutes = 0;
RECORD_STATE.hours += 1;
}
}),
1000
) as unknown as number;
} else {
globalThis.clearInterval(RECORD_STATE.intervalId);
RECORD_STATE.intervalId = -1;
RECORD_STATE.hours = 0;
RECORD_STATE.minutes = 0;
RECORD_STATE.seconds = 0;
}
}
);

View file

@ -1,607 +0,0 @@
import {
Dropdown,
IDropdownOption,
Icon,
IconButton,
Position,
SpinButton,
Stack,
TextField,
Toggle,
TooltipHost,
} from "@fluentui/react";
import { makeStyles } from "@griffel/react";
import { AdbSyncError } from "@yume-chan/adb";
import { AdbScrcpyClient, AdbScrcpyOptionsLatest } from "@yume-chan/adb-scrcpy";
import { VERSION } from "@yume-chan/fetch-scrcpy-server";
import {
DEFAULT_SERVER_PATH,
ScrcpyDisplay,
ScrcpyEncoder,
ScrcpyLogLevel,
ScrcpyOptionsInitLatest,
ScrcpyOptionsLatest,
ScrcpyVideoOrientation,
} from "@yume-chan/scrcpy";
import {
ScrcpyVideoDecoderConstructor,
TinyH264Decoder,
} from "@yume-chan/scrcpy-decoder-tinyh264";
import { ConcatStringStream, DecodeUtf8Stream } from "@yume-chan/stream-extra";
import {
autorun,
computed,
makeAutoObservable,
observable,
runInAction,
} from "mobx";
import { observer } from "mobx-react-lite";
import { GLOBAL_STATE } from "../../state";
import { Icons } from "../../utils";
import { STATE } from "./state";
export type Settings = ScrcpyOptionsInitLatest;
export interface ClientSettings {
turnScreenOff?: boolean;
decoder?: string;
ignoreDecoderCodecArgs?: boolean;
}
export type SettingKeys = keyof (Settings & ClientSettings);
export interface SettingDefinitionBase {
group: "settings" | "clientSettings";
key: SettingKeys;
type: string;
label: string;
labelExtra?: JSX.Element;
description?: string;
}
export interface TextSettingDefinition extends SettingDefinitionBase {
type: "text";
placeholder?: string;
}
export interface DropdownSettingDefinition extends SettingDefinitionBase {
type: "dropdown";
placeholder?: string;
options: IDropdownOption[];
}
export interface ToggleSettingDefinition extends SettingDefinitionBase {
type: "toggle";
disabled?: boolean;
}
export interface NumberSettingDefinition extends SettingDefinitionBase {
type: "number";
min?: number;
max?: number;
step?: number;
}
export type SettingDefinition =
| TextSettingDefinition
| DropdownSettingDefinition
| ToggleSettingDefinition
| NumberSettingDefinition;
interface SettingItemProps {
definition: SettingDefinition;
value: any;
onChange: (definition: SettingDefinition, value: any) => void;
}
const useClasses = makeStyles({
labelRight: {
marginLeft: "4px",
},
item: {
width: "100%",
maxWidth: "300px",
},
});
export const SettingItem = observer(function SettingItem({
definition,
value,
onChange,
}: SettingItemProps) {
const classes = useClasses();
let label: JSX.Element = (
<Stack horizontal verticalAlign="center">
<span>{definition.label}</span>
{!!definition.description && (
<TooltipHost content={definition.description}>
<Icon
className={classes.labelRight}
iconName={Icons.Info}
/>
</TooltipHost>
)}
{definition.labelExtra}
</Stack>
);
switch (definition.type) {
case "text":
return (
<TextField
className={classes.item}
label={label as any}
placeholder={definition.placeholder}
value={value}
onChange={(e, value) => onChange(definition, value)}
/>
);
case "dropdown":
return (
<Dropdown
className={classes.item}
label={label as any}
options={definition.options}
placeholder={definition.placeholder}
selectedKey={value}
onChange={(e, option) => onChange(definition, option!.key)}
/>
);
case "toggle":
return (
<Toggle
label={label}
checked={value}
disabled={definition.disabled}
onChange={(e, checked) => onChange(definition, checked)}
/>
);
case "number":
return (
<SpinButton
className={classes.item}
label={definition.label}
labelPosition={Position.top}
min={definition.min}
max={definition.max}
step={definition.step}
value={value.toString()}
onChange={(e, value) =>
onChange(definition, Number.parseInt(value!, 10))
}
/>
);
}
});
export interface DecoderDefinition {
key: string;
name: string;
Constructor: ScrcpyVideoDecoderConstructor;
}
const DEFAULT_SETTINGS = {
maxSize: 1080,
videoBitRate: 4_000_000,
videoCodec: "h264",
lockVideoOrientation: ScrcpyVideoOrientation.Unlocked,
displayId: 0,
crop: "",
powerOn: true,
audio: true,
audioCodec: "aac",
} as Settings;
export const SETTING_STATE = makeAutoObservable(
{
displays: [] as ScrcpyDisplay[],
encoders: [] as ScrcpyEncoder[],
decoders: [
{
key: "tinyh264",
name: "TinyH264 (Software)",
Constructor: TinyH264Decoder,
},
] as DecoderDefinition[],
settings: DEFAULT_SETTINGS,
clientSettings: {} as ClientSettings,
},
{
decoders: observable.shallow,
settings: observable.deep,
clientSettings: observable.deep,
}
);
export const SCRCPY_SETTINGS_FILENAME = "/data/local/tmp/.tango.json";
autorun(() => {
if (GLOBAL_STATE.adb) {
(async () => {
const sync = await GLOBAL_STATE.adb!.sync();
try {
const settings = JSON.parse(
await sync
.read(SCRCPY_SETTINGS_FILENAME)
.pipeThrough(new DecodeUtf8Stream())
.pipeThrough(new ConcatStringStream())
);
runInAction(() => {
SETTING_STATE.settings = {
...DEFAULT_SETTINGS,
...settings.settings,
};
SETTING_STATE.clientSettings = settings.clientSettings;
});
} catch (e) {
if (!(e instanceof AdbSyncError)) {
throw e;
}
} finally {
await sync.dispose();
}
})();
runInAction(() => {
SETTING_STATE.encoders = [];
SETTING_STATE.displays = [];
SETTING_STATE.settings.displayId = undefined;
});
}
});
autorun(() => {
SETTING_STATE.clientSettings.decoder = SETTING_STATE.decoders[0].key;
});
export const SETTING_DEFINITIONS = computed(() => {
const result: SettingDefinition[] = [];
result.push(
{
group: "settings",
key: "powerOn",
type: "toggle",
label: "Wake device up on start",
},
{
group: "clientSettings",
key: "turnScreenOff",
type: "toggle",
label: "Turn screen off during mirroring",
},
{
group: "settings",
key: "stayAwake",
type: "toggle",
label: "Stay awake during mirroring (if plugged in)",
},
{
group: "settings",
key: "powerOffOnClose",
type: "toggle",
label: "Turn device off on stop",
}
);
result.push({
group: "settings",
key: "displayId",
type: "dropdown",
label: "Display",
placeholder: "Press refresh to update available displays",
labelExtra: (
<IconButton
iconProps={{ iconName: Icons.ArrowClockwise }}
disabled={!GLOBAL_STATE.adb}
text="Refresh"
onClick={async () => {
try {
await STATE.pushServer();
const displays = await AdbScrcpyClient.getDisplays(
GLOBAL_STATE.adb!,
DEFAULT_SERVER_PATH,
VERSION,
new AdbScrcpyOptionsLatest(
new ScrcpyOptionsLatest({
logLevel: ScrcpyLogLevel.Debug,
})
)
);
runInAction(() => {
SETTING_STATE.displays = displays;
if (
!SETTING_STATE.settings.displayId ||
!SETTING_STATE.displays.some(
(x) =>
x.id ===
SETTING_STATE.settings.displayId
)
) {
SETTING_STATE.settings.displayId =
SETTING_STATE.displays[0]?.id;
}
});
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
}
}}
/>
),
options: SETTING_STATE.displays.map((item) => ({
key: item.id,
text: `${item.id}${item.resolution ? ` (${item.resolution})` : ""}`,
})),
});
result.push({
group: "settings",
key: "crop",
type: "text",
label: "Crop",
placeholder: "W:H:X:Y",
});
result.push(
{
group: "settings",
key: "maxSize",
type: "number",
label: "Max Resolution (longer side, 0 = unlimited)",
min: 0,
max: 2560,
step: 50,
},
{
group: "settings",
key: "videoBitRate",
type: "number",
label: "Max Video Bitrate (bps)",
min: 100,
max: 100_000_000,
step: 100,
},
{
group: "settings",
key: "videoCodec",
type: "dropdown",
label: "Video Codec",
options: [
{
key: "h264",
text: "H.264",
},
{
key: "h265",
text: "H.265",
},
],
},
{
group: "settings",
key: "videoEncoder",
type: "dropdown",
label: "Video Encoder",
placeholder:
SETTING_STATE.encoders.length === 0
? "Press refresh button to update encoder list"
: "(default)",
labelExtra: (
<IconButton
iconProps={{ iconName: Icons.ArrowClockwise }}
disabled={!GLOBAL_STATE.adb}
text="Refresh"
onClick={async () => {
try {
await STATE.pushServer();
const encoders = await AdbScrcpyClient.getEncoders(
GLOBAL_STATE.adb!,
DEFAULT_SERVER_PATH,
VERSION,
new AdbScrcpyOptionsLatest(
new ScrcpyOptionsLatest({
logLevel: ScrcpyLogLevel.Debug,
})
)
);
runInAction(() => {
SETTING_STATE.encoders = encoders;
});
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
}
}}
/>
),
options: SETTING_STATE.encoders
.filter(
(item) =>
item.type === "video" &&
(!item.codec ||
item.codec === SETTING_STATE.settings.videoCodec!)
)
.map((item) => ({
key: item.name,
text: item.name,
})),
}
);
result.push({
group: "settings",
key: "lockVideoOrientation",
type: "dropdown",
label: "Lock Video Orientation",
options: [
{
key: ScrcpyVideoOrientation.Unlocked,
text: "Unlocked",
},
{
key: ScrcpyVideoOrientation.Initial,
text: "Current",
},
{
key: ScrcpyVideoOrientation.Portrait,
text: "Portrait",
},
{
key: ScrcpyVideoOrientation.Landscape,
text: "Landscape",
},
{
key: ScrcpyVideoOrientation.PortraitFlipped,
text: "Portrait (Flipped)",
},
{
key: ScrcpyVideoOrientation.LandscapeFlipped,
text: "Landscape (Flipped)",
},
],
});
if (SETTING_STATE.decoders.length > 1) {
result.push({
group: "clientSettings",
key: "decoder",
type: "dropdown",
label: "Video Decoder",
options: SETTING_STATE.decoders.map((item) => ({
key: item.key,
text: item.name,
data: item,
})),
});
}
result.push({
group: "clientSettings",
key: "ignoreDecoderCodecArgs",
type: "toggle",
label: `Ignore video decoder's codec options`,
description: `Some decoders don't support all H.264 profile/levels, so they request the device to encode at their highest-supported codec. However, some super old devices may not support that codec so their encoders will fail to start. Use this option to let device choose the codec to be used.`,
});
result.push(
{
group: "settings",
key: "audio",
type: "toggle",
label: "Forward Audio (Requires Android 11)",
},
{
group: "settings",
key: "audioCodec",
type: "dropdown",
label: "Audio Codec",
options: [
{
key: "raw",
text: "Raw",
},
{
key: "aac",
text: "AAC",
},
{
key: "opus",
text: "Opus",
},
],
},
{
group: "settings",
key: "audioEncoder",
type: "dropdown",
placeholder:
SETTING_STATE.encoders.length === 0
? "Press refresh button to update encoder list"
: "(default)",
label: "Audio Encoder",
labelExtra: (
<IconButton
iconProps={{ iconName: Icons.ArrowClockwise }}
disabled={!GLOBAL_STATE.adb}
text="Refresh"
onClick={async () => {
try {
await STATE.pushServer();
const encoders = await AdbScrcpyClient.getEncoders(
GLOBAL_STATE.adb!,
DEFAULT_SERVER_PATH,
VERSION,
new AdbScrcpyOptionsLatest(
new ScrcpyOptionsLatest({
logLevel: ScrcpyLogLevel.Debug,
})
)
);
runInAction(() => {
SETTING_STATE.encoders = encoders;
});
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
}
}}
/>
),
options: SETTING_STATE.encoders
.filter(
(x) =>
x.type === "audio" &&
x.codec === SETTING_STATE.settings.audioCodec
)
.map((item) => ({
key: item.name,
text: item.name,
})),
}
);
return result;
});
autorun(() => {
if (SETTING_STATE.encoders.length === 0) {
SETTING_STATE.settings.videoEncoder = "";
SETTING_STATE.settings.audioEncoder = "";
return;
}
const encodersForCurrentVideoCodec = SETTING_STATE.encoders.filter(
(item) =>
item.type === "video" &&
item.codec === SETTING_STATE.settings.videoCodec
);
if (
SETTING_STATE.settings.videoEncoder &&
encodersForCurrentVideoCodec.every(
(item) => item.name !== SETTING_STATE.settings.videoEncoder
)
) {
SETTING_STATE.settings.videoEncoder = "";
}
const encodersForCurrentAudioCodec = SETTING_STATE.encoders.filter(
(item) =>
item.type === "audio" &&
item.codec === SETTING_STATE.settings.audioCodec
);
if (
SETTING_STATE.settings.audioEncoder &&
encodersForCurrentAudioCodec.every(
(item) => item.name !== SETTING_STATE.settings.audioEncoder
)
) {
SETTING_STATE.settings.audioEncoder = "";
}
});

View file

@ -1,637 +0,0 @@
import { ADB_SYNC_MAX_PACKET_SIZE, encodeUtf8 } from "@yume-chan/adb";
import { AdbDaemonWebUsbDevice } from "@yume-chan/adb-daemon-webusb";
import { AdbScrcpyClient, AdbScrcpyOptionsLatest } from "@yume-chan/adb-scrcpy";
import { VERSION } from "@yume-chan/fetch-scrcpy-server";
import {
Float32PcmPlayer,
Float32PlanerPcmPlayer,
Int16PcmPlayer,
PcmPlayer,
} from "@yume-chan/pcm-player";
import {
AndroidScreenPowerMode,
CodecOptions,
DEFAULT_SERVER_PATH,
ScrcpyAudioCodec,
ScrcpyDeviceMessageType,
ScrcpyHoverHelper,
ScrcpyInstanceId,
ScrcpyLogLevel,
ScrcpyMediaStreamPacket,
ScrcpyOptionsLatest,
ScrcpyVideoCodecId,
clamp,
h264ParseConfiguration,
h265ParseConfiguration,
} from "@yume-chan/scrcpy";
import { ScrcpyVideoDecoder } from "@yume-chan/scrcpy-decoder-tinyh264";
import {
Consumable,
DistributionStream,
InspectStream,
ReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import { action, autorun, makeAutoObservable, runInAction } from "mobx";
import { GLOBAL_STATE } from "../../state";
import { ProgressStream } from "../../utils";
import { AacDecodeStream, OpusDecodeStream } from "./audio-decode-stream";
import { fetchServer } from "./fetch-server";
import {
AoaKeyboardInjector,
KeyboardInjector,
ScrcpyKeyboardInjector,
} from "./input";
import { MatroskaMuxingRecorder, RECORD_STATE } from "./recorder";
import { SCRCPY_SETTINGS_FILENAME, SETTING_STATE } from "./settings";
const NOOP = () => {
// no-op
};
export class ScrcpyPageState {
running = false;
fullScreenContainer: HTMLDivElement | null = null;
rendererContainer: HTMLDivElement | null = null;
isFullScreen = false;
logVisible = false;
log: string[] = [];
demoModeVisible = false;
navigationBarVisible = true;
width = 0;
height = 0;
rotation = 0;
get rotatedWidth() {
return STATE.rotation & 1 ? STATE.height : STATE.width;
}
get rotatedHeight() {
return STATE.rotation & 1 ? STATE.width : STATE.height;
}
client: AdbScrcpyClient | undefined = undefined;
hoverHelper: ScrcpyHoverHelper | undefined = undefined;
keyboard: KeyboardInjector | undefined = undefined;
audioPlayer: PcmPlayer<unknown> | undefined = undefined;
async pushServer() {
const serverBuffer = await fetchServer();
await AdbScrcpyClient.pushServer(
GLOBAL_STATE.adb!,
new ReadableStream<Consumable<Uint8Array>>({
start(controller) {
controller.enqueue(new Consumable(serverBuffer));
controller.close();
},
})
);
}
decoder: ScrcpyVideoDecoder | undefined = undefined;
fpsCounterIntervalId: any = undefined;
fps = "0";
connecting = false;
serverTotalSize = 0;
serverDownloadedSize = 0;
debouncedServerDownloadedSize = 0;
serverDownloadSpeed = 0;
serverUploadedSize = 0;
debouncedServerUploadedSize = 0;
serverUploadSpeed = 0;
constructor() {
makeAutoObservable(this, {
start: false,
stop: action.bound,
dispose: action.bound,
setFullScreenContainer: action.bound,
setRendererContainer: action.bound,
clientPositionToDevicePosition: false,
});
autorun(() => {
if (!GLOBAL_STATE.adb) {
this.dispose();
}
});
if (typeof document === "object") {
document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement) {
runInAction(() => {
this.isFullScreen = false;
});
}
});
}
autorun(() => {
if (this.rendererContainer && this.decoder) {
while (this.rendererContainer.firstChild) {
this.rendererContainer.firstChild.remove();
}
this.rendererContainer.appendChild(this.decoder.renderer);
}
});
}
start = async () => {
if (!GLOBAL_STATE.adb) {
return;
}
try {
if (!SETTING_STATE.clientSettings.decoder) {
throw new Error("No available decoder");
}
runInAction(() => {
this.serverTotalSize = 0;
this.serverDownloadedSize = 0;
this.debouncedServerDownloadedSize = 0;
this.serverUploadedSize = 0;
this.debouncedServerUploadedSize = 0;
this.connecting = true;
});
let intervalId = setInterval(
action(() => {
this.serverDownloadSpeed =
this.serverDownloadedSize -
this.debouncedServerDownloadedSize;
this.debouncedServerDownloadedSize =
this.serverDownloadedSize;
}),
1000
);
let serverBuffer: Uint8Array;
try {
serverBuffer = await fetchServer(
action(([downloaded, total]) => {
this.serverDownloadedSize = downloaded;
this.serverTotalSize = total;
})
);
runInAction(() => {
this.serverDownloadSpeed =
this.serverDownloadedSize -
this.debouncedServerDownloadedSize;
this.debouncedServerDownloadedSize =
this.serverDownloadedSize;
});
} finally {
clearInterval(intervalId);
}
intervalId = setInterval(
action(() => {
this.serverUploadSpeed =
this.serverUploadedSize -
this.debouncedServerUploadedSize;
this.debouncedServerUploadedSize = this.serverUploadedSize;
}),
1000
);
try {
await AdbScrcpyClient.pushServer(
GLOBAL_STATE.adb!,
new ReadableStream<Consumable<Uint8Array>>({
start(controller) {
controller.enqueue(new Consumable(serverBuffer));
controller.close();
},
})
// In fact `pushServer` will pipe the stream through a DistributionStream,
// but without this pipeThrough, the progress will not be updated.
.pipeThrough(
new DistributionStream(ADB_SYNC_MAX_PACKET_SIZE)
)
.pipeThrough(
new ProgressStream(
action((progress) => {
this.serverUploadedSize = progress;
})
)
)
);
runInAction(() => {
this.serverUploadSpeed =
this.serverUploadedSize -
this.debouncedServerUploadedSize;
this.debouncedServerUploadedSize = this.serverUploadedSize;
});
} finally {
clearInterval(intervalId);
}
const decoderDefinition =
SETTING_STATE.decoders.find(
(x) => x.key === SETTING_STATE.clientSettings.decoder
) ?? SETTING_STATE.decoders[0];
const videoCodecOptions = new CodecOptions();
if (!SETTING_STATE.clientSettings.ignoreDecoderCodecArgs) {
const capability =
decoderDefinition.Constructor.capabilities[
SETTING_STATE.settings.videoCodec!
];
if (capability) {
videoCodecOptions.value.profile = capability.maxProfile;
videoCodecOptions.value.level = capability.maxLevel;
}
}
// Disabled due to https://github.com/Genymobile/scrcpy/issues/2841
// Less recording delay
// codecOptions.value.iFrameInterval = 1;
// Less latency
// codecOptions.value.intraRefreshPeriod = 10000;
const options = new AdbScrcpyOptionsLatest(
new ScrcpyOptionsLatest({
...SETTING_STATE.settings,
logLevel: ScrcpyLogLevel.Debug,
scid: ScrcpyInstanceId.random(),
sendDeviceMeta: false,
sendDummyByte: false,
videoCodecOptions,
})
);
runInAction(() => {
this.log = [];
this.log.push(`[client] Server version: ${VERSION}`);
this.log.push(
`[client] Server arguments: ${options
.serialize()
.join(" ")}`
);
});
const client = await AdbScrcpyClient.start(
GLOBAL_STATE.adb!,
DEFAULT_SERVER_PATH,
VERSION,
options
);
client.stdout.pipeTo(
new WritableStream<string>({
write: action((line) => {
this.log.push(line);
}),
})
);
const sync = await GLOBAL_STATE.adb!.sync();
try {
await sync.write({
filename: SCRCPY_SETTINGS_FILENAME,
file: new ReadableStream<Consumable<Uint8Array>>({
start(controller) {
controller.enqueue(
new Consumable(
encodeUtf8(
JSON.stringify({
settings: SETTING_STATE.settings,
clientSettings:
SETTING_STATE.clientSettings,
})
)
)
);
controller.close();
},
}),
});
} finally {
sync.dispose();
}
RECORD_STATE.recorder = new MatroskaMuxingRecorder();
client.videoStream!.then(({ stream, metadata }) => {
runInAction(() => {
RECORD_STATE.recorder.videoMetadata = metadata;
});
const decoder = new decoderDefinition.Constructor(
metadata.codec
);
runInAction(() => {
this.decoder = decoder;
let lastFrameRendered = 0;
let lastFrameSkipped = 0;
this.fpsCounterIntervalId = setInterval(
action(() => {
const deltaRendered =
decoder.frameRendered - lastFrameRendered;
const deltaSkipped =
decoder.frameSkipped - lastFrameSkipped;
// prettier-ignore
this.fps = `${
deltaRendered
}${
deltaSkipped ? `+${deltaSkipped} skipped` : ""
}`;
lastFrameRendered = decoder.frameRendered;
lastFrameSkipped = decoder.frameSkipped;
}),
1000
);
});
let lastKeyframe = 0n;
const handler = new InspectStream<ScrcpyMediaStreamPacket>(
(packet) => {
RECORD_STATE.recorder.addVideoPacket(packet);
if (packet.type === "configuration") {
let croppedWidth: number;
let croppedHeight: number;
switch (metadata.codec) {
case ScrcpyVideoCodecId.H264:
({ croppedWidth, croppedHeight } =
h264ParseConfiguration(packet.data));
break;
case ScrcpyVideoCodecId.H265:
({ croppedWidth, croppedHeight } =
h265ParseConfiguration(packet.data));
break;
default:
throw new Error("Codec not supported");
}
runInAction(() => {
this.log.push(
`[client] Video size changed: ${croppedWidth}x${croppedHeight}`
);
this.width = croppedWidth;
this.height = croppedHeight;
});
} else if (
packet.keyframe &&
packet.pts !== undefined
) {
if (lastKeyframe) {
const interval =
(Number(packet.pts - lastKeyframe) / 1000) |
0;
runInAction(() => {
this.log.push(
`[client] Keyframe interval: ${interval}ms`
);
});
}
lastKeyframe = packet.pts!;
}
}
);
stream.pipeThrough(handler).pipeTo(decoder.writable);
});
client.audioStream?.then(async (metadata) => {
switch (metadata.type) {
case "disabled":
runInAction(() =>
this.log.push(
`[client] Demuxer audio: stream explicitly disabled by the device`
)
);
return;
case "errored":
runInAction(() =>
this.log.push(
`[client] Demuxer audio: stream configuration error on the device`
)
);
return;
case "success":
// Code is after this `switch`
break;
default:
throw new Error(
`Unexpected audio metadata type ${
metadata["type"] as unknown as string
}`
);
}
const [recordStream, playbackStream] = metadata.stream.tee();
switch (metadata.codec) {
case ScrcpyAudioCodec.RAW: {
const audioPlayer = new Int16PcmPlayer(48000);
this.audioPlayer = audioPlayer;
playbackStream.pipeTo(
new WritableStream({
write: (chunk) => {
audioPlayer.feed(
new Int16Array(
chunk.data.buffer,
chunk.data.byteOffset,
chunk.data.byteLength /
Int16Array.BYTES_PER_ELEMENT
)
);
},
})
);
await this.audioPlayer.start();
break;
}
case ScrcpyAudioCodec.OPUS: {
const audioPlayer = new Float32PcmPlayer(48000);
this.audioPlayer = audioPlayer;
playbackStream
.pipeThrough(
new OpusDecodeStream({
codec: metadata.codec.webCodecId,
numberOfChannels: 2,
sampleRate: 48000,
})
)
.pipeTo(
new WritableStream({
write: (chunk) => {
audioPlayer.feed(chunk);
},
})
);
await audioPlayer.start();
break;
}
case ScrcpyAudioCodec.AAC: {
const audioPlayer = new Float32PlanerPcmPlayer(48000);
this.audioPlayer = audioPlayer;
playbackStream
.pipeThrough(
new AacDecodeStream({
codec: metadata.codec.webCodecId,
numberOfChannels: 2,
sampleRate: 48000,
})
)
.pipeTo(
new WritableStream({
write: (chunk) => {
audioPlayer.feed(chunk);
},
})
);
await audioPlayer.start();
break;
}
default:
throw new Error(
`Unsupported audio codec ${metadata.codec.optionValue}`
);
}
runInAction(() => {
RECORD_STATE.recorder.audioCodec = metadata.codec;
});
recordStream.pipeTo(
new WritableStream({
write: (packet) => {
if (packet.type === "data") {
RECORD_STATE.recorder.addAudioPacket(packet);
}
},
})
);
});
client.exit.then(this.dispose);
client.deviceMessageStream!.pipeTo(
new WritableStream({
write(message) {
switch (message.type) {
case ScrcpyDeviceMessageType.Clipboard:
globalThis.navigator.clipboard.writeText(
message.content
);
break;
}
},
})
);
if (SETTING_STATE.clientSettings.turnScreenOff) {
await client.controlMessageWriter!.setScreenPowerMode(
AndroidScreenPowerMode.Off
);
}
runInAction(() => {
this.client = client;
this.hoverHelper = new ScrcpyHoverHelper();
this.running = true;
});
const device = GLOBAL_STATE.device!;
if (device instanceof AdbDaemonWebUsbDevice) {
this.keyboard = await AoaKeyboardInjector.register(device.raw);
} else {
this.keyboard = new ScrcpyKeyboardInjector(client);
}
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
} finally {
runInAction(() => {
this.connecting = false;
});
}
};
async stop() {
// Request to close client first
await this.client?.close();
this.dispose();
}
dispose() {
// Otherwise some packets may still arrive at decoder
this.decoder?.dispose();
this.decoder = undefined;
if (RECORD_STATE.recording) {
RECORD_STATE.recorder.stop();
RECORD_STATE.recording = false;
}
this.keyboard?.dispose();
this.keyboard = undefined;
this.audioPlayer?.stop();
this.audioPlayer = undefined;
this.fps = "0";
clearTimeout(this.fpsCounterIntervalId);
if (this.isFullScreen) {
document.exitFullscreen();
this.isFullScreen = false;
}
this.client = undefined;
this.running = false;
}
setFullScreenContainer(element: HTMLDivElement | null) {
this.fullScreenContainer = element;
}
setRendererContainer(element: HTMLDivElement | null) {
this.rendererContainer = element;
}
clientPositionToDevicePosition(clientX: number, clientY: number) {
const viewRect = this.rendererContainer!.getBoundingClientRect();
let pointerViewX = clamp((clientX - viewRect.x) / viewRect.width, 0, 1);
let pointerViewY = clamp(
(clientY - viewRect.y) / viewRect.height,
0,
1
);
if (this.rotation & 1) {
[pointerViewX, pointerViewY] = [pointerViewY, pointerViewX];
}
switch (this.rotation) {
case 1:
pointerViewY = 1 - pointerViewY;
break;
case 2:
pointerViewX = 1 - pointerViewX;
pointerViewY = 1 - pointerViewY;
break;
case 3:
pointerViewX = 1 - pointerViewX;
break;
}
return {
x: pointerViewX * this.width,
y: pointerViewY * this.height,
};
}
}
export const STATE = new ScrcpyPageState();

View file

@ -1,180 +0,0 @@
import { makeStyles } from "@griffel/react";
import {
AndroidMotionEventAction,
AndroidMotionEventButton,
ScrcpyPointerId,
} from "@yume-chan/scrcpy";
import { MouseEvent, PointerEvent, useEffect, useState } from "react";
import { STATE } from "./state";
const useClasses = makeStyles({
video: {
transformOrigin: "center center",
touchAction: "none",
},
});
function handleWheel(e: WheelEvent) {
if (!STATE.client) {
return;
}
STATE.fullScreenContainer!.focus();
e.preventDefault();
e.stopPropagation();
const { x, y } = STATE.clientPositionToDevicePosition(e.clientX, e.clientY);
STATE.client!.controlMessageWriter!.injectScroll({
screenWidth: STATE.client!.screenWidth!,
screenHeight: STATE.client!.screenHeight!,
pointerX: x,
pointerY: y,
scrollX: -e.deltaX / 100,
scrollY: -e.deltaY / 100,
buttons: 0,
});
}
const MOUSE_EVENT_BUTTON_TO_ANDROID_BUTTON = [
AndroidMotionEventButton.Primary,
AndroidMotionEventButton.Tertiary,
AndroidMotionEventButton.Secondary,
AndroidMotionEventButton.Back,
AndroidMotionEventButton.Forward,
];
function injectTouch(
action: AndroidMotionEventAction,
e: PointerEvent<HTMLDivElement>
) {
if (!STATE.client) {
return;
}
const { pointerType } = e;
let pointerId: bigint;
if (pointerType === "mouse") {
// Android 13 has bug with mouse injection
// https://github.com/Genymobile/scrcpy/issues/3708
pointerId = ScrcpyPointerId.Finger;
} else {
pointerId = BigInt(e.pointerId);
}
const { x, y } = STATE.clientPositionToDevicePosition(e.clientX, e.clientY);
const messages = STATE.hoverHelper!.process({
action,
pointerId,
screenWidth: STATE.client.screenWidth!,
screenHeight: STATE.client.screenHeight!,
pointerX: x,
pointerY: y,
pressure: e.pressure,
actionButton: MOUSE_EVENT_BUTTON_TO_ANDROID_BUTTON[e.button],
// `MouseEvent.buttons` has the same order as Android `MotionEvent`
buttons: e.buttons,
});
for (const message of messages) {
STATE.client.controlMessageWriter!.injectTouch(message);
}
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return;
}
STATE.fullScreenContainer!.focus();
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
injectTouch(AndroidMotionEventAction.Down, e);
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return;
}
e.preventDefault();
e.stopPropagation();
injectTouch(
e.buttons === 0
? AndroidMotionEventAction.HoverMove
: AndroidMotionEventAction.Move,
e
);
}
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return;
}
e.preventDefault();
e.stopPropagation();
injectTouch(AndroidMotionEventAction.Up, e);
}
function handlePointerLeave(e: PointerEvent<HTMLDivElement>) {
if (!STATE.client) {
return;
}
e.preventDefault();
e.stopPropagation();
// Because pointer capture on pointer down, this event only happens for hovering mouse and pen.
// Release the injected pointer, otherwise it will stuck at the last position.
injectTouch(AndroidMotionEventAction.HoverExit, e);
injectTouch(AndroidMotionEventAction.Up, e);
}
function handleContextMenu(e: MouseEvent<HTMLDivElement>) {
e.preventDefault();
}
export function VideoContainer() {
const classes = useClasses();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
useEffect(() => {
STATE.setRendererContainer(container);
if (!container) {
return;
}
container.addEventListener("wheel", handleWheel, {
passive: false,
});
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [container]);
return (
<div
ref={setContainer}
className={classes.video}
style={{
width: STATE.width,
height: STATE.height,
transform: `translate(${
(STATE.rotatedWidth - STATE.width) / 2
}px, ${(STATE.rotatedHeight - STATE.height) / 2}px) rotate(${
STATE.rotation * 90
}deg)`,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerLeave={handlePointerLeave}
onContextMenu={handleContextMenu}
/>
);
}

View file

@ -1,178 +0,0 @@
import { AdbIncomingSocketHandler, AdbTransport } from "@yume-chan/adb";
import {
WrapConsumableStream,
WrapWritableStream,
} from "@yume-chan/stream-extra";
import { ValueOrPromise } from "@yume-chan/struct";
import * as Comlink from "comlink";
import { autorun } from "mobx";
import getConfig from "next/config";
import { GLOBAL_STATE } from "../state";
let port: MessagePort | undefined;
let resizeObserver: ResizeObserver | undefined;
let frame: HTMLIFrameElement | undefined;
export interface AdbProxyTransportServer {
disconnected: Promise<void>;
connect(
service: string,
callback: (
readable: ReadableStream<Uint8Array>,
writable: WritableStream<Uint8Array>,
close: () => ValueOrPromise<void>
) => void
): void;
addReverseTunnel(
handler: AdbIncomingSocketHandler,
address?: string
): ValueOrPromise<string>;
removeReverseTunnel(address: string): ValueOrPromise<void>;
clearReverseTunnels(): ValueOrPromise<void>;
close(): ValueOrPromise<void>;
}
class AdbProxyTransportServerImpl implements AdbProxyTransportServer {
private _transport: AdbTransport;
public get disconnected() {
return this._transport.disconnected;
}
public constructor(transport: AdbTransport) {
this._transport = transport;
}
public async connect(
service: string,
callback: (
readable: ReadableStream<Uint8Array>,
writable: WritableStream<Uint8Array>,
close: () => ValueOrPromise<void>
) => void
) {
const socket = await this._transport.connect(service);
const writable = new WrapWritableStream(
socket.writable
).bePipedThroughFrom(new WrapConsumableStream());
callback(
Comlink.transfer(socket.readable, [socket.readable]),
Comlink.transfer(writable, [writable]),
Comlink.proxy(() => socket.close())
);
}
public addReverseTunnel(
handler: AdbIncomingSocketHandler,
address?: string
): ValueOrPromise<string> {
return this._transport.addReverseTunnel(handler, address);
}
public removeReverseTunnel(address: string): ValueOrPromise<void> {
return this._transport.removeReverseTunnel(address);
}
public clearReverseTunnels(): ValueOrPromise<void> {
return this._transport.clearReverseTunnels();
}
public close(): ValueOrPromise<void> {
return this._transport.close();
}
}
function syncDevice() {
if (!frame) {
return;
}
if (port) {
port.close();
port = undefined;
}
const { adb: adb } = GLOBAL_STATE;
if (adb) {
const channel = new MessageChannel();
port = channel.port1;
const server = new AdbProxyTransportServerImpl(adb.transport);
Comlink.expose(server, port);
const { product, model, device, features } = adb.banner;
frame.contentWindow?.postMessage(
{
type: "adb-connect",
serial: adb.serial,
maxPayloadSize: adb.maxPayloadSize,
banner: {
product,
model,
device,
features,
},
port: channel.port2,
},
"*",
[channel.port2]
);
}
}
export function attachTabbyFrame(container: HTMLDivElement | null) {
if (container === null) {
if (resizeObserver !== undefined) {
resizeObserver.disconnect();
}
if (frame !== undefined) {
frame.style.visibility = "hidden";
}
return;
}
if (!frame) {
const {
publicRuntimeConfig: { basePath },
} = getConfig();
frame = document.createElement("iframe");
frame.src = `${basePath}/tabby-frame`;
frame.style.display = "block";
frame.style.position = "fixed";
frame.style.border = "none";
document.body.appendChild(frame);
globalThis.addEventListener("message", (e) => {
// Wait for Tabby to be ready
if (e.source === frame?.contentWindow && e.data === "adb-ready") {
syncDevice();
}
});
// Sync device when it's changed
autorun(syncDevice);
}
// Because re-parent an iframe will cause it to reload,
// use visibility to show/hide it
// and use a ResizeObserver to put it in the right place.
frame.style.visibility = "visible";
resizeObserver = new ResizeObserver(() => {
const { top, left, width, height } = container.getBoundingClientRect();
if (width === 0 || height === 0) {
// zero size makes xterm.js wrap lines incorrectly
return;
}
frame!.style.top = `${top}px`;
frame!.style.left = `${left}px`;
frame!.style.width = `${width}px`;
frame!.style.height = `${height}px`;
});
resizeObserver.observe(container);
}

View file

@ -1,40 +0,0 @@
import { useEffect } from "react";
type CommonEventMaps<T> = T extends typeof globalThis
? WindowEventMap
: T extends Window
? WindowEventMap
: T extends Document
? DocumentEventMap
: T extends HTMLElement
? HTMLElementEventMap
: T extends SVGElement
? SVGElementEventMap
: { [type: string]: unknown };
const useClientAddEventListener = <
T extends EventTarget,
U extends keyof CommonEventMaps<T>
>(
target: T | (() => T),
type: U,
listener: (this: T, ev: CommonEventMaps<T>[U]) => any,
options?: AddEventListenerOptions,
deps?: readonly unknown[]
) => {
useEffect(() => {
const targetValue = typeof target === "function" ? target() : target;
targetValue.addEventListener(type as any, listener as any, options);
return () =>
targetValue.removeEventListener(
type as any,
listener as any,
options
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
};
export const useAddEventListener =
typeof window !== "undefined" ? useClientAddEventListener : () => {};

View file

@ -1,4 +0,0 @@
export * from "./add-event-listener";
export * from "./layout-effect";
export * from "./local-storage";
export * from "./stable-callback";

View file

@ -1,4 +0,0 @@
import { useLayoutEffect as useReactLayoutEffect } from "react";
export const useLayoutEffect =
typeof window !== "undefined" ? useReactLayoutEffect : () => {};

View file

@ -1,34 +0,0 @@
import { useState } from "react";
import { useAddEventListener } from "./add-event-listener";
import { useStableCallback } from "./stable-callback";
function useClientLocalStorage<T extends string = string>(
key: string,
fallbackValue: T
) {
const [value, setValue] = useState<T>(
() => (localStorage.getItem(key) as T) || fallbackValue
);
useAddEventListener(
globalThis,
"storage",
() =>
setValue((localStorage.getItem(key) as T | null) ?? fallbackValue),
{ passive: true },
[key, fallbackValue]
);
const handleChange = useStableCallback((value: T) => {
setValue(value);
localStorage.setItem(key, value);
});
return [value, handleChange] as const;
}
export const useLocalStorage: typeof useClientLocalStorage =
typeof localStorage !== "undefined"
? useClientLocalStorage
: (key, fallbackValue) => [fallbackValue, () => {}];

View file

@ -1,35 +0,0 @@
import { MutableRefObject, useRef } from "react";
import { useLayoutEffect } from "./layout-effect";
const UNINITIALIZED = Symbol("UNINITIALIZED");
export function useConstLazy<T>(initializer: () => T): T {
const ref = useRef<T | typeof UNINITIALIZED>(UNINITIALIZED);
if (ref.current === UNINITIALIZED) {
ref.current = initializer();
}
return ref.current;
}
export function useConst<T>(value: T): T {
const ref = useRef(value);
return ref.current;
}
export function useLatestRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
export function useStableCallback<T extends (...args: any[]) => void>(
callback: T
): T {
const callbackRef = useLatestRef(callback);
return useConst(function (...args) {
return callbackRef.current(...args);
} as T);
}

View file

@ -1,224 +0,0 @@
import {
IComponentAsProps,
INavButtonProps,
IconButton,
Nav,
Stack,
StackItem,
} 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";
import { useCallback, useEffect, useState } from "react";
import { Connect, ErrorDialogProvider } from "../components";
import "../styles/globals.css";
import { Icons } from "../utils";
import { register as registerIcons } from "../utils/icons";
registerIcons();
const ROUTES = [
{
url: "/",
icon: Icons.Bookmark,
name: "README",
},
{
url: "/device-info",
icon: Icons.Phone,
name: "Device Info",
},
{
url: "/file-manager",
icon: Icons.Folder,
name: "File Manager",
},
{
url: "/framebuffer",
icon: Icons.Camera,
name: "Screen Capture",
},
{
url: "/shell",
icon: Icons.WindowConsole,
name: "Interactive Shell",
},
{
url: "/scrcpy",
icon: Icons.PhoneLaptop,
name: "Scrcpy",
},
{
url: "/tcpip",
icon: Icons.WifiSettings,
name: "ADB over WiFi",
},
{
url: "/install",
icon: Icons.Box,
name: "Install APK",
},
{
url: "/logcat",
icon: Icons.BookSearch,
name: "Logcat",
},
{
url: "/power",
icon: Icons.Power,
name: "Power Menu",
},
{
url: "/chrome-devtools",
icon: Icons.WindowDevTools,
name: "Chrome Remote Debugging",
},
{
url: "/bug-report",
icon: Icons.Bug,
name: "Bug Report",
},
{
url: "/packet-log",
icon: Icons.TextGrammarError,
name: "Packet Log",
},
];
function NavLink({
link,
defaultRender: DefaultRender,
...props
}: IComponentAsProps<INavButtonProps>) {
if (!link) {
return null;
}
return (
<Link href={link.url} legacyBehavior passHref>
<DefaultRender {...props} />
</Link>
);
}
const useClasses = makeStyles({
titleContainer: {
...shorthands.borderBottom("1px", "solid", "rgb(243, 242, 241)"),
},
hidden: {
display: "none",
},
title: {
...shorthands.padding("4px", "0"),
fontSize: "20px",
textAlign: "center",
},
leftColumn: {
width: "270px",
paddingRight: "8px",
...shorthands.borderRight("1px", "solid", "rgb(243, 242, 241)"),
overflowY: "auto",
},
});
const {
publicRuntimeConfig: { basePath },
} = getConfig();
function App({ Component, pageProps }: AppProps) {
const classes = useClasses();
const [leftPanelVisible, setLeftPanelVisible] = useState(false);
const toggleLeftPanel = useCallback(() => {
setLeftPanelVisible((value) => !value);
}, []);
useEffect(() => {
setLeftPanelVisible(innerWidth > 650);
}, []);
const router = useRouter();
if ("noLayout" in Component) {
return <Component {...pageProps} />;
}
return (
<ErrorDialogProvider>
<Head>
<link rel="manifest" href={basePath + "/manifest.json"} />
</Head>
<Stack verticalFill>
<Stack
className={classes.titleContainer}
horizontal
verticalAlign="center"
>
<IconButton
checked={leftPanelVisible}
title="Toggle Menu"
iconProps={{ iconName: Icons.Navigation }}
onClick={toggleLeftPanel}
/>
<StackItem grow>
<div className={classes.title}>Tango</div>
</StackItem>
<IconButton
iconProps={{ iconName: "PersonFeedback" }}
title="Feedback"
as="a"
href="https://github.com/yume-chan/ya-webadb/issues/new"
target="_blank"
/>
</Stack>
<Stack
grow
horizontal
verticalFill
disableShrink
styles={{
root: {
minHeight: 0,
overflow: "hidden",
lineHeight: "1.5",
},
}}
>
<StackItem
className={mergeClasses(
classes.leftColumn,
!leftPanelVisible && classes.hidden
)}
>
<Connect />
<Nav
groups={[
{
links: ROUTES.map((route) => ({
...route,
key: route.url,
})),
},
]}
linkAs={NavLink}
selectedKey={router.pathname}
/>
</StackItem>
<StackItem grow styles={{ root: { width: 0 } }}>
<Component {...pageProps} />
</StackItem>
</Stack>
</Stack>
</ErrorDialogProvider>
);
}
export default App;

View file

@ -1,46 +0,0 @@
import { resetIds, Stylesheet } from "@fluentui/react";
import { enableStaticRendering } from "mobx-react-lite";
import Document, { DocumentContext, DocumentInitialProps } from "next/document";
enableStaticRendering(true);
const stylesheet = Stylesheet.getInstance();
export default class MyDocument extends Document {
static async getInitialProps(
context: DocumentContext
): Promise<DocumentInitialProps> {
resetIds();
const { html, head, styles, ...rest } = await Document.getInitialProps(
context
);
return {
...rest,
html,
head: [
...(head ?? []),
<script
key="fluentui"
dangerouslySetInnerHTML={{
__html: `
window.FabricConfig = window.FabricConfig || {};
window.FabricConfig.serializedStylesheet = ${stylesheet.serialize()};
`,
}}
/>,
],
styles: (
<>
{styles}
<style
dangerouslySetInnerHTML={{
__html: stylesheet.getRules(),
}}
/>
</>
),
};
}
}

View file

@ -1,10 +0,0 @@
import Router from "next/router";
import { useEffect } from "react";
export default function Fallback() {
useEffect(() => {
Router.replace(location.href);
}, []);
return null;
}

View file

@ -1,81 +0,0 @@
import { DefaultButton, PrimaryButton } from "@fluentui/react";
import { AdbDaemonWebUsbDevice } from "@yume-chan/adb-daemon-webusb";
import {
aoaGetProtocol,
aoaSetAudioMode,
aoaStartAccessory,
} from "@yume-chan/aoa";
import { observer } from "mobx-react-lite";
import { useCallback, useState } from "react";
import { GLOBAL_STATE } from "../state";
function AudioPage() {
const [supported, setSupported] = useState<boolean | undefined>(undefined);
const handleQuerySupportClick = useCallback(async () => {
const transport = GLOBAL_STATE.device as AdbDaemonWebUsbDevice;
const device = transport.raw;
const version = await aoaGetProtocol(device);
setSupported(version >= 2);
}, []);
const handleEnableClick = useCallback(async () => {
const transport = GLOBAL_STATE.device as AdbDaemonWebUsbDevice;
const device = transport.raw;
const version = await aoaGetProtocol(device);
if (version < 2) {
return;
}
await aoaSetAudioMode(device, 1);
await aoaStartAccessory(device);
}, []);
const handleDisableClick = useCallback(async () => {
const transport = GLOBAL_STATE.device as AdbDaemonWebUsbDevice;
const device = transport.raw;
const version = await aoaGetProtocol(device);
if (version < 2) {
return;
}
await aoaSetAudioMode(device, 0);
await aoaStartAccessory(device);
}, []);
if (
!GLOBAL_STATE.device ||
!(GLOBAL_STATE.device instanceof AdbDaemonWebUsbDevice)
) {
return (
<div>Audio forward can only be used with WebUSB connection.</div>
);
}
return (
<div>
<div>
Supported:{" "}
{supported === undefined ? "Unknown" : supported ? "Yes" : "No"}
</div>
<div>
<PrimaryButton
disabled={!GLOBAL_STATE.device}
onClick={handleQuerySupportClick}
>
Query Support
</PrimaryButton>
<DefaultButton
disabled={!supported}
onClick={handleEnableClick}
>
Enable
</DefaultButton>
<DefaultButton
disabled={!supported}
onClick={handleDisableClick}
>
Disable
</DefaultButton>
</div>
</div>
);
}
export default observer(AudioPage);

View file

@ -1,165 +0,0 @@
// cspell: ignore bugreport
// cspell: ignore bugreportz
import {
MessageBar,
MessageBarType,
PrimaryButton,
Stack,
StackItem,
} from "@fluentui/react";
import { BugReport } from "@yume-chan/android-bin";
import {
action,
autorun,
makeAutoObservable,
observable,
runInAction,
} from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { GLOBAL_STATE } from "../state";
import { RouteStackProps, saveFile } from "../utils";
class BugReportState {
bugReport: BugReport | undefined = undefined;
bugReportZInProgress = false;
bugReportZProgress: string | undefined = undefined;
bugReportZTotalSize: string | undefined = undefined;
constructor() {
makeAutoObservable(this, {
generateBugReport: action.bound,
generateBugReportZStream: action.bound,
generateBugReportZ: action.bound,
});
autorun(() => {
if (GLOBAL_STATE.adb) {
(async () => {
const bugreport = await BugReport.queryCapabilities(
GLOBAL_STATE.adb!,
);
runInAction(() => {
this.bugReport = bugreport;
});
})();
} else {
runInAction(() => {
this.bugReport = undefined;
});
}
});
}
async generateBugReport() {
await this.bugReport!.bugReport().pipeTo(saveFile("bugreport.txt"));
}
async generateBugReportZStream() {
await this.bugReport!.bugReportZStream().pipeTo(
saveFile("bugreport.zip"),
);
}
async generateBugReportZ() {
runInAction(() => {
this.bugReportZInProgress = true;
});
const filename = await this.bugReport!.bugReportZ({
onProgress: this.bugReport!.supportsBugReportZProgress
? action((progress, total) => {
this.bugReportZProgress = progress;
this.bugReportZTotalSize = total;
})
: undefined,
});
const sync = await GLOBAL_STATE.adb!.sync();
await sync.read(filename).pipeTo(saveFile("bugreport.zip"));
sync.dispose();
runInAction(() => {
this.bugReportZInProgress = false;
this.bugReportZProgress = undefined;
this.bugReportZTotalSize = undefined;
});
}
}
const state = new BugReportState();
const BugReportPage: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>BugReport - Tango</title>
</Head>
<MessageBar messageBarType={MessageBarType.info}>
This is the `bugreport`/`bugreportz` tool in Android
</MessageBar>
<StackItem>
<PrimaryButton
disabled={!state.bugReport}
text="Generate BugReport"
onClick={state.generateBugReport}
/>
</StackItem>
<StackItem>
<PrimaryButton
disabled={!state.bugReport?.supportsBugReportZStream}
text="Generate Zipped BugReport (Streaming)"
onClick={state.generateBugReportZStream}
/>
</StackItem>
<StackItem>
<Stack
horizontal
verticalAlign="center"
tokens={{ childrenGap: 8 }}
>
<StackItem>
<PrimaryButton
disabled={
!state.bugReport?.supportsBugReportZ ||
state.bugReportZInProgress
}
text="Generate Zipped BugReport"
onClick={state.generateBugReportZ}
/>
</StackItem>
{state.bugReportZInProgress && (
<StackItem>
{state.bugReportZTotalSize ? (
<span>
Progress: {state.bugReportZProgress} /{" "}
{state.bugReportZTotalSize}
</span>
) : (
<span>
Generating... Please wait
{!state.bugReport!
.supportsBugReportZProgress &&
" (this device does not support progress)"}
</span>
)}
</StackItem>
)}
</Stack>
</StackItem>
</Stack>
);
};
export default observer(BugReportPage);

View file

@ -1,124 +0,0 @@
import { Stack } from "@fluentui/react";
import { makeStyles } from "@griffel/react";
import { useEffect } from "react";
const useClasses = makeStyles({
body: {
"@media (prefers-color-scheme: dark)": {
backgroundColor: "rgb(41, 42, 45)",
color: "#ddd",
},
},
});
function ChromeDevToolsFrame() {
const classes = useClasses();
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;
}
};
globalThis.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);
}, []);
// DevTools will set `document.title` to debugged page's title.
return (
<Stack
className={classes.body}
verticalFill
verticalAlign="center"
horizontalAlign="center"
>
<div>Loading DevTools...</div>
<div>(requires network connection)</div>
</Stack>
);
}
ChromeDevToolsFrame.noLayout = true;
export default ChromeDevToolsFrame;

View file

@ -1,458 +0,0 @@
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,
} 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._reader.closed.then(() => this.emit("end"));
}
async _read(size: number): Promise<void> {
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<void> {
const consumable = new Consumable(chunk);
try {
await this._writer.write(consumable);
callback();
} catch (e) {
callback(e as Error);
}
}
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) {
try {
const socket = await GLOBAL_STATE.adb!.createSocket(
"localabstract:" + options.hostname
);
callback(null, new AdbUndiciSocket(socket) as unknown as Socket);
} catch (e) {
callback(e as Error, null);
}
},
});
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`, {
dispatcher: agent,
});
const body = await response.body.json();
return body as Page[];
}
async function getVersion(socket: string) {
const response = await request(`http://${socket}/json/version`, {
dispatcher: agent,
});
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}`, {
dispatcher: agent,
});
}
async function closePage(socket: string, page: Page) {
await request(`http://${socket}/json/close/${page.id}`, {
dispatcher: agent,
});
}
const {
publicRuntimeConfig: { basePath },
} = getConfig();
// Use a fixed version from Chrome's distribution, updated regularly.
// Opera: doesn't host its own frontend
// Edge: only have versions for Canary version, have license issues
// Brave: `frontendUrl` points to Google's but version number is invalid
const FRONTEND_SCRIPT =
"https://chrome-devtools-frontend.appspot.com/serve_internal_file/@3c3641f7c28cf564edd441cc4ca2838b32c4e52a/front_end/entrypoints/inspector/inspector.js";
function getPopupParams(page: Page) {
const frontendUrl = page.devtoolsFrontendUrl;
const [, params] = frontendUrl.split("?");
return {
script: FRONTEND_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,
}
);
const SOCKET_NAMES = [
"@(.*)_devtools_remote(_\\d+)?",
"@com\\.opera\\.browser(\\.beta)?\\.devtools",
];
const GET_SOCKET_COMMAND = [
"cat /proc/net/unix",
`grep -E "${SOCKET_NAMES.join("|")}"`,
"awk '{print substr($8, 2)}'",
];
async function getBrowsers() {
const device = GLOBAL_STATE.adb!;
const sockets = await device.subprocess.spawnAndWaitLegacy(
GET_SOCKET_COMMAND.join(" | ")
);
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.adb, 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();
}
);
const PACKAGE_NAMES: Record<string, string | undefined> = {
"com.android.chrome": "Google Chrome",
"com.chrome.beta": "Google Chrome Beta",
"com.chrome.dev": "Google Chrome Dev",
"com.chrome.canary": "Google Chrome Canary",
"com.microsoft.emmx": "Microsoft Edge",
"com.microsoft.emmx.beta": "Microsoft Edge Beta",
"com.microsoft.emmx.dev": "Microsoft Edge Dev",
"com.microsoft.emmx.canary": "Microsoft Edge Canary",
"com.opera.browser": "Opera",
"com.opera.browser.beta": "Opera Beta",
"com.vivaldi.browser": "Vivaldi",
};
function getBrowserName(version: Version) {
const [, versionNumber] = version.Browser.split("/");
const name =
PACKAGE_NAMES[version["Android-Package"]] || version["Android-Package"];
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, {
dispatcher: agent,
});
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();
});
globalThis.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.length === 0 ? (
<>
<h2>Supported browsers:</h2>
<ul>
<li>Google Chrome (stable/beta/dev/canary)</li>
<li>Microsoft Edge (stable/beta/dev/canary)</li>
<li>Opera (stable/beta)</li>
<li>Vivaldi</li>
<li>Any WebView with remote debugging on</li>
</ul>
</>
) : (
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,106 +0,0 @@
import {
Icon,
MessageBar,
Separator,
Stack,
TooltipHost,
} from "@fluentui/react";
import { AdbFeature } from "@yume-chan/adb";
import { observer } from "mobx-react-lite";
import type { NextPage } from "next";
import Head from "next/head";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps } from "../utils";
const KNOWN_FEATURES: Record<string, string> = {
[AdbFeature.ShellV2]: `"shell" command now supports separating child process's stdout and stderr, and returning exit code`,
// 'cmd': '',
[AdbFeature.StatV2]:
'"sync" command now supports "STA2" (returns more information of a file than old "STAT") and "LST2" (returns information of a directory) sub command',
[AdbFeature.ListV2]:
'"sync" command now supports "LST2" sub command which returns more information when listing a directory than old "LIST"',
[AdbFeature.FixedPushMkdir]:
"Android 9 (P) introduced a bug that pushing files to a non-existing directory would fail. This feature indicates it's fixed (Android 10)",
// 'apex': '',
// 'abb': '',
// 'fixed_push_symlink_timestamp': '',
[AdbFeature.AbbExec]:
'Supports "abb_exec" variant that can be used to install App faster',
// 'remount_shell': '',
// 'track_app': '',
// 'sendrecv_v2': '',
sendrecv_v2_brotli:
'Supports "brotli" compression algorithm when pushing/pulling files',
sendrecv_v2_lz4:
'Supports "lz4" compression algorithm when pushing/pulling files',
sendrecv_v2_zstd:
'Supports "zstd" compression algorithm when pushing/pulling files',
// 'sendrecv_v2_dry_run_send': '',
};
const DeviceInfo: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Device Info - Tango</title>
</Head>
<MessageBar>
<code>ro.product.name</code>
<span> field in Android Build Props</span>
</MessageBar>
<span>Product Name: {GLOBAL_STATE.adb?.banner.product}</span>
<Separator />
<MessageBar>
<code>ro.product.model</code>
<span> field in Android Build Props</span>
</MessageBar>
<span>Model Name: {GLOBAL_STATE.adb?.banner.model}</span>
<Separator />
<MessageBar>
<code>ro.product.device</code>
<span> field in Android Build Props</span>
</MessageBar>
<span>Device Name: {GLOBAL_STATE.adb?.banner.device}</span>
<Separator />
<MessageBar>
<span>
Feature list decides how each individual commands should
behavior.
</span>
<br />
<span>
For example, it may indicate the availability of a new
command,{" "}
</span>
<span>{`or a workaround for an old bug is not required because it's already been fixed.`}</span>
<br />
</MessageBar>
<span>
<span>Features: </span>
{GLOBAL_STATE.adb?.banner.features.map((feature, index) => (
<span key={feature}>
{index !== 0 && <span>, </span>}
<span>{feature}</span>
{KNOWN_FEATURES[feature] && (
<TooltipHost
content={<span>{KNOWN_FEATURES[feature]}</span>}
>
<Icon
style={{ marginLeft: 4 }}
iconName={Icons.Info}
/>
</TooltipHost>
)}
</span>
))}
</span>
</Stack>
);
};
export default observer(DeviceInfo);

View file

@ -1,954 +0,0 @@
import {
Breadcrumb,
ContextualMenu,
ContextualMenuItem,
DetailsListLayoutMode,
Dialog,
DirectionalHint,
IBreadcrumbItem,
IColumn,
IContextualMenuItem,
IDetailsHeaderProps,
IRenderFunction,
Icon,
Layer,
MarqueeSelection,
Overlay,
ProgressIndicator,
Selection,
ShimmeredDetailsList,
Stack,
StackItem,
concatStyleSets,
mergeStyleSets,
} from "@fluentui/react";
import {
FileIconType,
getFileTypeIconProps,
initializeFileTypeIcons,
} from "@fluentui/react-file-type-icons";
import { useConst } from "@fluentui/react-hooks";
import { getIcon } from "@fluentui/style-utilities";
import {
AdbFeature,
AdbSync,
LinuxFileType,
type AdbSyncEntry,
} from "@yume-chan/adb";
import { WrapConsumableStream, WritableStream } from "@yume-chan/stream-extra";
import { EMPTY_UINT8_ARRAY } from "@yume-chan/struct";
import { Zip, ZipPassThrough } from "fflate";
import {
action,
autorun,
makeAutoObservable,
observable,
runInAction,
} from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import Router, { useRouter } from "next/router";
import path from "path";
import { useCallback, useEffect, useState } from "react";
import { CommandBar, NoSsr } from "../components";
import { GLOBAL_STATE } from "../state";
import {
Icons,
ProgressStream,
RouteStackProps,
asyncEffect,
createFileStream,
formatSize,
formatSpeed,
pickFile,
saveFile,
} from "../utils";
initializeFileTypeIcons();
interface ListItem extends AdbSyncEntry {
key: string;
}
function toListItem(item: AdbSyncEntry): ListItem {
(item as ListItem).key = item.name;
return item as ListItem;
}
const classNames = mergeStyleSets({
name: {
cursor: "pointer",
"&:hover": {
textDecoration: "underline",
},
},
});
const renderDetailsHeader: IRenderFunction<IDetailsHeaderProps> = (
props?,
defaultRender?
) => {
if (!props || !defaultRender) {
return null;
}
return defaultRender({
...props,
styles: concatStyleSets(props.styles, { root: { paddingTop: 0 } }),
});
};
function compareCaseInsensitively(a: string, b: string) {
let result = a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase());
if (result !== 0) {
return result;
} else {
return a.localeCompare(b);
}
}
class FileManagerState {
initial = true;
visible = false;
path = "/";
loading = false;
items: ListItem[] = [];
sortKey: keyof ListItem = "name";
sortDescending = false;
uploading = false;
uploadPath: string | undefined = undefined;
uploadedSize = 0;
uploadTotalSize = 0;
debouncedUploadedSize = 0;
uploadSpeed = 0;
selectedItems: ListItem[] = [];
contextMenuTarget: MouseEvent | undefined = undefined;
get breadcrumbItems(): IBreadcrumbItem[] {
let part = "";
const list: IBreadcrumbItem[] = this.path
.split("/")
.filter(Boolean)
.map((segment) => {
part += "/" + segment;
return {
key: part,
text: segment,
onClick: (e, item) => {
if (!item) {
return;
}
this.pushPathQuery(item.key);
},
};
});
list.unshift({
key: "/",
text: "Device",
onClick: () => this.pushPathQuery("/"),
});
list[list.length - 1].isCurrentItem = true;
delete list[list.length - 1].onClick;
return list;
}
get menuItems() {
let result: IContextualMenuItem[] = [];
switch (this.selectedItems.length) {
case 0:
result.push({
key: "upload",
text: "Upload",
iconProps: {
iconName: Icons.CloudArrowUp,
style: { height: 20, fontSize: 20, lineHeight: 1.5 },
},
disabled: !GLOBAL_STATE.adb,
onClick: () => {
(async () => {
const files = await pickFile({ multiple: true });
for (let i = 0; i < files.length; i++) {
const file = files.item(i)!;
await this.upload(file);
}
})();
return false;
},
});
break;
default:
result.push(
{
key: "download",
text: "Download",
iconProps: {
iconName: Icons.CloudArrowDown,
style: {
height: 20,
fontSize: 20,
lineHeight: 1.5,
},
},
onClick: () => {
void this.download();
return false;
},
},
{
key: "delete",
text: "Delete",
iconProps: {
iconName: Icons.Delete,
style: {
height: 20,
fontSize: 20,
lineHeight: 1.5,
},
},
onClick: () => {
(async () => {
try {
for (const item of this.selectedItems) {
const output =
await GLOBAL_STATE.adb!.rm(
path.resolve(
this.path,
item.name!
)
);
if (output) {
GLOBAL_STATE.showErrorDialog(
output
);
return;
}
}
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
} finally {
this.loadFiles();
}
})();
return false;
},
}
);
break;
}
return result;
}
get sortedList() {
const list = this.items.slice();
list.sort((a, b) => {
const aIsFile = a.type === LinuxFileType.File ? 1 : 0;
const bIsFile = b.type === LinuxFileType.File ? 1 : 0;
let result: number;
if (aIsFile !== bIsFile) {
result = aIsFile - bIsFile;
} else {
const aSortKey = a[this.sortKey]!;
const bSortKey = b[this.sortKey]!;
if (aSortKey === bSortKey) {
// use name as tie breaker
result = compareCaseInsensitively(a.name!, b.name!);
} else if (typeof aSortKey === "string") {
result = compareCaseInsensitively(
aSortKey,
bSortKey as string
);
} else {
result =
(aSortKey as number) < (bSortKey as number) ? -1 : 1;
}
}
if (this.sortDescending) {
result *= -1;
}
return result;
});
return list;
}
get columns(): IColumn[] {
const ICON_SIZE = 20;
const list: IColumn[] = [
{
key: "type",
name: "File Type",
iconName: Icons.Document20,
isIconOnly: true,
minWidth: ICON_SIZE,
maxWidth: ICON_SIZE,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
let iconName: string;
switch (item.type) {
case LinuxFileType.Link:
// larger sizes of `linkedFolder` icon now have a person symbol on it,
// We want to use it for symbolic links, so use the 16px version
// cspell:disable-next-line
iconName = "linkedfolder16_svg";
break;
case LinuxFileType.Directory:
({ iconName } = getFileTypeIconProps({
type: FileIconType.folder,
}));
break;
case LinuxFileType.File:
({ iconName } = getFileTypeIconProps({
extension: path.extname(item.name!),
}));
break;
default:
({ iconName } = getFileTypeIconProps({
type: FileIconType.genericFile,
}));
break;
}
// `@fluentui/react-file-type-icons` doesn't export icon src.
const iconSrc = (
getIcon(iconName)!.code as unknown as JSX.Element
).props.src;
return (
<Icon
imageProps={{
crossOrigin: "anonymous",
src: iconSrc,
}}
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
);
},
},
{
key: "name",
name: "Name",
minWidth: 0,
isRowHeader: true,
onRender(item: AdbSyncEntry) {
return (
<span className={classNames.name} data-selection-invoke>
{item.name}
</span>
);
},
},
{
key: "permission",
name: "Permission",
minWidth: 0,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return `${((item.mode >> 6) & 0b100).toString(8)}${(
(item.mode >> 3) &
0b100
).toString(8)}${(item.mode & 0b100).toString(8)}`;
},
},
{
key: "size",
name: "Size",
minWidth: 0,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
if (item.type === LinuxFileType.File) {
return formatSize(Number(item.size));
}
return "";
},
},
{
key: "mtime",
name: "Last Modified Time",
minWidth: 150,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return new Date(Number(item.mtime) * 1000).toLocaleString();
},
},
];
if (GLOBAL_STATE.adb?.supportsFeature(AdbFeature.ListV2)) {
list.push(
{
key: "ctime",
name: "Creation Time",
minWidth: 150,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return new Date(
Number(item.ctime!) * 1000
).toLocaleString();
},
},
{
key: "atime",
name: "Last Access Time",
minWidth: 150,
isCollapsible: true,
onRender(item: AdbSyncEntry) {
return new Date(
Number(item.atime!) * 1000
).toLocaleString();
},
}
);
}
for (const item of list) {
item.onColumnClick = (e, column) => {
if (this.sortKey === column.key) {
runInAction(
() => (this.sortDescending = !this.sortDescending)
);
} else {
runInAction(() => {
this.sortKey = column.key as keyof ListItem;
this.sortDescending = false;
});
}
};
if (item.key === this.sortKey) {
item.isSorted = true;
item.isSortedDescending = this.sortDescending;
}
}
return list;
}
constructor() {
makeAutoObservable(this, {
initial: false,
items: observable.shallow,
pushPathQuery: false,
changeDirectory: action.bound,
loadFiles: false,
});
autorun(() => {
if (GLOBAL_STATE.adb) {
if (this.initial && this.visible) {
this.initial = false;
this.loadFiles();
}
} else {
this.initial = true;
}
});
}
private getFileStream(sync: AdbSync, basePath: string, name: string) {
return sync.read(path.resolve(basePath, name));
}
private async addDirectory(
sync: AdbSync,
zip: Zip,
basePath: string,
relativePath: string
) {
if (relativePath !== ".") {
// Add empty directory
const file = new ZipPassThrough(relativePath + "/");
zip.add(file);
file.push(EMPTY_UINT8_ARRAY, true);
}
for (const entry of await sync.readdir(
path.resolve(basePath, relativePath)
)) {
if (entry.name === "." || entry.name === "..") {
continue;
}
switch (entry.type) {
case LinuxFileType.Directory:
await this.addDirectory(
sync,
zip,
basePath,
path.resolve(relativePath, entry.name)
);
break;
case LinuxFileType.File:
await this.addFile(
sync,
zip,
basePath,
path.resolve(relativePath, entry.name)
);
break;
}
}
}
private async addFile(
sync: AdbSync,
zip: Zip,
basePath: string,
name: string
) {
const file = new ZipPassThrough(name);
zip.add(file);
await this.getFileStream(sync, basePath, name).pipeTo(
new WritableStream({
write(chunk) {
file.push(chunk);
},
close() {
file.push(EMPTY_UINT8_ARRAY, true);
},
})
);
}
private async download() {
const sync = await GLOBAL_STATE.adb!.sync();
try {
if (this.selectedItems.length === 1) {
const item = this.selectedItems[0];
switch (item.type) {
case LinuxFileType.Directory: {
const stream = saveFile(
`${this.selectedItems[0].name}.zip`
);
const writer = stream.getWriter();
const zip = new Zip((err, data, final) => {
writer.write(data);
if (final) {
writer.close();
}
});
await this.addDirectory(
sync,
zip,
path.resolve(this.path, item.name),
"."
);
zip.end();
break;
}
case LinuxFileType.File:
await this.getFileStream(
sync,
this.path,
item.name
).pipeTo(saveFile(item.name, Number(item.size)));
break;
}
return;
}
const stream = saveFile(`${path.basename(this.path)}.zip`);
const writer = stream.getWriter();
const zip = new Zip((err, data, final) => {
writer.write(data);
if (final) {
writer.close();
}
});
for (const item of this.selectedItems) {
switch (item.type) {
case LinuxFileType.Directory:
await this.addDirectory(
sync,
zip,
this.path,
item.name
);
break;
case LinuxFileType.File:
await this.addFile(sync, zip, this.path, item.name);
break;
}
}
zip.end();
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
} finally {
sync.dispose();
}
}
pushPathQuery = (path: string) => {
Router.push({ query: { ...Router.query, path } });
};
changeDirectory(path: string) {
if (this.path === path) {
return;
}
this.path = path;
if (!GLOBAL_STATE.adb) {
return;
}
this.loadFiles();
}
loadFiles = asyncEffect(async (signal) => {
const currentPath = this.path;
runInAction(() => (this.items = []));
if (!GLOBAL_STATE.adb) {
return;
}
runInAction(() => (this.loading = true));
const sync = await GLOBAL_STATE.adb.sync();
const items: ListItem[] = [];
const linkItems: AdbSyncEntry[] = [];
const intervalId = setInterval(() => {
if (signal.aborted) {
return;
}
runInAction(() => (this.items = items.slice()));
}, 1000);
try {
for await (const entry of sync.opendir(currentPath)) {
if (signal.aborted) {
return;
}
if (entry.name === "." || entry.name === "..") {
continue;
}
if (entry.type === LinuxFileType.Link) {
linkItems.push(entry);
} else {
items.push(toListItem(entry));
}
}
for (const entry of linkItems) {
if (signal.aborted) {
return;
}
if (
!(await sync.isDirectory(
path.resolve(currentPath, entry.name!)
))
) {
entry.mode = (LinuxFileType.File << 12) | entry.permission;
entry.size = 0n;
}
items.push(toListItem(entry));
}
if (signal.aborted) {
return;
}
runInAction(() => (this.items = items));
} finally {
if (!signal.aborted) {
runInAction(() => (this.loading = false));
}
clearInterval(intervalId);
sync.dispose();
}
});
upload = async (file: File) => {
const sync = await GLOBAL_STATE.adb!.sync();
try {
const itemPath = path.resolve(this.path!, file.name);
runInAction(() => {
this.uploading = true;
this.uploadPath = file.name;
this.uploadedSize = 0;
this.uploadTotalSize = file.size;
this.debouncedUploadedSize = 0;
this.uploadSpeed = 0;
});
const intervalId = setInterval(
action(() => {
this.uploadSpeed =
this.uploadedSize - this.debouncedUploadedSize;
this.debouncedUploadedSize = this.uploadedSize;
}),
1000
);
try {
const start = Date.now();
await sync.write({
filename: itemPath,
file: createFileStream(file)
.pipeThrough(new WrapConsumableStream())
.pipeThrough(
new ProgressStream(
action((uploaded) => {
this.uploadedSize = uploaded;
})
)
),
type: LinuxFileType.File,
permission: 0o666,
mtime: file.lastModified / 1000,
});
console.log(
"Upload speed:",
(
((file.size / (Date.now() - start)) * 1000) /
1024 /
1024
).toFixed(2),
"MB/s"
);
runInAction(() => {
this.uploadSpeed =
this.uploadedSize - this.debouncedUploadedSize;
this.debouncedUploadedSize = this.uploadedSize;
});
} finally {
clearInterval(intervalId);
}
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
} finally {
sync.dispose();
this.loadFiles();
runInAction(() => {
this.uploading = false;
});
}
};
}
const state = new FileManagerState();
const UploadDialog = observer(() => {
return (
<Dialog
hidden={!state.uploading}
dialogContentProps={{
title: "Uploading...",
subText: state.uploadPath,
}}
>
<ProgressIndicator
description={formatSpeed(
state.debouncedUploadedSize,
state.uploadTotalSize,
state.uploadSpeed
)}
percentComplete={state.uploadedSize / state.uploadTotalSize}
/>
</Dialog>
);
});
const FileManager: NextPage = (): JSX.Element | null => {
useEffect(() => {
runInAction(() => {
state.visible = true;
});
return () => {
runInAction(() => {
state.visible = false;
});
};
});
const router = useRouter();
useEffect(() => {
let pathQuery = router.query.path;
if (!pathQuery) {
router.replace({ query: { ...router.query, path: state.path } });
return;
}
if (Array.isArray(pathQuery)) {
pathQuery = pathQuery[0];
}
state.changeDirectory(pathQuery);
}, [router]);
const [previewUrl, setPreviewUrl] = useState<string | undefined>();
const previewImage = useCallback(async (path: string) => {
const sync = await GLOBAL_STATE.adb!.sync();
try {
const readable = sync.read(path);
// @ts-ignore ReadableStream definitions are slightly incompatible
const response = new Response(readable);
const blob = await response.blob();
const url = globalThis.URL.createObjectURL(blob);
setPreviewUrl(url);
} finally {
sync.dispose();
}
}, []);
const hidePreview = useCallback(() => {
setPreviewUrl(undefined);
}, []);
const handleItemInvoked = useCallback(
(item: AdbSyncEntry) => {
switch (item.type) {
case LinuxFileType.Link:
case LinuxFileType.Directory:
state.pushPathQuery(path.resolve(state.path!, item.name!));
break;
case LinuxFileType.File:
switch (path.extname(item.name!)) {
case ".jpg":
case ".png":
case ".svg":
case ".gif":
previewImage(path.resolve(state.path!, item.name!));
break;
}
break;
}
},
[previewImage]
);
const selection = useConst(
() =>
new Selection({
onSelectionChanged() {
runInAction(() => {
state.selectedItems =
selection.getSelection() as ListItem[];
});
},
})
);
const showContextMenu = useCallback(
(item?: AdbSyncEntry, index?: number, e?: Event) => {
if (!e) {
return false;
}
if (state.menuItems.length) {
runInAction(() => {
state.contextMenuTarget = e as MouseEvent;
});
}
return false;
},
[]
);
const hideContextMenu = useCallback(() => {
runInAction(() => (state.contextMenuTarget = undefined));
}, []);
return (
<Stack {...RouteStackProps}>
<Head>
<title>File Manager - Tango</title>
</Head>
<CommandBar items={state.menuItems} />
<Breadcrumb items={state.breadcrumbItems} />
<StackItem
grow
styles={{
root: {
margin: "-8px -16px -16px -16px",
padding: "8px 16px 16px 16px",
overflowY: "auto",
},
}}
>
<MarqueeSelection selection={selection}>
<ShimmeredDetailsList
items={state.sortedList}
columns={state.columns}
setKey={state.path}
selection={selection}
layoutMode={DetailsListLayoutMode.justified}
enableShimmer={
state.loading && state.items.length === 0
}
onItemInvoked={handleItemInvoked}
onItemContextMenu={showContextMenu}
onRenderDetailsHeader={renderDetailsHeader}
usePageCache
useReducedRowRenderer
/>
</MarqueeSelection>
{previewUrl && (
<Layer>
<Overlay onClick={hidePreview}>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt=""
style={{
maxWidth: "100%",
maxHeight: "100%",
}}
/>
</div>
</Overlay>
</Layer>
)}
</StackItem>
<NoSsr>
<ContextualMenu
items={state.menuItems}
hidden={!state.contextMenuTarget}
directionalHint={DirectionalHint.bottomLeftEdge}
target={state.contextMenuTarget}
onDismiss={hideContextMenu}
contextualMenuItemAs={(props) => (
<ContextualMenuItem {...props} hasIcons={false} />
)}
/>
</NoSsr>
<UploadDialog />
</Stack>
);
};
export default observer(FileManager);

View file

@ -1,168 +0,0 @@
import { ICommandBarItemProps, Stack } from "@fluentui/react";
import { AdbFrameBuffer, AdbFrameBufferV2 } from "@yume-chan/adb";
import { action, autorun, computed, makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { useCallback, useEffect, useRef } from "react";
import { CommandBar, DemoModePanel, DeviceView } from "../components";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps } from "../utils";
class FrameBufferState {
width = 0;
height = 0;
imageData: ImageData | undefined = undefined;
demoModeVisible = false;
constructor() {
makeAutoObservable(this, {
toggleDemoModeVisible: action.bound,
});
}
setImage(image: AdbFrameBuffer) {
this.width = image.width;
this.height = image.height;
this.imageData = new ImageData(
new Uint8ClampedArray(image.data),
image.width,
image.height
);
}
toggleDemoModeVisible() {
this.demoModeVisible = !this.demoModeVisible;
}
}
const state = new FrameBufferState();
const FrameBuffer: NextPage = (): JSX.Element | null => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const capture = useCallback(async () => {
if (!GLOBAL_STATE.adb) {
return;
}
try {
const start = Date.now();
const framebuffer = await GLOBAL_STATE.adb.framebuffer();
console.log(
"Framebuffer speed",
(
(((AdbFrameBufferV2.size + framebuffer.size) /
(Date.now() - start)) *
1000) /
1024 /
1024
).toFixed(2),
"MB/s"
);
state.setImage(framebuffer);
} catch (e: any) {
GLOBAL_STATE.showErrorDialog(e);
}
}, []);
useEffect(() => {
return autorun(() => {
const canvas = canvasRef.current;
if (canvas && state.imageData) {
canvas.width = state.width;
canvas.height = state.height;
const context = canvas.getContext("2d")!;
context.putImageData(state.imageData, 0, 0);
}
});
}, []);
const commandBarItems = computed(() => [
{
key: "start",
disabled: !GLOBAL_STATE.adb,
iconProps: {
iconName: Icons.Camera,
style: { height: 20, fontSize: 20, lineHeight: 1.5 },
},
text: "Capture",
onClick: capture,
},
{
key: "Save",
disabled: !state.imageData,
iconProps: {
iconName: Icons.Save,
style: { height: 20, fontSize: 20, lineHeight: 1.5 },
},
text: "Save",
onClick: () => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const url = canvas.toDataURL();
const a = document.createElement("a");
a.href = url;
a.download = `Screenshot of ${GLOBAL_STATE.device!.name}.png`;
a.click();
},
},
]);
const commandBarFarItems = computed((): ICommandBarItemProps[] => [
{
key: "DemoMode",
iconProps: {
iconName: Icons.Wand,
style: { height: 20, fontSize: 20, lineHeight: 1.5 },
},
checked: state.demoModeVisible,
text: "Demo Mode",
onClick: state.toggleDemoModeVisible,
},
{
key: "info",
iconProps: {
iconName: Icons.Info,
style: { height: 20, fontSize: 20, lineHeight: 1.5 },
},
iconOnly: true,
tooltipHostProps: {
content:
"Use ADB FrameBuffer command to capture a full-size, high-resolution screenshot.",
calloutProps: {
calloutMaxWidth: 250,
},
},
},
]);
return (
<Stack {...RouteStackProps}>
<Head>
<title>Screen Capture - Tango</title>
</Head>
<CommandBar
items={commandBarItems.get()}
farItems={commandBarFarItems.get()}
/>
<Stack horizontal grow styles={{ root: { height: 0 } }}>
<DeviceView width={state.width} height={state.height}>
<canvas ref={canvasRef} style={{ display: "block" }} />
</DeviceView>
<DemoModePanel
style={{
display: state.demoModeVisible ? "block" : "none",
}}
/>
</Stack>
</Stack>
);
};
export default observer(FrameBuffer);

View file

@ -1,58 +0,0 @@
import { Stack } from "@fluentui/react";
import Head from "next/head";
import { ExternalLink } from "../components";
import { RouteStackProps } from "../utils";
{/* cspell: ignore cybojenix */}
This is a demo for my <ExternalLink href="https://github.com/yume-chan/ya-webadb/">ya-webadb</ExternalLink> project, which can use ADB protocol to control Android phones, directly from Web browsers (or Node.js).
It started because I want to try the <ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/API/USB">WebUSB</ExternalLink> API, and because I have an Android phone. It's not production-ready, and I don't recommend normal users to try it. If you have any questions or suggestions, please file an issue at <ExternalLink href="https://github.com/yume-chan/ya-webadb/issues">here</ExternalLink>.
It was called "ya-webadb" (Yet Another WebADB), because there have already been several similar projects, for example:
- <ExternalLink href="https://github.com/webadb/webadb.js">
webadb/webadb.js
</ExternalLink>
- <ExternalLink href="https://github.com/cybojenix/WebADB">
cybojenix/WebADB
</ExternalLink>
However, they are all pretty simple and not maintained, so I decided to make my own.
## Security concerns
Accessing USB devices (especially your phone) directly from a web page can be **very dangerous**. Firefox developers even refused to implement the WebUSB standard because they <ExternalLink href="https://mozilla.github.io/standards-positions/#webusb">considered it to be **harmful**</ExternalLink>.
However, I'm pretty confident about this demo, and here is a few reasons:
1. Unlike native apps, web apps can't access your devices silently. In addition to the connection verification popup that comes with ADB, browsers ask users for permission and web apps can only access the device you choose.
2. All source code of this project is open sourced on <ExternalLink href="https://github.com/yume-chan/ya-webadb/">GitHub</ExternalLink>. You can review it yourself (or find someone you trust and knows coding to review it).
3. This site is built and deployed by <ExternalLink href="https://github.com/yume-chan/ya-webadb/blob/main/.github/workflows/deploy.yml" spaceAfter>GitHub Actions</ExternalLink> to ensure that what you see is exactly the same as the source code.
## Compatibility
Currently, only Chromium-based browsers (Chrome, Microsoft Edge, Opera) support the WebUSB API. As mentioned before, it's unlikely for Firefox to implement it.
## FAQ
### Got "Unable to claim interface" error
One USB device can only be accessed by one application at a time. Please make sure:
1. Official ADB server is not running (stop it by `adb kill-server`).
2. No other Android management tools are running.
3. No other WebADB tabs have already connected to your device.
### Can I connect my device wirelessly (ADB over WiFi)?
Extra software is required to bridge the connection. See <ExternalLink href="https://github.com/yume-chan/ya-webadb/discussions/245#discussioncomment-384030">this discussion</ExternalLink>.
export default ({ children }) => (
<div style={{ height: "100%", padding: "0 16px", overflow: "auto" }}>
<Head>
<title>Tango</title>
</Head>
{children}
</div>
);

View file

@ -1,188 +0,0 @@
import {
Checkbox,
PrimaryButton,
ProgressIndicator,
Stack,
} from "@fluentui/react";
import {
PackageManager,
PackageManagerInstallOptions,
} from "@yume-chan/android-bin";
import { WrapConsumableStream, WritableStream } from "@yume-chan/stream-extra";
import { action, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { GLOBAL_STATE } from "../state";
import {
ProgressStream,
RouteStackProps,
createFileStream,
pickFile,
} from "../utils";
enum Stage {
Uploading,
Installing,
Completed,
}
interface Progress {
filename: string;
stage: Stage;
uploadedSize: number;
totalSize: number;
value: number | undefined;
}
class InstallPageState {
installing = false;
progress: Progress | undefined = undefined;
log: string = "";
options: Partial<PackageManagerInstallOptions> = {
bypassLowTargetSdkBlock: false,
};
constructor() {
makeAutoObservable(this, {
progress: observable.ref,
install: false,
options: observable.deep,
});
}
install = async () => {
const file = await pickFile({ accept: ".apk" });
if (!file) {
return;
}
runInAction(() => {
this.installing = true;
this.progress = {
filename: file.name,
stage: Stage.Uploading,
uploadedSize: 0,
totalSize: file.size,
value: 0,
};
this.log = "";
});
const pm = new PackageManager(GLOBAL_STATE.adb!);
const start = Date.now();
const log = await pm.installStream(
file.size,
createFileStream(file)
.pipeThrough(new WrapConsumableStream())
.pipeThrough(
new ProgressStream(
action((uploaded) => {
if (uploaded !== file.size) {
this.progress = {
filename: file.name,
stage: Stage.Uploading,
uploadedSize: uploaded,
totalSize: file.size,
value: (uploaded / file.size) * 0.8,
};
} else {
this.progress = {
filename: file.name,
stage: Stage.Installing,
uploadedSize: uploaded,
totalSize: file.size,
value: 0.8,
};
}
})
)
)
);
const elapsed = Date.now() - start;
await log.pipeTo(
new WritableStream({
write: action((chunk) => {
this.log += chunk;
}),
})
);
const transferRate = (
file.size /
(elapsed / 1000) /
1024 /
1024
).toFixed(2);
this.log += `Install finished in ${elapsed}ms at ${transferRate}MB/s`;
runInAction(() => {
this.progress = {
filename: file.name,
stage: Stage.Completed,
uploadedSize: file.size,
totalSize: file.size,
value: 1,
};
this.installing = false;
});
};
}
const state = new InstallPageState();
const Install: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Install APK - Tango</title>
</Head>
<Stack horizontal>
<Checkbox
label="--bypass-low-target-sdk-block (Android 14)"
checked={state.options.bypassLowTargetSdkBlock}
onChange={(_, checked) => {
if (checked === undefined) {
return;
}
runInAction(() => {
state.options.bypassLowTargetSdkBlock = checked;
});
}}
/>
</Stack>
<Stack horizontal>
<PrimaryButton
disabled={!GLOBAL_STATE.adb || state.installing}
text="Browse APK"
onClick={state.install}
/>
</Stack>
{state.progress && (
<ProgressIndicator
styles={{ root: { width: 300 } }}
label={state.progress.filename}
percentComplete={state.progress.value}
description={Stage[state.progress.stage]}
/>
)}
{state.log && <pre>{state.log}</pre>}
</Stack>
);
};
export default observer(Install);

View file

@ -1,833 +0,0 @@
import {
ContextualMenuItemType,
ICommandBarItemProps,
Stack,
StackItem,
isMac,
} from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import {
AndroidLogEntry,
AndroidLogPriority,
Logcat,
LogcatFormat,
} from "@yume-chan/android-bin";
import {
AbortController,
ReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import { encodeUtf8 } from "@yume-chan/struct";
import {
action,
autorun,
makeAutoObservable,
observable,
runInAction,
} from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { KeyboardEvent, PointerEvent, useCallback } from "react";
import {
CommandBar,
Grid,
GridColumn,
GridHeaderProps,
GridRowProps,
ObservableListSelection,
isModKey,
} from "../components";
import { CommandBarSpacerItem } from "../components/command-bar-spacer-item";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps, saveFile, useStableCallback } from "../utils";
const LINE_HEIGHT = 32;
const useClasses = makeStyles({
grid: {
height: "100%",
marginLeft: "-16px",
marginRight: "-16px",
},
header: {
textAlign: "center",
lineHeight: `${LINE_HEIGHT}px`,
},
row: {
"&:hover": {
backgroundColor: "#f3f2f1",
},
},
selected: {
backgroundColor: "#edebe9",
},
code: {
fontFamily: "monospace",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
lineHeight: LINE_HEIGHT + "px",
cursor: "default",
...shorthands.overflow("hidden"),
},
// Android Studio Classic Light theme
rowVerbose: {
color: "#000000",
},
rowDebug: {
color: "#000000",
},
rowInfo: {
color: "#000000",
},
rowWarn: {
color: "#645607",
},
rowError: {
color: "#CD0000",
},
rowFatal: {
color: "#CD0000",
},
});
export interface Column extends GridColumn {
title: string;
}
export interface LogRow extends AndroidLogEntry {
timeString?: string;
}
const state = makeAutoObservable(
{
logcat: undefined as Logcat | undefined,
running: false,
buffer: [] as LogRow[],
flushRequested: false,
list: [] as LogRow[],
selection: new ObservableListSelection(),
count: 0,
stream: undefined as ReadableStream<AndroidLogEntry> | undefined,
stopSignal: undefined as AbortController | undefined,
animationFrameId: undefined as number | undefined,
format: LogcatFormat.ThreadTime,
formatModifierUid: false,
formatModifierTimezone: false,
formatTime: "default" as "year" | "default" | "epoch" | "monotonic",
formatNanosecond: "millisecond" as
| "millisecond"
| "microsecond"
| "nanosecond",
formatEntry(entry: LogRow) {
return entry.toString(this.format, {
uid: this.formatModifierUid,
year: this.formatTime === "year",
epoch: this.formatTime === "epoch",
monotonic: this.formatTime === "monotonic",
microseconds: this.formatNanosecond === "microsecond",
nanoseconds: this.formatNanosecond === "nanosecond",
timezone: this.formatModifierTimezone,
});
},
start() {
if (this.running) {
return;
}
// Logcat has its internal buffer,
// it will output all logs in the buffer when started.
// so clear the list before starting.
this.list = [];
this.running = true;
this.stream = this.logcat!.binary();
this.stopSignal = new AbortController();
this.stream
.pipeTo(
new WritableStream({
write: (chunk) => {
this.buffer.push(chunk);
if (!this.flushRequested) {
this.flushRequested = true;
requestAnimationFrame(this.flush);
}
},
}),
{ signal: this.stopSignal.signal }
)
.catch((e) => {
if (this.stopSignal?.signal.aborted) {
return;
}
throw e;
});
},
flush() {
this.list.push(...this.buffer);
this.buffer = [];
this.flushRequested = false;
},
stop() {
this.running = false;
this.stopSignal!.abort();
},
clear() {
this.list = [];
this.selection.clear();
},
get empty() {
return this.list.length === 0;
},
get commandBar(): ICommandBarItemProps[] {
return [
this.running
? {
key: "stop",
text: "Stop",
iconProps: { iconName: Icons.Stop },
onClick: () => this.stop(),
}
: {
key: "start",
text: "Start",
disabled: this.logcat === undefined,
iconProps: { iconName: Icons.Play },
onClick: () => this.start(),
},
{
key: "clear",
text: "Clear",
disabled: this.empty,
iconProps: { iconName: Icons.Delete },
onClick: () => this.clear(),
},
{
key: "select-all",
disabled: this.empty,
iconProps: { iconName: Icons.Wand },
text: "Select All",
onClick: action(() => {
this.selection.clear();
this.selection.select(
this.list.length - 1,
false,
true
);
}),
},
{
key: "copy",
text: "Copy Selected",
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Copy },
onClick: () => {
let text = "";
for (const index of this.selection) {
text += this.formatEntry(this.list[index]) + "\n";
}
// Chrome on Windows can't copy null characters
text = text.replace(/\u0000/g, "");
navigator.clipboard.writeText(text);
},
},
{
key: "save",
text: "Save Selected",
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Save },
onClick: () => {
const stream = saveFile(`logcat.txt`);
const writer = stream.getWriter();
for (const index of this.selection) {
writer.write(
encodeUtf8(
this.formatEntry(this.list[index]) + "\n"
)
);
}
writer.close();
},
},
{
// HACK: make CommandBar overflow on far items
// https://github.com/microsoft/fluentui/issues/11842
key: "spacer",
onRender: () => <CommandBarSpacerItem />,
},
{
// HACK: add a separator in CommandBar overflow menu
// https://github.com/microsoft/fluentui/issues/10035
key: "separator",
disabled: true,
itemType: ContextualMenuItemType.Divider,
},
{
key: "format",
iconProps: { iconName: Icons.TextGrammarSettings },
text: "Format",
subMenuProps: {
items: [
{
key: "format",
text: "Format",
itemType: ContextualMenuItemType.Header,
},
{
key: "brief",
text: "Brief",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Brief,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Brief;
}),
},
{
key: "process",
text: "Process",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Process,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Process;
}),
},
{
key: "tag",
text: "Tag",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Tag,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Tag;
}),
},
{
key: "thread",
text: "Thread",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Thread,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Thread;
}),
},
{
key: "raw",
text: "Raw",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Raw,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Raw;
}),
},
{
key: "time",
text: "Time",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Time,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Time;
}),
},
{
key: "thread-time",
text: "ThreadTime",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked:
this.format === LogcatFormat.ThreadTime,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.ThreadTime;
}),
},
{
key: "long",
text: "Long",
canCheck: true,
itemProps: {
radioGroup: "format",
},
checked: this.format === LogcatFormat.Long,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.format = LogcatFormat.Long;
}),
},
{
key: "modifiers",
text: "Modifiers",
itemType: ContextualMenuItemType.Header,
},
{
key: "uid",
text: "UID",
canCheck: true,
checked: this.formatModifierUid,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatModifierUid =
!this.formatModifierUid;
}),
},
{
key: "timezone",
text: "Timezone",
canCheck: true,
checked: this.formatModifierTimezone,
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatModifierTimezone =
!this.formatModifierTimezone;
}),
},
{
key: "time-header",
text: "Time Format",
itemType: ContextualMenuItemType.Header,
},
{
key: "default",
text: "Default",
canCheck: true,
itemProps: {
radioGroup: "time",
},
checked: this.formatTime === "default",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatTime = "default";
}),
},
{
key: "year",
text: "Year",
canCheck: true,
itemProps: {
radioGroup: "time",
},
checked: this.formatTime === "year",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatTime = "year";
}),
},
{
key: "epoch",
text: "Epoch",
canCheck: true,
itemProps: {
radioGroup: "time",
},
checked: this.formatTime === "epoch",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatTime = "epoch";
}),
},
{
key: "monotonic",
text: "Monotonic",
canCheck: true,
itemProps: {
radioGroup: "time",
},
checked: this.formatTime === "monotonic",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatTime = "monotonic";
}),
},
{
key: "nanosecondFormat",
text: "Nanosecond Format",
itemType: ContextualMenuItemType.Header,
},
{
key: "millisecond",
text: "Millisecond",
canCheck: true,
itemProps: {
radioGroup: "nanosecond",
},
checked:
this.formatNanosecond === "millisecond",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatNanosecond = "millisecond";
}),
},
{
key: "microsecond",
text: "Microsecond",
canCheck: true,
itemProps: {
radioGroup: "nanosecond",
},
checked:
this.formatNanosecond === "microsecond",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatNanosecond = "microsecond";
}),
},
{
key: "nanosecond",
text: "Nanosecond",
canCheck: true,
itemProps: {
radioGroup: "nanosecond",
},
checked: this.formatNanosecond === "nanosecond",
onClick: action((e) => {
e?.preventDefault();
e?.stopPropagation();
this.formatNanosecond = "nanosecond";
}),
},
],
},
},
];
},
get columns(): Column[] {
return [
{
width: 200,
title: "Time",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
if (!item.timeString) {
item.timeString = new Date(
item.seconds * 1000
).toISOString();
}
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{item.timeString}
</div>
);
},
},
{
width: 60,
title: "PID",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{item.pid}
</div>
);
},
},
{
width: 60,
title: "TID",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{item.tid}
</div>
);
},
},
{
width: 80,
title: "Priority",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{AndroidLogPriority[item.priority]}
</div>
);
},
},
{
width: 300,
title: "Tag",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{item.tag}
</div>
);
},
},
{
width: 300,
flexGrow: 1,
title: "Message",
CellComponent: ({
rowIndex,
columnIndex,
className,
...rest
}) => {
const item = this.list[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(
classes.code,
className
)}
{...rest}
>
{item.message}
</div>
);
},
},
];
},
},
{
buffer: false,
list: observable.shallow,
flush: action.bound,
}
);
autorun(() => {
if (GLOBAL_STATE.adb) {
runInAction(() => {
state.logcat = new Logcat(GLOBAL_STATE.adb!);
});
} else {
runInAction(() => {
state.logcat = undefined;
if (state.running) {
state.stop();
}
});
}
});
const Header = observer(function Header({
className,
columnIndex,
...rest
}: GridHeaderProps) {
const classes = useClasses();
return (
<div className={mergeClasses(className, classes.header)} {...rest}>
{state.columns[columnIndex].title}
</div>
);
});
const PRIORITY_COLORS: Record<
AndroidLogPriority,
keyof ReturnType<typeof useClasses>
> = {
[AndroidLogPriority.Default]: "rowVerbose",
[AndroidLogPriority.Unknown]: "rowVerbose",
[AndroidLogPriority.Silent]: "rowVerbose",
[AndroidLogPriority.Verbose]: "rowVerbose",
[AndroidLogPriority.Debug]: "rowDebug",
[AndroidLogPriority.Info]: "rowInfo",
[AndroidLogPriority.Warn]: "rowWarn",
[AndroidLogPriority.Error]: "rowError",
[AndroidLogPriority.Fatal]: "rowFatal",
};
const Row = observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
const classes = useClasses();
const handlePointerDown = useStableCallback(
action((e: PointerEvent<HTMLDivElement>) => {
if (e.shiftKey) {
e.preventDefault();
}
state.selection.select(rowIndex, isModKey(e), e.shiftKey);
})
);
return (
<div
className={mergeClasses(
className,
classes.row,
state.selection.has(rowIndex) && classes.selected,
classes[PRIORITY_COLORS[state.list[rowIndex]!.priority]]
)}
onPointerDown={handlePointerDown}
{...rest}
/>
);
});
const LogcatPage: NextPage = () => {
const classes = useClasses();
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if ((isMac() ? e.metaKey : e.ctrlKey) && e.code === "KeyA") {
e.preventDefault();
e.stopPropagation();
state.selection.clear();
state.selection.select(state.list.length - 1, false, true);
return;
}
if (e.code === "Escape") {
e.preventDefault();
e.stopPropagation();
state.selection.clear();
return;
}
}, []);
return (
<Stack {...RouteStackProps}>
<Head>
<title>Logcat - Tango</title>
</Head>
<CommandBar items={state.commandBar} />
<StackItem grow>
<Grid
className={classes.grid}
rowCount={state.list.length}
rowHeight={LINE_HEIGHT}
columns={state.columns}
HeaderComponent={Header}
RowComponent={Row}
onKeyDown={handleKeyDown}
/>
</StackItem>
</Stack>
);
};
export default observer(LogcatPage);

View file

@ -1,358 +0,0 @@
import { ICommandBarItemProps, Stack, StackItem } from "@fluentui/react";
import { makeStyles, mergeClasses, shorthands } from "@griffel/react";
import { AdbCommand, decodeUtf8 } from "@yume-chan/adb";
import { action, autorun, makeAutoObservable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { PointerEvent } from "react";
import {
CommandBar,
Grid,
GridCellProps,
GridColumn,
GridHeaderProps,
GridRowProps,
HexViewer,
ObservableListSelection,
isModKey,
toText,
} from "../components";
import { GLOBAL_STATE } from "../state";
import {
Icons,
RouteStackProps,
useStableCallback,
withDisplayName,
} from "../utils";
const ADB_COMMAND_NAME = {
[AdbCommand.Auth]: "AUTH",
[AdbCommand.Close]: "CLSE",
[AdbCommand.Connect]: "CNXN",
[AdbCommand.OK]: "OKAY",
[AdbCommand.Open]: "OPEN",
[AdbCommand.Write]: "WRTE",
};
interface Column extends GridColumn {
title: string;
}
const LINE_HEIGHT = 32;
function uint8ArrayToHexString(array: Uint8Array) {
return Array.from(array)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join(" ");
}
const state = new (class {
get empty() {
return !GLOBAL_STATE.logs.length;
}
get commandBarItems(): ICommandBarItemProps[] {
return [
{
key: "clear",
disabled: this.empty,
iconProps: { iconName: Icons.Delete },
text: "Clear",
onClick: action(() => GLOBAL_STATE.clearLog()),
},
{
key: "select-all",
disabled: this.empty,
iconProps: { iconName: Icons.Wand },
text: "Select All",
onClick: action(() => {
this.selection.clear();
this.selection.select(
GLOBAL_STATE.logs.length - 1,
false,
true
);
}),
},
{
key: "copy",
disabled: this.selection.size === 0,
iconProps: { iconName: Icons.Copy },
text: "Copy",
onClick: () => {
let text = "";
for (const index of this.selection) {
const entry = GLOBAL_STATE.logs[index];
// prettier-ignore
text += `${
entry.timestamp!.toISOString()
}\t${
entry.direction === 'in' ? "IN" : "OUT"
}\t${
ADB_COMMAND_NAME[entry.command as keyof typeof ADB_COMMAND_NAME]
}\t${
entry.arg0.toString(16).padStart(8,'0')
}\t${
entry.arg1.toString(16).padStart(8,'0')
}\t${
uint8ArrayToHexString(entry.payload)
}\n`;
}
navigator.clipboard.writeText(text);
},
},
];
}
selection = new ObservableListSelection();
constructor() {
makeAutoObservable(this, {});
autorun(() => {
if (GLOBAL_STATE.logs.length === 0) {
runInAction(() => this.selection.clear());
}
});
}
})();
const useClasses = makeStyles({
grow: {
height: 0,
},
grid: {
height: "100%",
},
header: {
textAlign: "center",
lineHeight: `${LINE_HEIGHT}px`,
},
row: {
"&:hover": {
backgroundColor: "#f3f2f1",
},
},
selected: {
backgroundColor: "#edebe9",
},
code: {
fontFamily: "monospace",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
lineHeight: LINE_HEIGHT + "px",
cursor: "default",
...shorthands.overflow("hidden"),
},
hexViewer: {
...shorthands.padding("12px"),
...shorthands.borderTop("1px", "solid", "rgb(243, 242, 241)"),
},
});
const columns: Column[] = [
{
title: "Direction",
width: 100,
CellComponent: withDisplayName("Direction")(
({ className, rowIndex, ...rest }: GridCellProps) => {
const item = GLOBAL_STATE.logs[rowIndex];
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.direction}
</div>
);
}
),
},
{
title: "Command",
width: 100,
CellComponent: withDisplayName("Command")(
({ className, rowIndex, ...rest }: GridCellProps) => {
const item = GLOBAL_STATE.logs[rowIndex];
if (!item.commandString) {
item.commandString =
ADB_COMMAND_NAME[item.command as AdbCommand] ??
decodeUtf8(new Uint32Array([item.command]));
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.commandString}
</div>
);
}
),
},
{
title: "Arg0",
width: 100,
CellComponent: withDisplayName("Command")(
({ className, rowIndex, ...rest }: GridCellProps) => {
const item = GLOBAL_STATE.logs[rowIndex];
if (!item.arg0String) {
item.arg0String = item.arg0.toString(16).padStart(8, "0");
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg0String}
</div>
);
}
),
},
{
title: "Arg1",
width: 100,
CellComponent: withDisplayName("Command")(
({ className, rowIndex, ...rest }: GridCellProps) => {
const item = GLOBAL_STATE.logs[rowIndex];
if (!item.arg1String) {
item.arg1String = item.arg1.toString(16).padStart(8, "0");
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.arg1String}
</div>
);
}
),
},
{
title: "Payload",
width: 200,
flexGrow: 1,
CellComponent: withDisplayName("Command")(
({ className, rowIndex, ...rest }: GridCellProps) => {
const item = GLOBAL_STATE.logs[rowIndex];
if (!item.payloadString) {
item.payloadString = toText(item.payload.subarray(0, 100));
}
const classes = useClasses();
return (
<div
className={mergeClasses(className, classes.code)}
{...rest}
>
{item.payloadString}
</div>
);
}
),
},
];
const Header = withDisplayName("Header")(
({ className, columnIndex, ...rest }: GridHeaderProps) => {
const classes = useClasses();
return (
<div className={mergeClasses(className, classes.header)} {...rest}>
{columns[columnIndex].title}
</div>
);
}
);
const Row = observer(function Row({
className,
rowIndex,
...rest
}: GridRowProps) {
const classes = useClasses();
const handlePointerDown = useStableCallback(
(e: PointerEvent<HTMLDivElement>) => {
runInAction(() => {
if (e.shiftKey) {
e.preventDefault();
}
state.selection.select(rowIndex, isModKey(e), e.shiftKey);
});
}
);
return (
<div
className={mergeClasses(
className,
classes.row,
state.selection.has(rowIndex) && classes.selected
)}
onPointerDown={handlePointerDown}
{...rest}
/>
);
});
const PacketLog: NextPage = () => {
const classes = useClasses();
return (
<Stack {...RouteStackProps} tokens={{}}>
<Head>
<title>Packet Log - Tango</title>
</Head>
<CommandBar items={state.commandBarItems} />
<StackItem className={classes.grow} grow>
<Grid
className={classes.grid}
rowCount={GLOBAL_STATE.logs.length}
rowHeight={LINE_HEIGHT}
columns={columns}
HeaderComponent={Header}
RowComponent={Row}
/>
</StackItem>
{state.selection.selectedIndex !== null &&
GLOBAL_STATE.logs[state.selection.selectedIndex].payload
.length > 0 && (
<StackItem className={classes.grow} grow>
<HexViewer
className={classes.hexViewer}
data={
GLOBAL_STATE.logs[state.selection.selectedIndex]
.payload
}
/>
</StackItem>
)}
</Stack>
);
};
export default observer(PacketLog);

View file

@ -1,130 +0,0 @@
// cspell: ignore bootloader
// cspell: ignore fastboot
import {
DefaultButton,
Icon,
MessageBar,
MessageBarType,
Stack,
TooltipHost,
} from "@fluentui/react";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps } from "../utils";
const Power: NextPage = () => {
return (
<Stack {...RouteStackProps}>
<Head>
<title>Power Menu - Tango</title>
</Head>
<div>
<DefaultButton
text="Reboot"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.reboot()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Power Off"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.powerOff()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Press Power Button"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.powerButton()}
/>
</div>
<div style={{ marginTop: 20 }}>
<MessageBar messageBarType={MessageBarType.severeWarning}>
Danger Zone Below
</MessageBar>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Bootloader"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.bootloader()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Fastboot"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.fastboot()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Recovery"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.recovery()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Sideload"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.sideload()}
/>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Qualcomm EDL Mode"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.qualcommEdlMode()}
/>
<TooltipHost
content={<span>Only works on some Qualcomm devices.</span>}
>
<Icon
style={{
verticalAlign: "middle",
marginLeft: 4,
fontSize: 18,
}}
iconName={Icons.Info}
/>
</TooltipHost>
</div>
<div style={{ marginTop: 20 }}>
<DefaultButton
text="Reboot to Samsung Odin Download Mode"
disabled={!GLOBAL_STATE.adb}
onClick={() => GLOBAL_STATE.adb!.power.samsungOdin()}
/>
<TooltipHost
content={<span>Only works on Samsung devices.</span>}
>
<Icon
style={{
verticalAlign: "middle",
marginLeft: 4,
fontSize: 18,
}}
iconName={Icons.Info}
/>
</TooltipHost>
</div>
</Stack>
);
};
export default observer(Power);

View file

@ -1,50 +0,0 @@
import { NOOP, decodeUtf8 } from "@yume-chan/adb";
import { WritableStream } from "@yume-chan/stream-extra";
import { makeAutoObservable, reaction, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import { GLOBAL_STATE } from "../state";
const state = makeAutoObservable({
log: [] as string[],
});
reaction(
() => GLOBAL_STATE.adb,
async (device) => {
if (!device) {
return;
}
await device.reverse.remove("tcp:3000").catch(NOOP);
await device.reverse.add("tcp:3000", (socket) => {
runInAction(() => {
state.log.push(`received stream`);
});
socket.readable.pipeTo(
new WritableStream({
write: (chunk) => {
runInAction(() => {
state.log.push(
`received data: ${decodeUtf8(chunk)}`
);
});
},
})
);
});
},
{ fireImmediately: true }
);
const ReverseTesterPage: NextPage = () => {
return (
<div>
{state.log.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
);
};
export default observer(ReverseTesterPage);

View file

@ -1,335 +0,0 @@
import {
Dialog,
LayerHost,
Link,
PrimaryButton,
ProgressIndicator,
Stack,
StackItem,
} from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import { makeStyles, shorthands } from "@griffel/react";
import { WebCodecsDecoder } from "@yume-chan/scrcpy-decoder-webcodecs";
import { action, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { KeyboardEvent, useEffect, useState } from "react";
import { DemoModePanel, DeviceView, ExternalLink } from "../components";
import {
NavigationBar,
SETTING_DEFINITIONS,
SETTING_STATE,
STATE,
ScrcpyCommandBar,
SettingItem,
VideoContainer,
} from "../components/scrcpy";
import { useLocalStorage } from "../hooks";
import { GLOBAL_STATE } from "../state";
import { CommonStackTokens, RouteStackProps, formatSpeed } from "../utils";
const useClasses = makeStyles({
layerHost: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
pointerEvents: "none",
...shorthands.margin(0),
},
fullScreenContainer: {
flexGrow: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "black",
":focus-visible": {
...shorthands.outline("0"),
},
},
fullScreenStatusBar: {
display: "flex",
color: "white",
columnGap: "12px",
...shorthands.padding("8px", "20px"),
},
spacer: {
flexGrow: 1,
},
});
const ConnectingDialog = observer(() => {
const classes = useClasses();
const layerHostId = useId("layerHost");
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
return (
<>
<LayerHost id={layerHostId} className={classes.layerHost} />
<Dialog
hidden={!STATE.connecting}
modalProps={{ layerProps: { hostId: layerHostId } }}
dialogContentProps={{ title: "Connecting..." }}
>
<Stack tokens={CommonStackTokens}>
<ProgressIndicator
label="Downloading scrcpy server..."
percentComplete={
STATE.serverTotalSize
? STATE.serverDownloadedSize /
STATE.serverTotalSize
: undefined
}
description={formatSpeed(
STATE.debouncedServerDownloadedSize,
STATE.serverTotalSize,
STATE.serverDownloadSpeed
)}
/>
<ProgressIndicator
label="Pushing scrcpy server to device..."
progressHidden={
STATE.serverTotalSize === 0 ||
STATE.serverDownloadedSize !== STATE.serverTotalSize
}
percentComplete={
STATE.serverUploadedSize / STATE.serverTotalSize
}
description={formatSpeed(
STATE.debouncedServerUploadedSize,
STATE.serverTotalSize,
STATE.serverUploadSpeed
)}
/>
<ProgressIndicator
label="Starting scrcpy server on device..."
progressHidden={
STATE.serverTotalSize === 0 ||
STATE.serverUploadedSize !== STATE.serverTotalSize
}
/>
</Stack>
</Dialog>
</>
);
});
async function handleKeyEvent(e: KeyboardEvent<HTMLDivElement>) {
if (!STATE.client) {
return;
}
e.preventDefault();
e.stopPropagation();
const { type, code } = e;
STATE.keyboard![type === "keydown" ? "down" : "up"](code);
}
function handleBlur() {
if (!STATE.client) {
return;
}
STATE.keyboard?.reset();
}
const FullscreenHint = observer(function FullscreenHint({
keyboardLockEnabled,
}: {
keyboardLockEnabled: boolean;
}) {
const classes = useClasses();
const [hintHidden, setHintHidden] = useLocalStorage<`${boolean}`>(
"scrcpy-hint-hidden",
"false"
);
if (!keyboardLockEnabled || !STATE.isFullScreen || hintHidden === "true") {
return null;
}
return (
<div className={classes.fullScreenStatusBar}>
<div>{GLOBAL_STATE.device?.serial}</div>
<div>FPS: {STATE.fps}</div>
<div className={classes.spacer} />
<div>Press and hold ESC to exit full screen</div>
<Link onClick={() => setHintHidden("true")}>
{`Don't show again`}
</Link>
</div>
);
});
const Scrcpy: NextPage = () => {
const classes = useClasses();
useEffect(() => {
// Detect WebCodecs support at client side
if (
SETTING_STATE.decoders.length === 1 &&
WebCodecsDecoder.isSupported()
) {
runInAction(() => {
SETTING_STATE.decoders.unshift({
key: "webcodecs",
name: "WebCodecs",
Constructor: WebCodecsDecoder,
});
});
}
}, []);
const [keyboardLockEnabled, setKeyboardLockEnabled] = useState(false);
useEffect(() => {
if (!("keyboard" in navigator)) {
return;
}
// Keyboard Lock is only effective in fullscreen mode,
// but the `lock` method can be called at any time.
// @ts-expect-error
navigator.keyboard.lock();
setKeyboardLockEnabled(true);
return () => {
// @ts-expect-error
navigator.keyboard.unlock();
};
}, []);
useEffect(() => {
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("blur", handleBlur);
};
}, []);
return (
<Stack {...RouteStackProps}>
<Head>
<title>Scrcpy - Tango</title>
</Head>
{STATE.running ? (
<>
<ScrcpyCommandBar />
<Stack horizontal grow styles={{ root: { height: 0 } }}>
<div
ref={STATE.setFullScreenContainer}
className={classes.fullScreenContainer}
tabIndex={0}
onKeyDown={handleKeyEvent}
onKeyUp={handleKeyEvent}
>
<FullscreenHint
keyboardLockEnabled={keyboardLockEnabled}
/>
<DeviceView
width={STATE.rotatedWidth}
height={STATE.rotatedHeight}
BottomElement={NavigationBar}
>
<VideoContainer />
</DeviceView>
</div>
<div
style={{
padding: 12,
overflow: "hidden auto",
display: STATE.logVisible ? "block" : "none",
width: 500,
fontFamily: "monospace",
overflowY: "auto",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
}}
>
{STATE.log.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
<DemoModePanel
style={{
display: STATE.demoModeVisible
? "block"
: "none",
}}
/>
</Stack>
</>
) : (
<>
<div>
<ExternalLink
href="https://github.com/Genymobile/scrcpy"
spaceAfter
>
Scrcpy
</ExternalLink>
can mirror device display and audio with low latency and
control the device, all without root access.
</div>
<div>
This is a TypeScript re-implementation of the client
part. Paired with official pre-built server binary.
</div>
<StackItem align="start">
<PrimaryButton
text="Start"
disabled={!GLOBAL_STATE.adb}
onClick={STATE.start}
/>
</StackItem>
{SETTING_DEFINITIONS.get().map((definition) => (
<SettingItem
key={definition.key}
definition={definition}
value={
(SETTING_STATE[definition.group] as any)[
definition.key
]
}
onChange={action(
(definition, value) =>
((SETTING_STATE[definition.group] as any)[
definition.key
] = value)
)}
/>
))}
<ConnectingDialog />
</>
)}
</Stack>
);
};
export default observer(Scrcpy);

View file

@ -1,41 +0,0 @@
import { makeStyles } from "@griffel/react";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { useCallback } from "react";
import { attachTabbyFrame } from "../components";
const useClasses = makeStyles({
container: {
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
});
const Shell: NextPage = (): JSX.Element | null => {
const classes = useClasses();
const handleContainerRef = useCallback(
(container: HTMLDivElement | null) => {
// invoke it with `null` to hide the iframe
attachTabbyFrame(container);
},
[]
);
return (
<>
<Head>
<title>Interactive Shell - Tango</title>
</Head>
<div ref={handleContainerRef} className={classes.container}>
<div>Loading Tabby...</div>
</div>
</>
);
};
export default observer(Shell);

View file

@ -1,25 +0,0 @@
import { useEffect } from "react";
function TabbyFrame() {
useEffect(() => {
// Only run at client side.
try {
require("@yume-chan/tabby-launcher");
} catch (e) {
console.error(e);
}
}, []);
return (
<div>
<style id="custom-css" />
{/* @ts-expect-error */}
<app-root />
</div>
);
}
TabbyFrame.noLayout = true;
export default TabbyFrame;

View file

@ -1,252 +0,0 @@
// cspell: ignore addrs
import {
ICommandBarItemProps,
MessageBar,
Stack,
StackItem,
Text,
TextField,
Toggle,
} from "@fluentui/react";
import { autorun, makeAutoObservable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
import Head from "next/head";
import { useCallback, useEffect } from "react";
import { CommandBar, ExternalLink } from "../components";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps, asyncEffect } from "../utils";
class TcpIpState {
initial = true;
visible = false;
serviceListenAddresses: string[] | undefined = undefined;
servicePortEnabled = false;
servicePort: string = "";
persistPortEnabled = false;
persistPort: string | undefined = undefined;
constructor() {
makeAutoObservable(this, {
initial: false,
queryInfo: false,
applyServicePort: false,
});
autorun(() => {
if (GLOBAL_STATE.adb) {
if (this.initial && this.visible) {
this.initial = false;
this.queryInfo();
}
} else {
this.initial = true;
}
});
}
get commandBarItems(): ICommandBarItemProps[] {
return [
{
key: "refresh",
disabled: !GLOBAL_STATE.adb,
iconProps: { iconName: Icons.ArrowClockwise },
text: "Refresh",
onClick: this.queryInfo as VoidFunction,
},
{
key: "apply",
disabled: !GLOBAL_STATE.adb,
iconProps: { iconName: Icons.Save },
text: "Apply",
onClick: this.applyServicePort,
},
];
}
queryInfo = asyncEffect(async (signal) => {
if (!GLOBAL_STATE.adb) {
runInAction(() => {
this.serviceListenAddresses = undefined;
this.servicePortEnabled = false;
this.servicePort = "";
this.persistPortEnabled = false;
this.persistPort = undefined;
});
return;
}
const { serviceListenAddresses, servicePort, persistPort } =
await GLOBAL_STATE.adb.tcpip.getListenAddresses();
if (signal.aborted) {
return;
}
runInAction(() => {
this.serviceListenAddresses = serviceListenAddresses;
if (servicePort) {
this.servicePortEnabled = !serviceListenAddresses;
this.servicePort = servicePort.toString();
} else {
this.servicePortEnabled = false;
this.servicePort = "5555";
}
if (persistPort) {
this.persistPortEnabled =
!serviceListenAddresses && !servicePort;
this.persistPort = persistPort.toString();
} else {
this.persistPortEnabled = false;
this.persistPort = undefined;
}
});
});
applyServicePort = async () => {
if (!GLOBAL_STATE.adb) {
return;
}
if (state.servicePortEnabled) {
await GLOBAL_STATE.adb.tcpip.setPort(
Number.parseInt(state.servicePort, 10),
);
} else {
await GLOBAL_STATE.adb.tcpip.disable();
}
};
}
const state = new TcpIpState();
const TcpIp: NextPage = () => {
useEffect(() => {
runInAction(() => {
state.visible = true;
});
return () => {
runInAction(() => {
state.visible = false;
});
};
});
const handleServicePortEnabledChange = useCallback(
(e: unknown, value?: boolean) => {
runInAction(() => {
state.servicePortEnabled = !!value;
});
},
[],
);
const handleServicePortChange = useCallback(
(e: unknown, value?: string) => {
if (value === undefined) {
return;
}
runInAction(() => (state.servicePort = value));
},
[],
);
return (
<Stack {...RouteStackProps}>
<Head>
<title>ADB over WiFi - Tango</title>
</Head>
<CommandBar items={state.commandBarItems} />
<StackItem>
<MessageBar>
<Text>
For Tango to wirelessly connect to your device,
<ExternalLink
href="https://github.com/yume-chan/ya-webadb/discussions/245#discussioncomment-384030"
spaceBefore
spaceAfter
>
extra software
</ExternalLink>
is required.
</Text>
</MessageBar>
</StackItem>
<StackItem>
<MessageBar>
<Text>
Your device will disconnect after changing ADB over WiFi
config.
</Text>
</MessageBar>
</StackItem>
<StackItem>
<Toggle
inlineLabel
label="service.adb.listen_addrs"
disabled
checked={!!state.serviceListenAddresses}
onText="Enabled"
offText="Disabled"
/>
{state.serviceListenAddresses?.map((address) => (
<TextField
key={address}
disabled
value={address}
styles={{ root: { width: 300 } }}
/>
))}
</StackItem>
<StackItem>
<Toggle
inlineLabel
label="service.adb.tcp.port"
checked={state.servicePortEnabled}
disabled={
!GLOBAL_STATE.adb || !!state.serviceListenAddresses
}
onText="Enabled"
offText="Disabled"
onChange={handleServicePortEnabledChange}
/>
<TextField
disabled={
!GLOBAL_STATE.adb || !!state.serviceListenAddresses
}
value={state.servicePort}
styles={{ root: { width: 300 } }}
onChange={handleServicePortChange}
/>
</StackItem>
<StackItem>
<Toggle
inlineLabel
label="persist.adb.tcp.port"
disabled
checked={state.persistPortEnabled}
onText="Enabled"
offText="Disabled"
/>
{state.persistPort && (
<TextField
disabled
value={state.persistPort}
styles={{ root: { width: 300 } }}
/>
)}
</StackItem>
</Stack>
);
};
export default observer(TcpIp);

View file

@ -1,64 +0,0 @@
import { Adb, AdbDaemonDevice, AdbPacketData } from "@yume-chan/adb";
import { action, makeAutoObservable, observable } from "mobx";
export type PacketLogItemDirection = "in" | "out";
export interface PacketLogItem extends AdbPacketData {
direction: PacketLogItemDirection;
timestamp?: Date;
commandString?: string;
arg0String?: string;
arg1String?: string;
payloadString?: string;
}
export class GlobalState {
device: AdbDaemonDevice | undefined = undefined;
adb: Adb | undefined = undefined;
errorDialogVisible = false;
errorDialogMessage = "";
logs: PacketLogItem[] = [];
constructor() {
makeAutoObservable(this, {
hideErrorDialog: action.bound,
logs: observable.shallow,
});
}
setDevice(device: AdbDaemonDevice | undefined, adb: Adb | undefined) {
this.device = device;
this.adb = adb;
}
showErrorDialog(message: Error | string) {
this.errorDialogVisible = true;
if (message instanceof Error) {
this.errorDialogMessage = message.stack || message.message;
} else {
this.errorDialogMessage = message;
}
}
hideErrorDialog() {
this.errorDialogVisible = false;
}
appendLog(direction: PacketLogItemDirection, packet: AdbPacketData) {
this.logs.push({
...packet,
direction,
timestamp: new Date(),
payload: packet.payload.slice(),
} as PacketLogItem);
}
clearLog() {
this.logs.length = 0;
}
}
export const GLOBAL_STATE = new GlobalState();

View file

@ -1 +0,0 @@
export * from './global';

View file

@ -1,27 +0,0 @@
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
#__next {
width: 100%;
height: 100%;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
.ms-Dialog-subText {
white-space: pre-wrap;
}

View file

@ -1,42 +0,0 @@
export function asyncEffect<Args extends unknown[]>(effect: (signal: AbortSignal, ...args: Args) => Promise<void | (() => void)>) {
let cancelLast = () => { };
return async (...args: Args) => {
cancelLast();
cancelLast = () => {
// Effect finished before abortion
// Call cleanup
if (typeof cleanup === 'function') {
cleanup();
}
// Request abortion
abortController.abort();
};
const abortController = new AbortController();
let cleanup: void | (() => void);
try {
cleanup = await effect(abortController.signal, ...args);
// Abortion requested but the effect still finished
// Immediately call cleanup
if (abortController.signal.aborted) {
if (typeof cleanup === 'function') {
cleanup();
}
}
} catch (e) {
if (e instanceof DOMException) {
// Ignore errors from AbortSignal-aware APIs
// (e.g. `fetch`)
if (e.name === 'AbortError') {
return;
}
}
console.error(e);
}
};
}

View file

@ -1,88 +0,0 @@
import { useSetInterval } from "@fluentui/react-hooks";
import { Consumable, InspectStream } from "@yume-chan/stream-extra";
import { useEffect, useRef, useState } from "react";
const units = [" B", " KB", " MB", " GB"];
export function formatSize(value: number): string {
let index = 0;
while (index < units.length && value > 1024) {
index += 1;
value /= 1024;
}
return (
value.toLocaleString(undefined, { maximumFractionDigits: 2 }) +
units[index]
);
}
export function formatSpeed(
completed: number,
total: number,
speed: number
): string | undefined {
if (total === 0) {
return undefined;
}
return `${formatSize(completed)} of ${formatSize(total)} (${formatSize(
speed
)}/s)`;
}
export function useSpeed(
completed: number,
total: number
): [completed: number, speed: number] {
const completedRef = useRef(completed);
completedRef.current = completed;
const [debouncedCompleted, setDebouncedCompleted] = useState(completed);
const [speed, setSpeed] = useState(0);
const { setInterval, clearInterval } = useSetInterval();
const intervalIdRef = useRef<number>();
useEffect(() => {
intervalIdRef.current = setInterval(() => {
setDebouncedCompleted((debouncedCompleted) => {
setSpeed(completedRef.current - debouncedCompleted);
return completedRef.current;
});
}, 1000);
return () => {
clearInterval(intervalIdRef.current!);
};
}, [clearInterval, setInterval, total]);
useEffect(() => {
if (total !== 0 && completed === total) {
setDebouncedCompleted((debouncedCompleted) => {
setSpeed(total - debouncedCompleted);
return total;
});
clearInterval(intervalIdRef.current!);
}
}, [clearInterval, completed, total]);
return [debouncedCompleted, speed];
}
export function delay(time: number): Promise<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, time);
});
}
/**
* Because of internal buffer of upstream/downstream streams,
* the progress value won't be 100% accurate. But it's usually good enough.
*/
export class ProgressStream extends InspectStream<Consumable<Uint8Array>> {
public constructor(onProgress: (value: number) => void) {
let progress = 0;
super((chunk) => {
progress += chunk.value.byteLength;
onProgress(progress);
});
}
}

View file

@ -1,76 +0,0 @@
import {
WrapReadableStream,
WritableStream,
type ReadableStream,
} from "@yume-chan/stream-extra";
import getConfig from "next/config";
interface PickFileOptions {
accept?: string;
}
export function pickFile(
options: { multiple: true } & PickFileOptions
): Promise<FileList>;
export function pickFile(
options: { multiple?: false } & PickFileOptions
): Promise<File | null>;
export function pickFile(
options: { multiple?: boolean } & PickFileOptions
): Promise<FileList | File | null> {
return new Promise<FileList | File | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
if (options.multiple) {
input.multiple = true;
}
if (options.accept) {
input.accept = options.accept;
}
input.onchange = () => {
if (options.multiple) {
resolve(input.files!);
} else {
resolve(input.files!.item(0));
}
};
input.click();
});
}
let StreamSaver: typeof import("@yume-chan/stream-saver");
if (typeof window !== "undefined") {
const {
publicRuntimeConfig: { basePath },
} = getConfig();
// Can't use `import` here because ESM is read-only (can't set `mitm` field)
StreamSaver = require("@yume-chan/stream-saver");
StreamSaver.mitm = basePath + "/StreamSaver/mitm.html";
// Pre-register the service worker for offline usage.
// Request for service worker script won't go through another service worker
// so can't be cached.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register(basePath + "/StreamSaver/sw.js", {
scope: basePath + "/StreamSaver/",
});
}
}
export function saveFile(fileName: string, size?: number | undefined) {
return StreamSaver!.createWriteStream(fileName, {
size,
}) as unknown as WritableStream<Uint8Array>;
}
export function createFileStream(file: File) {
// `@types/node` typing messed things up
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/58079
// TODO: demo: remove the wrapper after switching to native stream implementation.
return new WrapReadableStream<Uint8Array>(
file.stream() as unknown as ReadableStream<Uint8Array>
);
}

View file

@ -1,189 +0,0 @@
import { registerIcons } from "@fluentui/react";
import {
AddCircleRegular,
WindowDevToolsRegular,
ArrowClockwiseRegular,
ArrowRotateClockwiseRegular,
ArrowRotateCounterclockwiseRegular,
ArrowSortDownRegular,
ArrowSortUpRegular,
BookSearchRegular,
BookmarkRegular,
BoxRegular,
BugRegular,
CameraRegular,
CheckmarkRegular,
ChevronDownRegular,
ChevronRightRegular,
ChevronUpRegular,
CircleRegular,
CloudArrowDownRegular,
CloudArrowUpRegular,
CopyRegular,
DeleteRegular,
DocumentRegular,
FilterRegular,
FolderRegular,
FullScreenMaximizeRegular,
InfoRegular,
LightbulbFilamentRegular,
LightbulbRegular,
MoreHorizontalRegular,
NavigationRegular,
OrientationRegular,
PanelBottomRegular,
PersonFeedbackRegular,
PhoneLaptopRegular,
PhoneRegular,
PhoneSpeakerRegular,
PlayRegular,
PlugConnectedRegular,
PlugDisconnectedRegular,
PowerRegular,
RecordRegular,
SaveRegular,
SearchRegular,
SettingsRegular,
Speaker1Regular,
Speaker2Regular,
SpeakerOffRegular,
StopRegular,
TextGrammarErrorRegular,
TextGrammarSettingsRegular,
WandRegular,
WarningRegular,
WifiSettingsRegular,
WindowConsoleRegular,
} from "@fluentui/react-icons";
const STYLE = {};
export function register() {
registerIcons({
icons: {
// General use
AddCircle: <AddCircleRegular style={STYLE} />,
ArrowClockwise: <ArrowClockwiseRegular style={STYLE} />,
Bookmark: <BookmarkRegular style={STYLE} />,
BookSearch: <BookSearchRegular style={STYLE} />,
Box: <BoxRegular style={STYLE} />,
Bug: <BugRegular style={STYLE} />,
Camera: <CameraRegular style={STYLE} />,
ChevronDown: <ChevronDownRegular style={STYLE} />,
ChevronRight: <ChevronRightRegular style={STYLE} />,
ChevronUp: <ChevronUpRegular style={STYLE} />,
Circle: <CircleRegular style={STYLE} />,
Copy: <CopyRegular style={STYLE} />,
CloudArrowUp: <CloudArrowUpRegular style={STYLE} />,
CloudArrowDown: <CloudArrowDownRegular style={STYLE} />,
Delete: <DeleteRegular style={STYLE} />,
Document: <DocumentRegular style={STYLE} />,
Folder: <FolderRegular style={STYLE} />,
FullScreenMaximize: <FullScreenMaximizeRegular style={STYLE} />,
Info: <InfoRegular style={STYLE} />,
Lightbulb: <LightbulbRegular style={STYLE} />,
LightbulbFilament: <LightbulbFilamentRegular style={STYLE} />,
Navigation: <NavigationRegular style={STYLE} />,
Orientation: <OrientationRegular style={STYLE} />,
PanelBottom: <PanelBottomRegular style={STYLE} />,
PersonFeedback: <PersonFeedbackRegular style={STYLE} />,
Phone: <PhoneRegular style={STYLE} />,
PhoneLaptop: <PhoneLaptopRegular style={STYLE} />,
PhoneSpeaker: <PhoneSpeakerRegular style={STYLE} />,
Play: <PlayRegular style={STYLE} />,
PlugConnected: <PlugConnectedRegular style={STYLE} />,
PlugDisconnected: <PlugDisconnectedRegular style={STYLE} />,
Power: <PowerRegular style={STYLE} />,
Record: <RecordRegular style={STYLE} />,
RotateLeft: <ArrowRotateCounterclockwiseRegular style={STYLE} />,
RotateRight: <ArrowRotateClockwiseRegular style={STYLE} />,
Save: <SaveRegular style={STYLE} />,
Settings: <SettingsRegular style={STYLE} />,
Speaker1: <Speaker1Regular style={STYLE} />,
Speaker2: <Speaker2Regular style={STYLE} />,
SpeakerOff: <SpeakerOffRegular style={STYLE} />,
Stop: <StopRegular style={STYLE} />,
TextGrammarError: <TextGrammarErrorRegular style={STYLE} />,
TextGrammarSettings: <TextGrammarSettingsRegular style={STYLE} />,
Wand: <WandRegular style={STYLE} />,
Warning: <WarningRegular style={STYLE} />,
WifiSettings: <WifiSettingsRegular style={STYLE} />,
WindowConsole: <WindowConsoleRegular style={STYLE} />,
WindowDevTools: <WindowDevToolsRegular style={STYLE} />,
// Required by @fluentui/react
Checkmark: <CheckmarkRegular style={STYLE} />,
StatusCircleCheckmark: <CheckmarkRegular style={STYLE} />,
ChevronUpSmall: <ChevronUpRegular style={STYLE} />,
ChevronDownSmall: <ChevronDownRegular style={STYLE} />,
CircleRing: <CircleRegular style={STYLE} />,
More: <MoreHorizontalRegular />,
SortUp: <ArrowSortUpRegular style={STYLE} />,
SortDown: <ArrowSortDownRegular style={STYLE} />,
Search: <SearchRegular style={STYLE} />,
Filter: <FilterRegular style={STYLE} />,
// Required by file manager page
Document20: (
<DocumentRegular
style={{ fontSize: 20, verticalAlign: "middle" }}
/>
),
},
});
}
const Icons = {
AddCircle: "AddCircle",
ArrowClockwise: "ArrowClockwise",
Bookmark: "Bookmark",
BookSearch: "BookSearch",
Box: "Box",
Bug: "Bug",
Camera: "Camera",
Copy: "Copy",
Circle: "Circle",
ChevronDown: "ChevronDown",
ChevronRight: "ChevronRight",
ChevronUp: "ChevronUp",
CloudArrowUp: "CloudArrowUp",
CloudArrowDown: "CloudArrowDown",
Delete: "Delete",
Document: "Document",
Folder: "Folder",
FullScreenMaximize: "FullScreenMaximize",
Lightbulb: "Lightbulb",
LightbulbFilament: "LightbulbFilament",
Info: "Info",
Navigation: "Navigation",
Orientation: "Orientation",
PanelBottom: "PanelBottom",
PersonFeedback: "PersonFeedback",
Phone: "Phone",
PhoneLaptop: "PhoneLaptop",
PhoneSpeaker: "PhoneSpeaker",
Play: "Play",
PlugConnected: "PlugConnected",
PlugDisconnected: "PlugDisconnected",
Power: "Power",
Record: "Record",
RotateLeft: "RotateLeft",
RotateRight: "RotateRight",
Save: "Save",
Settings: "Settings",
Speaker1: "Speaker1",
Speaker2: "Speaker2",
SpeakerOff: "SpeakerOff",
Stop: "Stop",
TextGrammarError: "TextGrammarError",
TextGrammarSettings: "TextGrammarSettings",
Wand: "Wand",
Warning: "Warning",
WifiSettings: "WifiSettings",
WindowConsole: "WindowConsole",
WindowDevTools: "WindowDevTools",
Document20: "Document20",
};
export default Icons;

View file

@ -1,6 +0,0 @@
export * from './async-effect';
export * from './file';
export * from './file-size';
export { default as Icons } from './icons';
export * from './styles';
export * from './with-display-name';

View file

@ -1,15 +0,0 @@
import { AnimationClassNames, IStackProps } from "@fluentui/react";
export const CommonStackTokens = { childrenGap: 8 };
export const DefaultStackProps: IStackProps = {
tokens: { childrenGap: 8, padding: 16 },
verticalFill: true,
};
export const RouteStackProps: IStackProps = {
...DefaultStackProps,
className: AnimationClassNames.slideUpIn10!,
styles: { root: { overflow: 'auto', position: 'relative' } },
disableShrink: true,
};

View file

@ -1,28 +0,0 @@
import React, { memo, useCallback, useEffect, useRef } from 'react';
export function withDisplayName(name: string) {
return <P extends object>(Component: React.FunctionComponent<P>) => {
Component.displayName = name;
return memo(Component);
};
}
export function forwardRef<T>(name: string) {
return <P extends object>(Component: React.ForwardRefRenderFunction<T, P>) => {
return withDisplayName(name)(React.forwardRef(Component));
};
}
export function useStableCallback<TArgs extends any[], R>(callback: (...args: TArgs) => R): (...args: TArgs) => R {
const ref = useRef<(...args: TArgs) => R>(callback);
useEffect(() => {
ref.current = callback;
});
const wrapper = useRef((...args: TArgs) => {
return ref.current.apply(undefined, args);
});
return wrapper.current;
}

View file

@ -1,41 +0,0 @@
declare module '@yume-chan/stream-saver' {
type OriginalWriteableStream = typeof WritableStream;
namespace StreamSaver {
export interface Options<W = any> {
size?: number;
pathname?: string;
writableStrategy?: QueuingStrategy<W>;
readableStrategy?: QueuingStrategy<W>;
}
export function createWriteStream<W = any>(
filename: string,
options?: Options<W>
): WritableStream<W>;
/** @deprecated */
export function createWriteStream<W = any>(
filename: string,
size?: number,
strategy?: QueuingStrategy<W>
): WritableStream<W>;
/** @deprecated */
export function createWriteStream<W = any>(
filename: string,
strategy?: QueuingStrategy<W>
): WritableStream<W>;
export const WritableStream: OriginalWriteableStream;
export const supported: true;
export const version: { full: string; major: number, minor: number, dot: number; };
export let mitm: string;
}
export = StreamSaver;
}

View file

@ -1,32 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext",
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.mjs"
],
"exclude": [
"node_modules"
]
}

View file

@ -1,4 +0,0 @@
declare module "file-loader!*" {
const url: string;
export default url;
}