diff --git a/client-electron/package-lock.json b/client-electron/package-lock.json index c81694b..93facf4 100644 --- a/client-electron/package-lock.json +++ b/client-electron/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.19", "@tanstack/react-table": "^8.11.7", @@ -49,10 +50,12 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.49.3", "react-router-dom": "^6.21.1", - "sonner": "^1.3.1", + "sonner": "^1.4.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "usb": "^2.11.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", "zod": "^3.22.4", "zustand": "^4.4.7" }, @@ -2761,18 +2764,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -3748,6 +3739,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", @@ -12152,9 +12173,9 @@ } }, "node_modules/sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.4.0.tgz", + "integrity": "sha512-nvkTsIuOmi9e5Wz5If8ldasJjZNVfwiXYijBi2dbijvTQnQppvMcXTFNxL/NUFWlI2yJ1JX7TREDsg+gYm9WyA==", "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" @@ -12649,34 +12670,6 @@ "node": ">= 10.0.0" } }, - "node_modules/terser": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", - "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13358,6 +13351,19 @@ "node": ">=8.0" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" + }, + "node_modules/xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -15455,18 +15461,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" }, - "@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -16013,6 +16007,22 @@ "@radix-ui/react-compose-refs": "1.0.1" } }, + "@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, "@radix-ui/react-toast": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", @@ -22118,9 +22128,9 @@ } }, "sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.4.0.tgz", + "integrity": "sha512-nvkTsIuOmi9e5Wz5If8ldasJjZNVfwiXYijBi2dbijvTQnQppvMcXTFNxL/NUFWlI2yJ1JX7TREDsg+gYm9WyA==", "requires": {} }, "source-map": { @@ -22503,30 +22513,6 @@ } } }, - "terser": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", - "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -23006,6 +22992,17 @@ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true }, + "xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" + }, + "xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "requires": {} + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/client-electron/package.json b/client-electron/package.json index b6b8b30..0c8d1f3 100644 --- a/client-electron/package.json +++ b/client-electron/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.19", "@tanstack/react-table": "^8.11.7", @@ -61,10 +62,12 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.49.3", "react-router-dom": "^6.21.1", - "sonner": "^1.3.1", + "sonner": "^1.4.0", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "usb": "^2.11.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", "zod": "^3.22.4", "zustand": "^4.4.7" }, diff --git a/client-electron/src/App.tsx b/client-electron/src/App.tsx index b3d8433..bdaf30e 100644 --- a/client-electron/src/App.tsx +++ b/client-electron/src/App.tsx @@ -3,7 +3,7 @@ import AuthCallBack from './AuthCallBack' import MainLayout from './layouts/MainLayout' import HomePage from './pages/home' import LoginPage from './pages/login' -import AndroidPage from './pages/android' +import AndroidPage from './pages/android/android' import RecipesPage from './pages/recipes/recipes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import UploadPage from './pages/upload' diff --git a/client-electron/src/components/ui/sonner.tsx b/client-electron/src/components/ui/sonner.tsx new file mode 100644 index 0000000..1128edf --- /dev/null +++ b/client-electron/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/client-electron/src/components/ui/tabs.tsx b/client-electron/src/components/ui/tabs.tsx new file mode 100644 index 0000000..85d83be --- /dev/null +++ b/client-electron/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/client-electron/src/pages/Android.tsx b/client-electron/src/pages/Android.tsx deleted file mode 100644 index 2bfcfcc..0000000 --- a/client-electron/src/pages/Android.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { useShallow } from 'zustand/react/shallow' -import { ADB_DEFAULT_DEVICE_FILTER } from '@yume-chan/adb-daemon-webusb' -import AdbWebCredentialStore from '@yume-chan/adb-credential-web' -import { Adb, AdbDaemonTransport } from '@yume-chan/adb' -import useAdb from '../hooks/useAdb' -import type { AdbScrcpyVideoStream } from '@yume-chan/adb-scrcpy' -import { AdbScrcpyClient, AdbScrcpyOptions1_22 } from '@yume-chan/adb-scrcpy' -import { WebCodecsDecoder } from '@yume-chan/scrcpy-decoder-webcodecs' -import { - AndroidKeyCode, - AndroidKeyEventAction, - AndroidMotionEventAction, - ScrcpyLogLevel1_18, - ScrcpyOptions1_25, - ScrcpyPointerId, - ScrcpyVideoCodecId -} from '@yume-chan/scrcpy' -import { useCallback, useRef, useState } from 'react' -import { ReadableStream, WritableStream, Consumable, DecodeUtf8Stream } from '@yume-chan/stream-extra' -import { useBeforeUnload } from 'react-router-dom' -import { Button } from '@/components/ui/button' -import { ArrowLeftIcon, HomeIcon } from '@radix-ui/react-icons' - -const AndroidPage: React.FC = () => { - const { adb, manager, device, setAdb, setDevice } = useAdb( - useShallow(state => ({ - adb: state.adb, - manager: state.manager, - device: state.device, - setAdb: state.setAdb, - setDevice: state.setDevice - })) - ) - - async function createNewConnection() { - device?.raw.forget() - setDevice(undefined) - const selectedDevice = await manager?.requestDevice({ - filters: [ - { - ...ADB_DEFAULT_DEVICE_FILTER, - serialNumber: 'd' - } - ] - }) - - if (!selectedDevice) { - return - } else { - setDevice(selectedDevice) - } - - const connection = await selectedDevice.connect() - - const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore() - - const transport = await AdbDaemonTransport.authenticate({ - serial: selectedDevice.serial, - connection: connection, - credentialStore: credentialStore - }) - - const adb: Adb = new Adb(transport) - setAdb(adb) - } - - const [client, setClient] = useState() - const [decoder, setDecoder] = useState() - const screenRef = useRef(null) - - // when user close or refresh the page, close the adb connection - useBeforeUnload( - useCallback(() => { - decoder?.dispose() - client?.close() - adb?.close() - - setDecoder(undefined) - setClient(undefined) - setAdb(undefined) - }, []) - ) - - async function scrcpyConnect() { - const server: ArrayBuffer = await fetch(new URL('../scrcpy/scrcpy_server_v1.25', import.meta.url)).then(res => - res.arrayBuffer() - ) - - await AdbScrcpyClient.pushServer( - adb!, - new ReadableStream({ - start(controller) { - controller.enqueue(new Consumable(new Uint8Array(server))) - controller.close() - } - }) - ) - - const res = await adb!.subprocess.spawn( - 'CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25' - ) - - res.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo( - new WritableStream({ - write(chunk) { - console.log(chunk) - } - }) - ) - - const scrcpyOption = new ScrcpyOptions1_25({ - maxFps: 60, - bitRate: 4000000, - stayAwake: true, - control: true, - logLevel: ScrcpyLogLevel1_18.Debug - }) - const _client = await AdbScrcpyClient.start( - adb!, - '/data/local/tmp/scrcpy-server.jar', - '1.25', - new AdbScrcpyOptions1_22(scrcpyOption) - ) - - const videoStream: AdbScrcpyVideoStream | undefined = await _client?.videoStream - - if (videoStream) { - const _decoder = new WebCodecsDecoder(ScrcpyVideoCodecId.H264) - - _decoder.renderer.style.width = '100%' - _decoder.renderer.style.height = '100%' - - if (screenRef.current && screenRef.current.firstChild) { - screenRef.current.removeChild(screenRef.current.firstChild) - } - screenRef.current?.appendChild(_decoder.renderer) - videoStream?.stream.pipeTo(_decoder.writable) - setDecoder(_decoder) - - if (_client.controlMessageWriter) { - _decoder.renderer.addEventListener('mousedown', e => { - // client width and height 700 x 400 - const react = _decoder.renderer.getBoundingClientRect() - - // normalize to _decoder.renderer.width and height 1080 x 1920 - const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width - const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height - - //console.log('mouse down at ' + x + ' ' + y) - _client.controlMessageWriter?.injectTouch({ - action: AndroidMotionEventAction.Down, - pointerId: ScrcpyPointerId.Mouse | ScrcpyPointerId.Finger, - pointerX: x, - pointerY: y, - pressure: 1, - screenWidth: _decoder.renderer.width, - screenHeight: _decoder.renderer.height, - buttons: 0, - actionButton: 0 - }) - }) - - _decoder.renderer.addEventListener('mouseup', e => { - // client width and height 700 x 400 - const react = _decoder.renderer.getBoundingClientRect() - - // normalize to _decoder.renderer.width and height 1080 x 1920 - const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width - const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height - - //console.log('mouse up at ' + x + ' ' + y) - _client.controlMessageWriter?.injectTouch({ - action: AndroidMotionEventAction.Up, - pointerId: ScrcpyPointerId.Mouse, - pointerX: x, - pointerY: y, - pressure: 1, - screenWidth: _decoder.renderer.width, - screenHeight: _decoder.renderer.height, - buttons: 0, - actionButton: 0 - }) - }) - - _decoder.renderer.addEventListener('mousemove', e => { - // client width and height 700 x 400 - const react = _decoder.renderer.getBoundingClientRect() - - // normalize to _decoder.renderer.width and height 1080 x 1920 - const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width - const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height - - //console.log('mouse move at ' + x + ' ' + y) - _client.controlMessageWriter?.injectTouch({ - action: AndroidMotionEventAction.Move, - pointerId: ScrcpyPointerId.Mouse, - pointerX: x, - pointerY: y, - pressure: 1, - screenWidth: _decoder.renderer.width, - screenHeight: _decoder.renderer.height, - buttons: 0, - actionButton: 0 - }) - }) - } - } - - setClient(_client) - } - - function disconnectAdb() { - client?.close() - adb?.close() - setClient(undefined) - setAdb(undefined) - } - - function scrcpyDisconnect() { - decoder?.dispose() - client?.close() - if (decoder && screenRef.current) { - screenRef.current.removeChild(decoder.renderer) - } - setClient(undefined) - setDecoder(undefined) - } - - async function rebootDevice() { - const res = await adb?.power.reboot() - console.log('[rebootDevice] res: ', res) - } - - function goHome() { - client?.controlMessageWriter?.injectKeyCode({ - action: AndroidKeyEventAction.Up, - keyCode: AndroidKeyCode.AndroidHome, - metaState: 0, - repeat: 0 - }) - } - - function goBack() { - client?.controlMessageWriter?.injectKeyCode({ - action: AndroidKeyEventAction.Up, - keyCode: AndroidKeyCode.AndroidBack, - metaState: 0, - repeat: 0 - }) - } - - return ( -
-
-
-
- {screenRef.current?.firstChild ? ( -
- - -
- ) : null} -
-
- - - -
-
-
- ) -} - -export default AndroidPage diff --git a/client-electron/src/pages/android/android.tsx b/client-electron/src/pages/android/android.tsx new file mode 100644 index 0000000..912ec39 --- /dev/null +++ b/client-electron/src/pages/android/android.tsx @@ -0,0 +1,230 @@ +import { useShallow } from 'zustand/react/shallow' +import useAdb from '../../hooks/useAdb' +import { type AdbScrcpyClient } from '@yume-chan/adb-scrcpy' +import { type WebCodecsDecoder } from '@yume-chan/scrcpy-decoder-webcodecs' +import { useCallback, useState } from 'react' +import { useBeforeUnload } from 'react-router-dom' +import { ToolBar } from './components/tool-bar' +import { ScrcpyTab } from './components/scrcpy-tab' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Toaster } from '@/components/ui/toaster' +import { ShellTab } from './components/shell-tab' + +const AndroidPage: React.FC = () => { + const { adb, setAdb } = useAdb( + useShallow(state => ({ + adb: state.adb, + manager: state.manager, + device: state.device, + setAdb: state.setAdb, + setDevice: state.setDevice + })) + ) + + const [client, setClient] = useState() + const [decoder, setDecoder] = useState() + + // when user close or refresh the page, close the adb connection + useBeforeUnload( + useCallback(() => { + decoder?.dispose() + client?.close() + adb?.close() + + setDecoder(undefined) + setClient(undefined) + setAdb(undefined) + }, []) + ) + + // async function scrcpyConnect() { + // const server: ArrayBuffer = await fetch(new URL('../scrcpy/scrcpy_server_v1.25', import.meta.url)).then(res => + // res.arrayBuffer() + // ) + + // await AdbScrcpyClient.pushServer( + // adb!, + // new ReadableStream({ + // start(controller) { + // controller.enqueue(new Consumable(new Uint8Array(server))) + // controller.close() + // } + // }) + // ) + + // const res = await adb!.subprocess.spawn( + // 'CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25' + // ) + + // res.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo( + // new WritableStream({ + // write(chunk) { + // console.log(chunk) + // } + // }) + // ) + + // const scrcpyOption = new ScrcpyOptions1_25({ + // maxFps: 60, + // bitRate: 4000000, + // stayAwake: true, + // control: true, + // logLevel: ScrcpyLogLevel1_18.Debug + // }) + // const _client = await AdbScrcpyClient.start( + // adb!, + // '/data/local/tmp/scrcpy-server.jar', + // '1.25', + // new AdbScrcpyOptions1_22(scrcpyOption) + // ) + + // const videoStream: AdbScrcpyVideoStream | undefined = await _client?.videoStream + + // if (videoStream) { + // const _decoder = new WebCodecsDecoder(ScrcpyVideoCodecId.H264) + + // _decoder.renderer.style.width = '100%' + // _decoder.renderer.style.height = '100%' + + // if (screenRef.current && screenRef.current.firstChild) { + // screenRef.current.removeChild(screenRef.current.firstChild) + // } + // screenRef.current?.appendChild(_decoder.renderer) + // videoStream?.stream.pipeTo(_decoder.writable) + // setDecoder(_decoder) + + // if (_client.controlMessageWriter) { + // _decoder.renderer.addEventListener('mousedown', e => { + // // client width and height 700 x 400 + // const react = _decoder.renderer.getBoundingClientRect() + + // // normalize to _decoder.renderer.width and height 1080 x 1920 + // const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width + // const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height + + // //console.log('mouse down at ' + x + ' ' + y) + // _client.controlMessageWriter?.injectTouch({ + // action: AndroidMotionEventAction.Down, + // pointerId: ScrcpyPointerId.Mouse | ScrcpyPointerId.Finger, + // pointerX: x, + // pointerY: y, + // pressure: 1, + // screenWidth: _decoder.renderer.width, + // screenHeight: _decoder.renderer.height, + // buttons: 0, + // actionButton: 0 + // }) + // }) + + // _decoder.renderer.addEventListener('mouseup', e => { + // // client width and height 700 x 400 + // const react = _decoder.renderer.getBoundingClientRect() + + // // normalize to _decoder.renderer.width and height 1080 x 1920 + // const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width + // const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height + + // //console.log('mouse up at ' + x + ' ' + y) + // _client.controlMessageWriter?.injectTouch({ + // action: AndroidMotionEventAction.Up, + // pointerId: ScrcpyPointerId.Mouse, + // pointerX: x, + // pointerY: y, + // pressure: 1, + // screenWidth: _decoder.renderer.width, + // screenHeight: _decoder.renderer.height, + // buttons: 0, + // actionButton: 0 + // }) + // }) + + // _decoder.renderer.addEventListener('mousemove', e => { + // // client width and height 700 x 400 + // const react = _decoder.renderer.getBoundingClientRect() + + // // normalize to _decoder.renderer.width and height 1080 x 1920 + // const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width + // const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height + + // //console.log('mouse move at ' + x + ' ' + y) + // _client.controlMessageWriter?.injectTouch({ + // action: AndroidMotionEventAction.Move, + // pointerId: ScrcpyPointerId.Mouse, + // pointerX: x, + // pointerY: y, + // pressure: 1, + // screenWidth: _decoder.renderer.width, + // screenHeight: _decoder.renderer.height, + // buttons: 0, + // actionButton: 0 + // }) + // }) + // } + // } + + // setClient(_client) + // } + + // function disconnectAdb() { + // client?.close() + // adb?.close() + // setClient(undefined) + // setAdb(undefined) + // } + + // function scrcpyDisconnect() { + // decoder?.dispose() + // client?.close() + // if (decoder && screenRef.current) { + // screenRef.current.removeChild(decoder.renderer) + // } + // setClient(undefined) + // setDecoder(undefined) + // } + + // async function rebootDevice() { + // const res = await adb?.power.reboot() + // console.log('[rebootDevice] res: ', res) + // } + + // function goHome() { + // client?.controlMessageWriter?.injectKeyCode({ + // action: AndroidKeyEventAction.Up, + // keyCode: AndroidKeyCode.AndroidHome, + // metaState: 0, + // repeat: 0 + // }) + // } + + // function goBack() { + // client?.controlMessageWriter?.injectKeyCode({ + // action: AndroidKeyEventAction.Up, + // keyCode: AndroidKeyCode.AndroidBack, + // metaState: 0, + // repeat: 0 + // }) + // } + + return ( +
+
+ +
+ + + Scrcpy + Shell + + + + + + + + + +
+ ) +} + +export default AndroidPage diff --git a/client-electron/src/pages/android/components/scrcpy-tab.tsx b/client-electron/src/pages/android/components/scrcpy-tab.tsx new file mode 100644 index 0000000..0a71cd8 --- /dev/null +++ b/client-electron/src/pages/android/components/scrcpy-tab.tsx @@ -0,0 +1,113 @@ +import { Button } from '@/components/ui/button' +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' +import useAdb from '@/hooks/useAdb' +import { type AdbSubprocessProtocol } from '@yume-chan/adb' +import { WritableStream } from '@yume-chan/stream-extra' +import { useEffect, useRef, useState } from 'react' +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' + +import 'xterm/css/xterm.css' + +export const ScrcpyTab: React.FC = () => { + const adb = useAdb(state => state.adb) + + const logcatRef = useRef(null) + + const [process, setProcess] = useState() + + useEffect(() => { + const startTerminal = async () => { + if (logcatRef.current && adb) { + if (logcatRef.current.children.length > 0) { + // remove all children from the logcatRef + while (logcatRef.current.firstChild) { + logcatRef.current.removeChild(logcatRef.current.firstChild) + } + } + const terminal: Terminal = new Terminal() + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + + const process: AdbSubprocessProtocol = await adb.subprocess.shell('logcat') + process.stdout.pipeTo( + new WritableStream({ + write(chunk) { + terminal.write(chunk) + } + }) + ) + + terminal.options.disableStdin = true + terminal.options.theme = { + background: '#1e1e1e', + foreground: '#d4d4d4' + } + + terminal.open(logcatRef.current) + fitAddon.fit() + setProcess(process) + } + } + startTerminal() + + return () => { + logcatRef.current && logcatRef.current.firstChild && logcatRef.current.removeChild(logcatRef.current.firstChild) + process?.stderr.cancel() + process?.stdout.cancel() + process?.kill() + } + }, [logcatRef, adb]) + + return ( + + + Scrcpy + Stream and control your Android device from your computer + + +
+
+
+ + +
+
+
+ + + Control + + +
+ + + + +
+
+
+ + {/* logcat card */} + + + Logcat + + +
+
+
+ + +
+
+ + + +
+ ) +} diff --git a/client-electron/src/pages/android/components/shell-tab.tsx b/client-electron/src/pages/android/components/shell-tab.tsx new file mode 100644 index 0000000..c31bf73 --- /dev/null +++ b/client-electron/src/pages/android/components/shell-tab.tsx @@ -0,0 +1,100 @@ +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import useAdb from '@/hooks/useAdb' +import { encodeUtf8, type AdbSubprocessProtocol } from '@yume-chan/adb' +import { Consumable, WritableStream } from '@yume-chan/stream-extra' +import { useEffect, useRef, useState } from 'react' + +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' + +import 'xterm/css/xterm.css' + +export const ShellTab: React.FC = () => { + const adb = useAdb(state => state.adb) + + const shellRef = useRef(null) + + const [process, setProcess] = useState() + + useEffect(() => { + const startTerminal = async () => { + if (shellRef.current && adb) { + if (shellRef.current.children.length > 0) { + // remove all children from the shellRef + while (shellRef.current.firstChild) { + shellRef.current.removeChild(shellRef.current.firstChild) + } + } + const terminal: Terminal = new Terminal() + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + + const process: AdbSubprocessProtocol = await adb.subprocess.shell( + '/data/data/com.termux/files/usr/bin/telnet localhost 45515' + ) + process.stdout.pipeTo( + new WritableStream({ + write(chunk) { + terminal.write(chunk) + } + }) + ) + + const writer = process.stdin.getWriter() + terminal.onData(data => { + const buffer = encodeUtf8(data) + const consumable = new Consumable(buffer) + writer.write(consumable) + }) + + terminal.options.cursorBlink = true + terminal.options.theme = { + background: '#1e1e1e', + foreground: '#d4d4d4' + } + + terminal.open(shellRef.current) + fitAddon.fit() + setProcess(process) + } + } + startTerminal() + + return () => { + console.log('cleaning up shell') + shellRef.current && shellRef.current.firstChild && shellRef.current.removeChild(shellRef.current.firstChild) + process?.stderr.cancel() + process?.stdin.close() + process?.stdout.cancel() + process?.kill() + } + }, [shellRef, adb]) + + return ( + + + Shell + Access your device's shell using a terminal emulator + + +
+
+ +
+
+
+
+
+ ) +} diff --git a/client-electron/src/pages/android/components/tool-bar.tsx b/client-electron/src/pages/android/components/tool-bar.tsx new file mode 100644 index 0000000..96f74ca --- /dev/null +++ b/client-electron/src/pages/android/components/tool-bar.tsx @@ -0,0 +1,158 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/use-toast' +import useAdb from '@/hooks/useAdb' +import { Adb, AdbDaemonTransport } from '@yume-chan/adb' +import AdbWebCredentialStore from '@yume-chan/adb-credential-web' +import { ADB_DEFAULT_DEVICE_FILTER } from '@yume-chan/adb-daemon-webusb' +import { useState } from 'react' +import { useShallow } from 'zustand/react/shallow' + +export const ToolBar: React.FC = () => { + const { manager, device, adb, setDevice, setAdb } = useAdb( + useShallow(state => ({ + manager: state.manager, + device: state.device, + adb: state.adb, + setDevice: state.setDevice, + setAdb: state.setAdb + })) + ) + + const [name, setName] = useState('') + const [resolution, setResolution] = useState('') + const [version, setVersion] = useState('') + + async function createNewConnection() { + let selectedDevice + + if (!device) { + selectedDevice = await manager?.requestDevice({ + filters: [ + { + ...ADB_DEFAULT_DEVICE_FILTER, + serialNumber: 'd' + } + ] + }) + + if (!selectedDevice) { + return + } else { + setDevice(selectedDevice) + } + } else { + selectedDevice = device + } + + let connection + try { + connection = await selectedDevice.connect() + } catch (e) { + toast({ + duration: 5000, + variant: 'destructive', + title: 'Failed to connect to device', + description: (e as Error).message + }) + return + } + + const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore() + + const transport = await AdbDaemonTransport.authenticate({ + serial: selectedDevice.serial, + connection: connection, + credentialStore: credentialStore + }) + + const adb: Adb = new Adb(transport) + + const name = await adb.getProp('ro.product.model') + const version = await adb.getProp('ro.build.version.release') + + setName(name) + setResolution(resolution) + setVersion(version) + + setAdb(adb) + } + + function onDisconnect() { + device?.raw.forget() + setDevice(undefined) + + adb?.close() + setAdb(undefined) + } + + function onTerminate() { + adb?.close() + setAdb(undefined) + } + + return ( +
+ {adb ? ( +
+
    +
  • Name: {name}
  • +
  • Version: {version}
  • +
+
+ ) : ( +
+

No Device Connected

+
+ )} +
+ {adb ? ( + + ) : ( + + )} +
+
+ ) +} + +interface DisconnectConfirmDialogProps { + onDisconnect: () => void + onTerminate: () => void +} + +const DisconnectConfirmDialog: React.FC = ({ onDisconnect, onTerminate }) => { + return ( + + + + + + + Disconnect Device + + Do you want to also declaim device? if so press Disconnect else press Terminate + + + + + + + + + ) +}