mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-06 03:50:18 +02:00
chore: remove deprecated packages
This commit is contained in:
parent
3a14c162d6
commit
ec46a1730e
136 changed files with 134 additions and 18955 deletions
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
41
apps/demo/.gitignore
vendored
41
apps/demo/.gitignore
vendored
|
@ -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
|
|
@ -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.
|
5
apps/demo/next-env.d.ts
vendored
5
apps/demo/next-env.d.ts
vendored
|
@ -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.
|
|
@ -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
|
||||
);
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 |
|
@ -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"
|
||||
);
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -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 && ' '}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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";
|
|
@ -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]();
|
||||
}
|
||||
}
|
|
@ -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}</>;
|
||||
}
|
|
@ -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} />
|
||||
);
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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()} />;
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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";
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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 = "";
|
||||
}
|
||||
});
|
|
@ -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();
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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 : () => {};
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./add-event-listener";
|
||||
export * from "./layout-effect";
|
||||
export * from "./local-storage";
|
||||
export * from "./stable-callback";
|
|
@ -1,4 +0,0 @@
|
|||
import { useLayoutEffect as useReactLayoutEffect } from "react";
|
||||
|
||||
export const useLayoutEffect =
|
||||
typeof window !== "undefined" ? useReactLayoutEffect : () => {};
|
|
@ -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, () => {}];
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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(),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import Router from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Fallback() {
|
||||
useEffect(() => {
|
||||
Router.replace(location.href);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
|
||||
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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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();
|
|
@ -1 +0,0 @@
|
|||
export * from './global';
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
41
apps/demo/streamsaver.d.ts
vendored
41
apps/demo/streamsaver.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
4
apps/demo/types/file-loader.d.ts
vendored
4
apps/demo/types/file-loader.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
declare module "file-loader!*" {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue