├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── README.md ├── android-auto-proto ├── .gitignore ├── buf.gen.yaml ├── package.json ├── src │ ├── bluetooth.proto │ ├── bluetooth_interfaces.ts │ ├── protos.proto │ └── protos_interfaces.ts └── tsconfig.json ├── android-auto ├── eslint.config.mjs ├── package.json ├── src │ ├── AndroidAutoServer.ts │ ├── bluetooth │ │ ├── BluetoothMessage.ts │ │ ├── BluetoothMessageCodec.ts │ │ ├── BluetoothMessageType.ts │ │ └── index.ts │ ├── crypto │ │ ├── Cryptor.ts │ │ ├── index.ts │ │ └── keys.ts │ ├── index.ts │ ├── messenger │ │ ├── FrameCodec.ts │ │ ├── FrameData.ts │ │ ├── FrameHeader.ts │ │ ├── MessageAggregator.ts │ │ └── MessageCodec.ts │ ├── sensors │ │ ├── Sensor.ts │ │ └── index.ts │ ├── services │ │ ├── AVOutputService.ts │ │ ├── AVService.ts │ │ ├── AudioInputService.ts │ │ ├── AudioOutputService.ts │ │ ├── ControlService.ts │ │ ├── InputService.ts │ │ ├── MediaStatusService.ts │ │ ├── NavigationStatusService.ts │ │ ├── Pinger.ts │ │ ├── SensorService.ts │ │ ├── Service.ts │ │ ├── VideoResolutionUtils.ts │ │ ├── VideoService.ts │ │ └── index.ts │ ├── transport │ │ ├── Device.ts │ │ ├── DeviceHandler.ts │ │ └── index.ts │ └── utils │ │ ├── BufferReader.ts │ │ ├── BufferWriter.ts │ │ ├── buffer-utils.ts │ │ ├── buffer.ts │ │ ├── index.ts │ │ └── time.ts └── tsconfig.json ├── common-ipc ├── eslint.config.mjs ├── package.json ├── src │ ├── common.ts │ ├── main.ts │ └── renderer.ts └── tsconfig.json ├── config-loader ├── eslint.config.mjs ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── config.default.json5 ├── electron-ipc-preload ├── eslint.config.mjs ├── package.json └── preload-run.mjs ├── electron-ipc ├── eslint.config.mjs ├── package.json ├── src │ ├── common.ts │ ├── main.ts │ ├── preload.ts │ └── renderer.ts └── tsconfig.json ├── electron ├── .gitignore ├── eslint.config.mjs ├── package.json ├── src │ ├── ElectronWindowBuilder.ts │ └── main.ts └── tsconfig.json ├── eslint-config ├── base-js.mjs ├── base-ts.mjs ├── common-ts.mjs ├── node-js.mjs ├── node-ts.mjs └── package.json ├── logging ├── eslint.config.mjs ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── node-common ├── .gitignore ├── eslint.config.mjs ├── package.json ├── src │ ├── NodeAndroidAutoServer.ts │ ├── NodeAndroidAutoServerBuilder.ts │ ├── codec │ │ ├── codec.ts │ │ ├── h264.ts │ │ ├── h265.ts │ │ └── index.ts │ ├── config.ts │ ├── crypto │ │ ├── NodeCryptor.ts │ │ ├── OpenSSLCryptor.ts │ │ ├── openssl.ts │ │ └── openssl_bindings.ts │ ├── index.ts │ ├── ipc.ts │ ├── sensors │ │ ├── DummyDrivingStatusSensor.ts │ │ └── DummyNightDataSensor.ts │ ├── services │ │ ├── NodeAudioInputService.ts │ │ ├── NodeAudioOutputService.ts │ │ ├── NodeBrightnessService.ts │ │ ├── NodeDdcBrightnessService.ts │ │ ├── NodeInputService.ts │ │ ├── NodeMediaStatusService.ts │ │ ├── NodeNavigationService.ts │ │ ├── NodeRtAudioInputService.ts │ │ ├── NodeRtAudioOutputService.ts │ │ ├── NodeSensorService.ts │ │ └── NodeVideoService.ts │ ├── transport │ │ ├── DuplexTransport.ts │ │ ├── bluetooth │ │ │ ├── AndroidAutoProfile.ts │ │ │ ├── BluetoothDevice.ts │ │ │ ├── BluetoothDeviceHandler.ts │ │ │ ├── BluetoothDeviceTcpConnector.ts │ │ │ ├── BluetoothDeviceWifiConnector.ts │ │ │ ├── BluetoothProfile.ts │ │ │ └── BluetoothProfileHandler.ts │ │ ├── tcp │ │ │ ├── TcpDevice.ts │ │ │ ├── TcpDeviceHandler.ts │ │ │ └── tcp.ts │ │ └── usb │ │ │ ├── UsbDevice.ts │ │ │ └── UsbDeviceHandler.ts │ └── utils.ts └── tsconfig.json ├── node ├── .gitignore ├── eslint.config.mjs ├── package.json ├── src │ ├── index.ts │ └── main.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── socket-ipc ├── eslint.config.mjs ├── package.json ├── src │ ├── common.ts │ ├── main.ts │ └── renderer.ts └── tsconfig.json ├── tsconfig.json ├── tsconfig ├── tsconfig.node.json └── tsconfig.web.json └── web ├── .gitignore ├── env.d.ts ├── eslint.config.mjs ├── index.html ├── package.json ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ ├── logo.svg │ └── main.css ├── codec │ ├── Canvas2DRenderer.ts │ ├── DecoderWorker.ts │ ├── DecoderWorkerMessages.ts │ ├── DecoderWorkerWrapper.ts │ ├── Renderer.ts │ ├── WebGLRenderer.ts │ └── m4.ts ├── components │ ├── AppBar.vue │ ├── Device.vue │ ├── DeviceNotConnected.vue │ ├── DeviceSelector.vue │ ├── Video.vue │ ├── app-bar-icons │ │ ├── AppBarBrightness.vue │ │ ├── AppBarIcon.vue │ │ ├── AppBarKeyIcon.vue │ │ ├── AppBarRouteIcon.vue │ │ ├── AppBarSpacer.vue │ │ └── AppBarVolume.vue │ ├── home-tiles │ │ ├── MediaStatus.vue │ │ └── MiniVideo.vue │ └── views │ │ ├── ConnectionsView.vue │ │ ├── HomeView.vue │ │ └── VideoView.vue ├── config.ts ├── decoders.ts ├── ipc.ts ├── main.ts ├── router │ └── index.ts ├── stores │ ├── brightness-store.ts │ ├── device-store.ts │ ├── media-status-store.ts │ ├── video-store.ts │ └── volume-store.ts ├── theme.ts ├── utils │ └── objectId.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | config.json5 4 | web-auto.log 5 | 6 | # Certificate 7 | cert.key 8 | cert.crt 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "tabWidth": 4, 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[vue]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[html]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "typescript.preferences.importModuleSpecifierEnding": "js" 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebAuto 2 | 3 | ## Dependencies 4 | 5 | ### Ubuntu 6 | 7 | `sudo apt install protobuf-compiler` 8 | 9 | If using `TcpDeviceHandler`: 10 | 11 | `sudo apt install nmap` 12 | 13 | #### NVM 14 | 15 | To get the latest Node version, use [nvm](https://github.com/nvm-sh/nvm). 16 | 17 | ## Installation 18 | 19 | 1. `git clone https://github.com/Demon000/web-auto` 20 | 2. `cd web-auto` 21 | 3. `cp config.default.json5 config.json5` 22 | 4. Open the `config.json5` file and configure it 23 | 5. Generate a self signed certificate. 24 | `openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.crt` 25 | 6. `npm install` 26 | 7. `npm run build` 27 | 28 | ### Electron 29 | 30 | 1. `npm run prepare-electron` 31 | 2. `npm run start-electron` 32 | 33 | ### Node 34 | 35 | #### Server 36 | 37 | 1. `npm run prepare-node` 38 | (not necessary unless `prepare-electron` has been run previously) 39 | 2. `npm run start-node` 40 | 41 | #### Web 42 | 43 | 1. `npm run start-web` 44 | 45 | ## Features 46 | 47 | - Connection via TCP (Head unit server enabled on phone) 48 | - Connection via USB 49 | - Connection via Bluetooth 50 | - Android Auto video 51 | - Instrument cluster video 52 | - Video decode (H264 & H265) 53 | - Audio input 54 | - Audio output 55 | - Media status 56 | - Navigation status (WIP) 57 | - Picture-in-picture video 58 | - Assistant key 59 | - Mouse support for interacting with the video 60 | 61 | ## Troubleshooting 62 | 63 | 1. `LIBUSB_ERROR_ACCESS` error on server start-up 64 | 65 | Add the following lines to `/etc/udev/rules.d/50-usb.rules`: 66 | 67 | ``` 68 | SUBSYSTEM=="usb", ATTR{idVendor}=="*", ATTR{idProduct}=="*", MODE="0660", GROUP="plugdev" 69 | ``` 70 | -------------------------------------------------------------------------------- /android-auto-proto/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/protos_pb.ts 3 | src/bluetooth_pb.ts 4 | -------------------------------------------------------------------------------- /android-auto-proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | plugins: 3 | - plugin: es 4 | opt: target=ts 5 | out: ./ 6 | -------------------------------------------------------------------------------- /android-auto-proto/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/android-auto-proto", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "types": "./dist/esm/protos_pb.d.ts", 8 | "exports": { 9 | ".": "./dist/esm/protos_pb.js", 10 | "./interfaces.js": "./dist/esm/protos_interfaces.js", 11 | "./bluetooth.js": "./dist/esm/bluetooth_pb.js", 12 | "./bluetooth_interfaces.js": "./dist/esm/bluetooth_interfaces.js" 13 | }, 14 | "scripts": { 15 | "build": "npm run build-protos && npm run build-bluetooth-protos && tsc", 16 | "build-protos": "buf generate --path src/protos.proto", 17 | "build-bluetooth-protos": "buf generate --path src/bluetooth.proto", 18 | "clean": "tspc --build --clean && rm ./src/protos_pb.ts && rm ./src/bluetooth_pb.ts" 19 | }, 20 | "devDependencies": { 21 | "@bufbuild/protoc-gen-es": "^1.10.0" 22 | }, 23 | "dependencies": { 24 | "@bufbuild/buf": "^1.32.2", 25 | "@bufbuild/protobuf": "^1.10.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /android-auto-proto/src/bluetooth.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | message SocketInfoRequest { 4 | required string ip_address = 1; 5 | optional uint32 port = 2; 6 | } 7 | 8 | enum SocketInfoResponseStatus { 9 | STATUS_UNSOLICITED_MESSAGE = 1; 10 | STATUS_SUCCESS = 0; 11 | STATUS_NO_COMPATIBLE_VERSION = -1; 12 | STATUS_WIFI_INACCESSIBLE_CHANNEL = -2; 13 | STATUS_WIFI_INCORRECT_CREDENTIALS = -3; 14 | STATUS_PROJECTION_ALREADY_STARTED = -4; 15 | STATUS_WIFI_DISABLED = -5; 16 | STATUS_WIFI_NOT_YET_STARTED = -6; 17 | STATUS_INVALID_HOST = -7; 18 | STATUS_NO_SUPPORTED_WIFI_CHANNELS = -8; 19 | STATUS_INSTRUCT_USER_TO_CHECK_THE_PHONE = -9; 20 | STATUS_PHONE_WIFI_DISABLED = -10; 21 | STATUS_WIFI_NETWORK_UNAVAILABLE = -11; 22 | } 23 | 24 | message SocketInfoResponse { 25 | optional string ip_address = 1; 26 | optional int32 port = 2; 27 | 28 | required SocketInfoResponseStatus status = 3; 29 | } 30 | 31 | message ConnectStatus { required SocketInfoResponseStatus status = 1; } 32 | 33 | enum SecurityMode { 34 | UNKNOWN_SECURITY_MODE = 0; 35 | OPEN = 1; 36 | WEP_64 = 2; 37 | WEP_128 = 3; 38 | WPA_PERSONAL = 4; 39 | WPA2_PERSONAL = 8; 40 | WPA_WPA2_PERSONAL = 12; 41 | WPA_ENTERPRISE = 20; 42 | WPA2_ENTERPRISE = 24; 43 | WPA_WPA2_ENTERPRISE = 28; 44 | } 45 | 46 | enum AccessPointType { 47 | STATIC = 0; 48 | DYNAMIC = 1; 49 | } 50 | 51 | message NetworkInfo { 52 | required string ssid = 1; 53 | required string psk = 2; 54 | required string mac_addr = 3; 55 | required SecurityMode security_mode = 4; 56 | required AccessPointType ap_type = 5; 57 | } 58 | -------------------------------------------------------------------------------- /android-auto-proto/src/bluetooth_interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PartialMessage } from '@bufbuild/protobuf'; 2 | import type { NetworkInfo, SocketInfoRequest } from './bluetooth_pb.js'; 3 | 4 | export type INetworkInfo = PartialMessage; 5 | export type ISocketInfoRequest = PartialMessage; 6 | -------------------------------------------------------------------------------- /android-auto-proto/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /android-auto/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /android-auto/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/android-auto", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "exports": { 8 | "import": "./dist/esm/index.js", 9 | "types": "./dist/esm/index.d.ts" 10 | }, 11 | "scripts": { 12 | "build": "tspc", 13 | "clean": "tspc --build --clean", 14 | "lint": "eslint --fix ." 15 | }, 16 | "dependencies": { 17 | "async-mutex": "^0.5.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /android-auto/src/bluetooth/BluetoothMessage.ts: -------------------------------------------------------------------------------- 1 | import { BluetoothMessageType } from './BluetoothMessageType.js'; 2 | 3 | export class BluetoothMessage { 4 | public constructor( 5 | public type: BluetoothMessageType, 6 | public payload: Uint8Array, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /android-auto/src/bluetooth/BluetoothMessageCodec.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from '@web-auto/logging'; 2 | 3 | import { BufferReader, BufferWriter } from '../utils/buffer.js'; 4 | import { BluetoothMessage } from './BluetoothMessage.js'; 5 | 6 | export class BluetoothMessageCodec { 7 | protected logger = getLogger(this.constructor.name); 8 | 9 | public encodeMessage(message: BluetoothMessage): Uint8Array { 10 | const buffer = BufferWriter.fromSize( 11 | 2 + 2 + message.payload.byteLength, 12 | ); 13 | 14 | buffer.appendUint16BE(message.payload.byteLength); 15 | buffer.appendUint16BE(message.type); 16 | buffer.appendBuffer(message.payload); 17 | 18 | return buffer.data; 19 | } 20 | 21 | public decodeMessage(buffer: BufferReader): BluetoothMessage { 22 | const size = buffer.readUint16BE(); 23 | const type = buffer.readUint16BE(); 24 | const payload = buffer.readBuffer(size); 25 | return new BluetoothMessage(type, payload); 26 | } 27 | 28 | public decodeBuffer(data: Uint8Array): BluetoothMessage[] { 29 | const buffer = BufferReader.fromBuffer(data); 30 | 31 | const messages = []; 32 | while (buffer.readBufferSize() !== 0) { 33 | const message = this.decodeMessage(buffer); 34 | messages.push(message); 35 | } 36 | return messages; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /android-auto/src/bluetooth/BluetoothMessageType.ts: -------------------------------------------------------------------------------- 1 | export enum BluetoothMessageType { 2 | SOCKET_INFO_REQUEST = 1, 3 | NETWORK_INFO_REQUEST = 2, 4 | NETWORK_INFO_RESPONSE = 3, 5 | 6 | VERSION_REQUEST = 4, 7 | VERSION_RESPONSE = 5, 8 | CONNECT_STATUS = 6, 9 | 10 | SOCKET_INFO_RESPONSE = 7, 11 | } 12 | -------------------------------------------------------------------------------- /android-auto/src/bluetooth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BluetoothMessage.js'; 2 | export * from './BluetoothMessageCodec.js'; 3 | export * from './BluetoothMessageType.js'; 4 | -------------------------------------------------------------------------------- /android-auto/src/crypto/Cryptor.ts: -------------------------------------------------------------------------------- 1 | export abstract class Cryptor { 2 | public constructor( 3 | protected certificateBuffer: Buffer, 4 | protected privateKeyBuffer: Buffer, 5 | ) {} 6 | 7 | public abstract start(): void; 8 | public abstract stop(): void; 9 | 10 | public abstract isHandshakeComplete(): boolean; 11 | public abstract readHandshakeBuffer(): Promise; 12 | public abstract writeHandshakeBuffer(buffer: Uint8Array): Promise; 13 | 14 | public abstract encrypt(buffer: Uint8Array): Promise; 15 | public abstract decrypt(buffer: Uint8Array): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /android-auto/src/crypto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Cryptor.js'; 2 | -------------------------------------------------------------------------------- /android-auto/src/crypto/keys.ts: -------------------------------------------------------------------------------- 1 | export const ANDROID_AUTO_PRIVATE_KEY = 2 | Buffer.from(`-----BEGIN RSA PRIVATE KEY----- 3 | MIIEowIBAAKCAQEAz3XWY2dR/H5Ym3G6TToY7uRdFb+BdRU1AGRsAVmZV1U28ugR 4 | A22GLZfxYI7Bfqfqgw/FTYwYme+Jw/fqQGp8eF9DYW+qV/tiOOGAEeHSWopKFU/E 5 | i91q0GNVDvprKbkfcamSKAsaSZ7KJWhU7yhzdwnVs73rAVGaTuQlthwSNDJqQ4M8 6 | O7hCBZRPCnVnwOfjUBEwbk4WHqi0hMtcHOP1ESRQ+TF9LTd0Fcavn8siq9KyfGJv 7 | WEnjvGdgldE5SwHLd7pP59hkQTNlrvLu0WKgchDwb9POI+a8XOZClpYwaJh+giBf 8 | xahHcYPV2Jhb7AFplNb7hIVRkmZEqCpNsw0WQwIDAQABAoIBAB2u7ZLheKCY71Km 9 | bhKYqnKb6BmxgfNfqmq4858p07/kKG2O+Mg1xooFgHrhUhwuKGbCPee/kNGNrXeF 10 | pFW9JrwOXVS2pnfaNw6ObUWhuvhLaxgrhqLAdoUEgWoYOHcKzs3zhj8Gf6di+edq 11 | SyTA8+xnUtVZ6iMRKvP4vtCUqaIgBnXdmQbGINP+/4Qhb5R7XzMt/xPe6uMyAIyC 12 | y5Fm9HnvekaepaeFEf3bh4NV1iN/R8px6cFc6ELYxIZc/4Xbm91WGqSdB0iSriaZ 13 | TjgrmaFjSO40tkCaxI9N6DGzJpmpnMn07ifhl2VjnGOYwtyuh6MKEnyLqTrTg9x0 14 | i3mMwskCgYEA9IyljPRerXxHUAJt+cKOayuXyNt80q9PIcGbyRNvn7qIY6tr5ut+ 15 | ZbaFgfgHdSJ/4nICRq02HpeDJ8oj9BmhTAhcX6c1irH5ICjRlt40qbPwemIcpybt 16 | mb+DoNYbI8O4dUNGH9IPfGK8dRpOok2m+ftfk94GmykWbZF5CnOKIp8CgYEA2Syc 17 | 5xlKB5Qk2ZkwXIzxbzozSfunHhWWdg4lAbyInwa6Y5GB35UNdNWI8TAKZsN2fKvX 18 | RFgCjbPreUbREJaM3oZ92o5X4nFxgjvAE1tyRqcPVbdKbYZgtcqqJX06sW/g3r/3 19 | RH0XPj2SgJIHew9sMzjGWDViMHXLmntI8rVA7d0CgYBOr36JFwvrqERN0ypNpbMr 20 | epBRGYZVSAEfLGuSzEUrUNqXr019tKIr2gmlIwhLQTmCxApFcXArcbbKs7jTzvde 21 | PoZyZJvOr6soFNozP/YT8Ijc5/quMdFbmgqhUqLS5CPS3z2N+YnwDNj0mO1aPcAP 22 | STmcm2DmxdaolJksqrZ0owKBgQCD0KJDWoQmaXKcaHCEHEAGhMrQot/iULQMX7Vy 23 | gl5iN5E2EgFEFZIfUeRWkBQgH49xSFPWdZzHKWdJKwSGDvrdrcABwdfx520/4MhK 24 | d3y7CXczTZbtN1zHuoTfUE0pmYBhcx7AATT0YCblxrynosrHpDQvIefBBh5YW3AB 25 | cKZCOQKBgEM/ixzI/OVSZ0Py2g+XV8+uGQyC5XjQ6cxkVTX3Gs0ZXbemgUOnX8co 26 | eCXS4VrhEf4/HYMWP7GB5MFUOEVtlLiLM05ruUL7CrphdfgayDXVcTPfk75lLhmu 27 | KAwp3tIHPoJOQiKNQ3/qks5km/9dujUGU2ARiU3qmxLMdgegFz8e 28 | -----END RSA PRIVATE KEY-----`); 29 | 30 | export const ANDROID_AUTO_CERTIFICATE = Buffer.from(`-----BEGIN CERTIFICATE----- 31 | MIIDKjCCAhICARswDQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCVVMxEzARBgNV 32 | BAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxHzAdBgNVBAoM 33 | Fkdvb2dsZSBBdXRvbW90aXZlIExpbmswJhcRMTQwNzA0MDAwMDAwLTA3MDAXETQ1 34 | MDQyOTE0MjgzOC0wNzAwMFMxCzAJBgNVBAYTAkpQMQ4wDAYDVQQIDAVUb2t5bzER 35 | MA8GA1UEBwwISGFjaGlvamkxFDASBgNVBAoMC0pWQyBLZW53b29kMQswCQYDVQQL 36 | DAIwMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM911mNnUfx+WJtx 37 | uk06GO7kXRW/gXUVNQBkbAFZmVdVNvLoEQNthi2X8WCOwX6n6oMPxU2MGJnvicP3 38 | 6kBqfHhfQ2Fvqlf7YjjhgBHh0lqKShVPxIvdatBjVQ76aym5H3GpkigLGkmeyiVo 39 | VO8oc3cJ1bO96wFRmk7kJbYcEjQyakODPDu4QgWUTwp1Z8Dn41ARMG5OFh6otITL 40 | XBzj9REkUPkxfS03dBXGr5/LIqvSsnxib1hJ47xnYJXROUsBy3e6T+fYZEEzZa7y 41 | 7tFioHIQ8G/TziPmvFzmQpaWMGiYfoIgX8WoR3GD1diYW+wBaZTW+4SFUZJmRKgq 42 | TbMNFkMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAsGdH5VFn78WsBElMXaMziqFC 43 | zmilkvr85/QpGCIztI0FdF6xyMBJk/gYs2thwvF+tCCpXoO8mjgJuvJZlwr6fHzK 44 | Ox5hNUb06AeMtsUzUfFjSZXKrSR+XmclVd+Z6/ie33VhGePOPTKYmJ/PPfTT9wvT 45 | 93qswcxhA+oX5yqLbU3uDPF1ZnJaEeD/YN45K/4eEA4/0SDXaWW14OScdS2LV0Bc 46 | YmsbkPVNYZn37FlY7e2Z4FUphh0A7yME2Eh/e57QxWrJ1wubdzGnX8mrABc67ADU 47 | U5r9tlTRqMs7FGOk6QS2Cxp4pqeVQsrPts4OEwyPUyb3LfFNo3+sP111D9zEow== 48 | -----END CERTIFICATE-----`); 49 | -------------------------------------------------------------------------------- /android-auto/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AndroidAutoServer.js'; 2 | export * from './bluetooth/index.js'; 3 | export * from './crypto/index.js'; 4 | export * from './sensors/index.js'; 5 | export * from './services/index.js'; 6 | export * from './transport/index.js'; 7 | export * from './utils/index.js'; 8 | -------------------------------------------------------------------------------- /android-auto/src/messenger/FrameData.ts: -------------------------------------------------------------------------------- 1 | import { type FrameHeader } from './FrameHeader.js'; 2 | 3 | export interface FrameData { 4 | frameHeader: FrameHeader; 5 | payload: Uint8Array; 6 | totalSize: number; 7 | } 8 | -------------------------------------------------------------------------------- /android-auto/src/messenger/FrameHeader.ts: -------------------------------------------------------------------------------- 1 | export enum FrameHeaderFlags { 2 | NONE = 0, 3 | FIRST = 1 << 0, 4 | LAST = 1 << 1, 5 | CONTROL = 1 << 2, 6 | ENCRYPTED = 1 << 3, 7 | } 8 | 9 | export type FrameHeader = { 10 | serviceId: number; 11 | flags: FrameHeaderFlags; 12 | payloadSize: number; 13 | }; 14 | -------------------------------------------------------------------------------- /android-auto/src/messenger/MessageCodec.ts: -------------------------------------------------------------------------------- 1 | import { Message as ProtoMessage } from '@bufbuild/protobuf'; 2 | 3 | import { BufferReader } from '../utils/BufferReader.js'; 4 | import { BufferWriter } from '../utils/BufferWriter.js'; 5 | 6 | export class MessageCodec { 7 | public encodeMessage(messageId: number, message: ProtoMessage): Uint8Array { 8 | const payload = message.toBinary(); 9 | return this.encodePayload(messageId, payload); 10 | } 11 | 12 | public encodePayload(messageId: number, payload: Uint8Array): Uint8Array { 13 | const writer = BufferWriter.fromSize(2 + payload.byteLength); 14 | writer.appendUint16BE(messageId); 15 | writer.appendBuffer(payload); 16 | return writer.data; 17 | } 18 | 19 | public decodeMessage(totalPayload: Uint8Array): [number, Uint8Array] { 20 | const reader = BufferReader.fromBuffer(totalPayload); 21 | const messageId = reader.readUint16BE(); 22 | const payload = reader.readBuffer(); 23 | return [messageId, payload]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android-auto/src/sensors/Sensor.ts: -------------------------------------------------------------------------------- 1 | import { SensorBatch, SensorType } from '@web-auto/android-auto-proto'; 2 | 3 | export interface SensorEvents { 4 | onData: (data: SensorBatch) => void; 5 | } 6 | 7 | export abstract class Sensor { 8 | public constructor( 9 | public readonly type: SensorType, 10 | protected events: SensorEvents, 11 | ) {} 12 | 13 | public start(): void {} 14 | 15 | public stop(): void {} 16 | 17 | public abstract emit(): void; 18 | } 19 | -------------------------------------------------------------------------------- /android-auto/src/sensors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Sensor.js'; 2 | -------------------------------------------------------------------------------- /android-auto/src/services/AVOutputService.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { 4 | Ack, 5 | Config, 6 | Config_Status, 7 | MediaMessageId, 8 | Start, 9 | Stop, 10 | } from '@web-auto/android-auto-proto'; 11 | 12 | import { BufferReader } from '../utils/buffer.js'; 13 | import { AVService } from './AVService.js'; 14 | import { type ServiceEvents } from './Service.js'; 15 | 16 | export interface AVOutputServiceConfig { 17 | priorities: number[]; 18 | } 19 | 20 | export abstract class AVOutputService extends AVService { 21 | protected configurationIndex: number | undefined; 22 | protected sessionAckBuffer: Uint8Array | undefined; 23 | 24 | public constructor( 25 | private _config: AVOutputServiceConfig, 26 | events: ServiceEvents, 27 | ) { 28 | super(events); 29 | 30 | this.addMessageCallback( 31 | MediaMessageId.MEDIA_MESSAGE_DATA, 32 | this.onAvMediaWithTimestampIndication.bind(this), 33 | ); 34 | this.addMessageCallback( 35 | MediaMessageId.MEDIA_MESSAGE_CODEC_CONFIG, 36 | this.onAvMediaIndication.bind(this), 37 | ); 38 | this.addMessageCallback( 39 | MediaMessageId.MEDIA_MESSAGE_START, 40 | this.onStartIndication.bind(this), 41 | Start, 42 | ); 43 | this.addMessageCallback( 44 | MediaMessageId.MEDIA_MESSAGE_STOP, 45 | this.onStopIndication.bind(this), 46 | Stop, 47 | ); 48 | } 49 | 50 | protected override getConfig(status: boolean): Config { 51 | return new Config({ 52 | maxUnacked: 1, 53 | status: status ? Config_Status.READY : Config_Status.WAIT, 54 | configurationIndices: this._config.priorities, 55 | }); 56 | } 57 | 58 | protected onAvMediaIndication(buffer: Uint8Array): void { 59 | this.handleData(buffer); 60 | 61 | this.sendAvMediaAckIndication(); 62 | } 63 | 64 | protected onAvMediaWithTimestampIndication(payload: Uint8Array): void { 65 | const reader = BufferReader.fromBuffer(payload); 66 | const timestamp = reader.readUint64BE(); 67 | const buffer = reader.readBuffer(); 68 | 69 | this.handleData(buffer, timestamp); 70 | 71 | this.sendAvMediaAckIndication(); 72 | } 73 | 74 | protected onStopIndication(data: Stop): void { 75 | try { 76 | this.channelStop(); 77 | } catch (err) { 78 | this.logger.error('Failed to stop channel', { 79 | data, 80 | err, 81 | }); 82 | } 83 | } 84 | 85 | protected onStartIndication(data: Start): void { 86 | assert(data.sessionId !== undefined); 87 | this.session = data.sessionId; 88 | this.sessionAckBuffer = new Ack({ 89 | sessionId: this.session, 90 | ack: 1, 91 | }).toBinary(); 92 | 93 | try { 94 | this.channelStart(data); 95 | } catch (err) { 96 | this.logger.error('Failed to start channel', { 97 | data, 98 | err, 99 | }); 100 | } 101 | } 102 | 103 | public get channelStarted(): boolean { 104 | return this.configurationIndex !== undefined; 105 | } 106 | 107 | protected channelStart(data: Start): void { 108 | this.configurationIndex = data.configurationIndex; 109 | } 110 | 111 | protected channelStop(): void { 112 | this.configurationIndex = undefined; 113 | } 114 | 115 | protected abstract handleData(buffer: Uint8Array, timestamp?: bigint): void; 116 | 117 | protected sendAvMediaAckIndication(): void { 118 | assert(this.sessionAckBuffer !== undefined); 119 | this.sendPayloadWithId( 120 | MediaMessageId.MEDIA_MESSAGE_ACK, 121 | this.sessionAckBuffer, 122 | 'Ack', 123 | true, 124 | false, 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /android-auto/src/services/AVService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | Config_Status, 4 | MediaMessageId, 5 | Setup, 6 | } from '@web-auto/android-auto-proto'; 7 | 8 | import { Service, type ServiceEvents } from './Service.js'; 9 | 10 | export abstract class AVService extends Service { 11 | protected session: number | undefined; 12 | 13 | public constructor(events: ServiceEvents) { 14 | super(events); 15 | 16 | this.addMessageCallback( 17 | MediaMessageId.MEDIA_MESSAGE_SETUP, 18 | this.onSetupRequest.bind(this), 19 | Setup, 20 | ); 21 | } 22 | 23 | protected async onSetupRequest(data: Setup): Promise { 24 | let status = false; 25 | 26 | try { 27 | await this.setup(data); 28 | status = true; 29 | } catch (err) { 30 | this.logger.error('Failed to setup', { 31 | data, 32 | err, 33 | }); 34 | return; 35 | } 36 | 37 | this.sendSetupResponse(status); 38 | } 39 | 40 | protected async setup(_data: Setup): Promise {} 41 | protected afterSetup(): void {} 42 | 43 | protected getConfig(status: boolean): Config { 44 | return new Config({ 45 | maxUnacked: 1, 46 | status: status ? Config_Status.READY : Config_Status.WAIT, 47 | }); 48 | } 49 | 50 | protected sendSetupResponse(status: boolean): void { 51 | const data = this.getConfig(status); 52 | 53 | this.sendEncryptedSpecificMessage( 54 | MediaMessageId.MEDIA_MESSAGE_CONFIG, 55 | data, 56 | ); 57 | 58 | if (!status) { 59 | return; 60 | } 61 | 62 | this.afterSetup(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /android-auto/src/services/AudioInputService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Ack, 3 | MediaCodecType, 4 | MediaMessageId, 5 | MediaSourceService, 6 | MicrophoneRequest, 7 | MicrophoneResponse, 8 | Service, 9 | } from '@web-auto/android-auto-proto'; 10 | 11 | import { BufferWriter } from '../utils/buffer.js'; 12 | import { microsecondsTime } from '../utils/time.js'; 13 | import { AVService } from './AVService.js'; 14 | import { type ServiceEvents } from './Service.js'; 15 | 16 | export interface AudioInputServiceConfig { 17 | channelCount: number; 18 | sampleRate: number; 19 | numberOfBits: number; 20 | } 21 | 22 | export abstract class AudioInputService extends AVService { 23 | public constructor( 24 | protected config: AudioInputServiceConfig, 25 | events: ServiceEvents, 26 | ) { 27 | super(events); 28 | 29 | this.addMessageCallback( 30 | MediaMessageId.MEDIA_MESSAGE_MICROPHONE_REQUEST, 31 | this.onInputOpenRequest.bind(this), 32 | MicrophoneRequest, 33 | ); 34 | this.addMessageCallback( 35 | MediaMessageId.MEDIA_MESSAGE_ACK, 36 | this.onAckIndication.bind(this), 37 | Ack, 38 | ); 39 | } 40 | 41 | protected abstract inputOpen(data: MicrophoneRequest): void; 42 | 43 | protected onInputOpenRequest(data: MicrophoneRequest): void { 44 | try { 45 | this.inputOpen(data); 46 | } catch (err) { 47 | this.logger.error('Failed to open input', { 48 | data, 49 | err, 50 | }); 51 | return; 52 | } 53 | 54 | this.sendInputOpenResponse(); 55 | } 56 | 57 | protected onAckIndication(_data: Ack): void {} 58 | 59 | protected sendInputOpenResponse(): void { 60 | if (this.session === undefined) { 61 | this.logger.error( 62 | 'Cannot send input open response because session id is undefined', 63 | ); 64 | return; 65 | } 66 | 67 | const data = new MicrophoneResponse({ 68 | status: 0, 69 | sessionId: this.session, 70 | }); 71 | 72 | this.sendEncryptedSpecificMessage( 73 | MediaMessageId.MEDIA_MESSAGE_MICROPHONE_RESPONSE, 74 | data, 75 | ); 76 | } 77 | 78 | protected sendAvMediaWithTimestampIndication(buffer: Uint8Array): void { 79 | const writer = BufferWriter.fromSize(8 + buffer.byteLength); 80 | const timestamp = microsecondsTime(); 81 | 82 | writer.appendUint64BE(timestamp); 83 | writer.appendBuffer(buffer); 84 | 85 | this.sendPayloadWithId( 86 | MediaMessageId.MEDIA_MESSAGE_DATA, 87 | writer.data, 88 | 'Data', 89 | true, 90 | false, 91 | ); 92 | } 93 | 94 | protected override fillChannelDescriptor(channelDescriptor: Service): void { 95 | channelDescriptor.mediaSourceService = new MediaSourceService({ 96 | availableType: MediaCodecType.MEDIA_CODEC_AUDIO_PCM, 97 | audioConfig: { 98 | samplingRate: this.config.sampleRate, 99 | numberOfChannels: this.config.channelCount, 100 | numberOfBits: this.config.numberOfBits, 101 | }, 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /android-auto/src/services/AudioOutputService.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { 4 | AudioStreamType, 5 | MediaCodecType, 6 | MediaSinkService, 7 | type Service, 8 | } from '@web-auto/android-auto-proto'; 9 | import { type IAudioConfiguration } from '@web-auto/android-auto-proto/interfaces.js'; 10 | 11 | import { AVOutputService } from './AVOutputService.js'; 12 | import { type ServiceEvents } from './Service.js'; 13 | 14 | export interface AudioOutputServiceConfig { 15 | audioType: AudioStreamType; 16 | configs: IAudioConfiguration[]; 17 | } 18 | 19 | export abstract class AudioOutputService extends AVOutputService { 20 | public constructor( 21 | protected config: AudioOutputServiceConfig, 22 | events: ServiceEvents, 23 | ) { 24 | super( 25 | { 26 | priorities: Array.from(config.configs.keys()), 27 | }, 28 | events, 29 | ); 30 | } 31 | 32 | protected channelConfig(): IAudioConfiguration { 33 | let index = this.configurationIndex; 34 | if (index === undefined && this.config.configs.length === 1) { 35 | index = 0; 36 | } 37 | 38 | assert(index !== undefined); 39 | const config = this.config.configs[index]; 40 | assert(config !== undefined); 41 | return config; 42 | } 43 | 44 | protected channelCount(): number { 45 | const channelCount = this.channelConfig().numberOfChannels; 46 | assert(channelCount !== undefined); 47 | return channelCount; 48 | } 49 | 50 | protected sampleRate(): number { 51 | const sampleRate = this.channelConfig().samplingRate; 52 | assert(sampleRate !== undefined); 53 | return sampleRate; 54 | } 55 | 56 | protected numberOfBits(): number { 57 | const numberOfBits = this.channelConfig().numberOfBits; 58 | assert(numberOfBits !== undefined); 59 | return numberOfBits; 60 | } 61 | 62 | protected override fillChannelDescriptor(channelDescriptor: Service): void { 63 | channelDescriptor.mediaSinkService = new MediaSinkService({ 64 | availableType: MediaCodecType.MEDIA_CODEC_AUDIO_PCM, 65 | audioType: this.config.audioType, 66 | audioConfigs: this.config.configs, 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /android-auto/src/services/InputService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InputMessageId, 3 | InputReport, 4 | KeyBindingRequest, 5 | KeyBindingResponse, 6 | KeyEvent, 7 | TouchEvent, 8 | } from '@web-auto/android-auto-proto'; 9 | 10 | import { microsecondsTime } from '../utils/time.js'; 11 | import { Service, type ServiceEvents } from './Service.js'; 12 | 13 | export abstract class InputService extends Service { 14 | public constructor(events: ServiceEvents) { 15 | super(events); 16 | 17 | this.addMessageCallback( 18 | InputMessageId.INPUT_MESSAGE_KEY_BINDING_REQUEST, 19 | this.onBindingRequest.bind(this), 20 | KeyBindingRequest, 21 | ); 22 | } 23 | 24 | protected abstract bind(data: KeyBindingRequest): Promise; 25 | 26 | protected async onBindingRequest(data: KeyBindingRequest): Promise { 27 | let status = false; 28 | 29 | try { 30 | await this.bind(data); 31 | status = true; 32 | } catch (err) { 33 | this.logger.error('Failed to bind', { 34 | data, 35 | err, 36 | }); 37 | return; 38 | } 39 | 40 | return this.sendBindingResponse(status); 41 | } 42 | 43 | protected sendBindingResponse(status: boolean): void { 44 | const data = new KeyBindingResponse({ 45 | status: status ? 0 : -1, 46 | }); 47 | 48 | this.sendEncryptedSpecificMessage( 49 | InputMessageId.INPUT_MESSAGE_KEY_BINDING_RESPONSE, 50 | data, 51 | ); 52 | } 53 | 54 | protected sendTouchEvent(touchEvent: TouchEvent): void { 55 | if (!this.started) { 56 | return; 57 | } 58 | 59 | const data = new InputReport({ 60 | timestamp: microsecondsTime(), 61 | touchEvent, 62 | }); 63 | 64 | this.sendEncryptedSpecificMessage( 65 | InputMessageId.INPUT_MESSAGE_INPUT_REPORT, 66 | data, 67 | ); 68 | } 69 | 70 | protected sendKeyEvent(keyEvent: KeyEvent): void { 71 | if (!this.started) { 72 | return; 73 | } 74 | 75 | const data = new InputReport({ 76 | timestamp: microsecondsTime(), 77 | keyEvent, 78 | }); 79 | 80 | this.sendEncryptedSpecificMessage( 81 | InputMessageId.INPUT_MESSAGE_INPUT_REPORT, 82 | data, 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /android-auto/src/services/MediaStatusService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MediaPlaybackMetadata, 3 | MediaPlaybackStatus, 4 | MediaPlaybackStatusMessageId, 5 | MediaPlaybackStatusService, 6 | type Service as ProtoService, 7 | } from '@web-auto/android-auto-proto'; 8 | 9 | import { Service, type ServiceEvents } from './Service.js'; 10 | 11 | export abstract class MediaStatusService extends Service { 12 | public constructor(events: ServiceEvents) { 13 | super(events); 14 | 15 | this.addMessageCallback( 16 | MediaPlaybackStatusMessageId.MEDIA_PLAYBACK_METADATA, 17 | this.onMetadata.bind(this), 18 | MediaPlaybackMetadata, 19 | ); 20 | this.addMessageCallback( 21 | MediaPlaybackStatusMessageId.MEDIA_PLAYBACK_STATUS, 22 | this.onPlayback.bind(this), 23 | MediaPlaybackStatus, 24 | ); 25 | } 26 | 27 | protected abstract handleMetadata( 28 | data: MediaPlaybackMetadata, 29 | ): Promise; 30 | 31 | protected abstract handlePlayback(data: MediaPlaybackStatus): Promise; 32 | 33 | protected async onMetadata(data: MediaPlaybackMetadata): Promise { 34 | await this.handleMetadata(data); 35 | } 36 | 37 | protected async onPlayback(data: MediaPlaybackStatus): Promise { 38 | await this.handlePlayback(data); 39 | } 40 | 41 | protected override fillChannelDescriptor( 42 | channelDescriptor: ProtoService, 43 | ): void { 44 | channelDescriptor.mediaPlaybackService = new MediaPlaybackStatusService( 45 | {}, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /android-auto/src/services/NavigationStatusService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NavigationCurrentPosition, 3 | NavigationNextTurnDistanceEvent, 4 | NavigationNextTurnEvent, 5 | NavigationState, 6 | NavigationStatus, 7 | NavigationStatusMessageId, 8 | } from '@web-auto/android-auto-proto'; 9 | 10 | import { Service, type ServiceEvents } from './Service.js'; 11 | 12 | export abstract class NavigationStatusService extends Service { 13 | public constructor(events: ServiceEvents) { 14 | super(events); 15 | 16 | this.addMessageCallback( 17 | NavigationStatusMessageId.INSTRUMENT_CLUSTER_NAVIGATION_STATUS, 18 | this.onStatus.bind(this), 19 | NavigationStatus, 20 | ); 21 | this.addMessageCallback( 22 | NavigationStatusMessageId.INSTRUMENT_CLUSTER_NAVIGATION_DISTANCE_EVENT, 23 | this.onDistance.bind(this), 24 | NavigationNextTurnDistanceEvent, 25 | ); 26 | this.addMessageCallback( 27 | NavigationStatusMessageId.INSTRUMENT_CLUSTER_NAVIGATION_TURN_EVENT, 28 | this.onTurn.bind(this), 29 | NavigationNextTurnEvent, 30 | ); 31 | this.addMessageCallback( 32 | NavigationStatusMessageId.INSTRUMENT_CLUSTER_NAVIGATION_STATE, 33 | this.onState.bind(this), 34 | NavigationState, 35 | ); 36 | this.addMessageCallback( 37 | NavigationStatusMessageId.INSTRUMENT_CLUSTER_NAVIGATION_CURRENT_POSITION, 38 | this.onCurrentPosition.bind(this), 39 | NavigationCurrentPosition, 40 | ); 41 | } 42 | 43 | protected abstract handleStatus(data: NavigationStatus): Promise; 44 | 45 | protected abstract handleDistance( 46 | data: NavigationNextTurnDistanceEvent, 47 | ): Promise; 48 | 49 | protected abstract handleTurn(data: NavigationNextTurnEvent): Promise; 50 | 51 | protected abstract handleCurrentPosition( 52 | data: NavigationCurrentPosition, 53 | ): Promise; 54 | 55 | protected abstract handleState(data: NavigationState): Promise; 56 | 57 | protected async onStatus(data: NavigationStatus): Promise { 58 | await this.handleStatus(data); 59 | } 60 | 61 | protected async onDistance( 62 | data: NavigationNextTurnDistanceEvent, 63 | ): Promise { 64 | await this.handleDistance(data); 65 | } 66 | 67 | protected async onTurn(data: NavigationNextTurnEvent): Promise { 68 | await this.handleTurn(data); 69 | } 70 | 71 | protected async onCurrentPosition( 72 | data: NavigationCurrentPosition, 73 | ): Promise { 74 | await this.handleCurrentPosition(data); 75 | } 76 | 77 | protected async onState(data: NavigationState): Promise { 78 | await this.handleState(data); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /android-auto/src/services/Pinger.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { PingRequest, PingResponse } from '@web-auto/android-auto-proto'; 4 | import { getLogger } from '@web-auto/logging'; 5 | 6 | import { microsecondsTime, milliToMicro } from './../utils/time.js'; 7 | 8 | export interface PingerEvents { 9 | onPingRequest: (request: PingRequest) => void; 10 | onPingTimeout: () => void; 11 | } 12 | 13 | export class Pinger { 14 | protected logger = getLogger(this.constructor.name); 15 | private pingInterval: ReturnType | undefined; 16 | private pingReceivedTime: bigint | undefined; 17 | private pingSentTime: bigint | undefined; 18 | private started = false; 19 | private pingTimeoutUs: bigint; 20 | private onPingTimeoutBound: () => void; 21 | 22 | public constructor( 23 | pingTimeoutMs: number, 24 | private events: PingerEvents, 25 | ) { 26 | this.pingTimeoutUs = milliToMicro(pingTimeoutMs); 27 | this.onPingTimeoutBound = this.onPingTimeout.bind(this); 28 | } 29 | 30 | public schedulePingTimeout(): void { 31 | assert(this.pingInterval === undefined); 32 | this.pingInterval = setInterval(this.onPingTimeoutBound, 5000); 33 | } 34 | 35 | public cancelPing(): void { 36 | assert(this.pingInterval !== undefined); 37 | clearInterval(this.pingInterval); 38 | this.pingInterval = undefined; 39 | } 40 | 41 | public onPingTimeout(): void { 42 | const isTimeoutPing = 43 | this.pingSentTime !== undefined && 44 | (this.pingReceivedTime === undefined || 45 | this.pingReceivedTime - this.pingSentTime > this.pingTimeoutUs); 46 | 47 | if (isTimeoutPing) { 48 | this.events.onPingTimeout(); 49 | return; 50 | } 51 | 52 | this.pingSentTime = microsecondsTime(); 53 | this.pingReceivedTime = undefined; 54 | 55 | const data = new PingRequest({ 56 | timestamp: this.pingSentTime, 57 | }); 58 | 59 | this.events.onPingRequest(data); 60 | } 61 | 62 | public onPingResponse(data: PingResponse): void { 63 | this.pingReceivedTime = data.timestamp; 64 | } 65 | 66 | public start(): void { 67 | assert(!this.started); 68 | 69 | this.schedulePingTimeout(); 70 | this.started = true; 71 | } 72 | 73 | public stop(): void { 74 | assert(this.started); 75 | 76 | this.cancelPing(); 77 | this.pingReceivedTime = undefined; 78 | this.pingSentTime = undefined; 79 | this.started = false; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /android-auto/src/services/SensorService.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { 4 | MessageStatus, 5 | SensorBatch, 6 | SensorMessageId, 7 | SensorRequest, 8 | SensorResponse, 9 | SensorSourceService, 10 | SensorSourceService_Sensor, 11 | SensorType, 12 | type Service as ProtoService, 13 | } from '@web-auto/android-auto-proto'; 14 | 15 | import { Sensor } from '../sensors/Sensor.js'; 16 | import { Service, type ServiceEvents } from './Service.js'; 17 | 18 | export abstract class SensorService extends Service { 19 | protected abstract sensors: Sensor[]; 20 | 21 | public constructor(events: ServiceEvents) { 22 | super(events); 23 | 24 | this.addMessageCallback( 25 | SensorMessageId.SENSOR_MESSAGE_REQUEST, 26 | this.onSensorStartRequest.bind(this), 27 | SensorRequest, 28 | ); 29 | } 30 | 31 | protected findSensor(sensorType: SensorType): Sensor | undefined { 32 | for (const sensor of this.sensors) { 33 | if (sensor.type === sensorType) { 34 | return sensor; 35 | } 36 | } 37 | 38 | return undefined; 39 | } 40 | 41 | protected getSensor(sensorType: SensorType): Sensor { 42 | const sensor = this.findSensor(sensorType); 43 | if (sensor === undefined) { 44 | throw new Error( 45 | `Failed to get sensor with type ${sensorType.toString()}`, 46 | ); 47 | } 48 | 49 | return sensor; 50 | } 51 | 52 | protected onSensorStartRequest(data: SensorRequest): void { 53 | try { 54 | assert(data.type !== undefined); 55 | const sensor = this.getSensor(data.type); 56 | sensor.start(); 57 | } catch (err) { 58 | this.logger.error('Failed to start sensor', { 59 | data, 60 | err, 61 | }); 62 | return; 63 | } 64 | 65 | this.sendSensorStartResponse(data.type, true); 66 | } 67 | 68 | protected sendSensorStartResponse( 69 | sensorType: SensorType, 70 | status: boolean, 71 | ): void { 72 | const data = new SensorResponse({ 73 | status: status 74 | ? MessageStatus.STATUS_SUCCESS 75 | : MessageStatus.STATUS_INVALID_SENSOR, 76 | }); 77 | 78 | this.sendEncryptedSpecificMessage( 79 | SensorMessageId.SENSOR_MESSAGE_RESPONSE, 80 | data, 81 | ); 82 | 83 | const sensor = this.getSensor(sensorType); 84 | sensor.emit(); 85 | } 86 | 87 | protected sendEventIndication(data: SensorBatch): void { 88 | this.sendEncryptedSpecificMessage( 89 | SensorMessageId.SENSOR_MESSAGE_BATCH, 90 | data, 91 | ); 92 | } 93 | 94 | protected override fillChannelDescriptor( 95 | channelDescriptor: ProtoService, 96 | ): void { 97 | channelDescriptor.sensorSourceService = new SensorSourceService({ 98 | sensors: [], 99 | }); 100 | 101 | for (const sensor of this.sensors) { 102 | channelDescriptor.sensorSourceService.sensors.push( 103 | new SensorSourceService_Sensor({ 104 | sensorType: sensor.type, 105 | }), 106 | ); 107 | } 108 | } 109 | 110 | public override stop(): void { 111 | super.stop(); 112 | 113 | for (const sensor of this.sensors) { 114 | sensor.stop(); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /android-auto/src/services/VideoService.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { 4 | DisplayType, 5 | KeyCode, 6 | MediaMessageId, 7 | MediaSinkService, 8 | Service, 9 | VideoFocusNotification, 10 | VideoFocusRequestNotification, 11 | } from '@web-auto/android-auto-proto'; 12 | import { type IVideoConfiguration } from '@web-auto/android-auto-proto/interfaces.js'; 13 | 14 | import { AVOutputService } from './AVOutputService.js'; 15 | import type { ServiceEvents } from './Service.js'; 16 | import { 17 | type DisplayConfig, 18 | type ResolutionConfig, 19 | VideoResolutionUtils, 20 | } from './VideoResolutionUtils.js'; 21 | 22 | export interface VideoServiceConfig { 23 | id: number; 24 | type: DisplayType; 25 | display: DisplayConfig; 26 | resolutions: ResolutionConfig[]; 27 | } 28 | 29 | export abstract class VideoService extends AVOutputService { 30 | protected configs: IVideoConfiguration[]; 31 | 32 | public constructor( 33 | protected config: VideoServiceConfig, 34 | events: ServiceEvents, 35 | ) { 36 | const configs = VideoResolutionUtils.getVideoConfigs( 37 | config.display, 38 | config.resolutions, 39 | ); 40 | 41 | super( 42 | { 43 | priorities: Array.from(configs.keys()), 44 | }, 45 | events, 46 | ); 47 | 48 | this.configs = configs; 49 | 50 | this.addMessageCallback( 51 | MediaMessageId.MEDIA_MESSAGE_VIDEO_FOCUS_REQUEST, 52 | this.onVideoFocusRequest.bind(this), 53 | VideoFocusRequestNotification, 54 | ); 55 | } 56 | 57 | protected channelConfig(): IVideoConfiguration { 58 | assert(this.configurationIndex !== undefined); 59 | const config = this.configs[this.configurationIndex]; 60 | assert(config !== undefined); 61 | return config; 62 | } 63 | 64 | protected abstract focus( 65 | data: VideoFocusRequestNotification, 66 | ): Promise; 67 | 68 | protected async onVideoFocusRequest( 69 | data: VideoFocusRequestNotification, 70 | ): Promise { 71 | try { 72 | await this.focus(data); 73 | } catch (err) { 74 | this.logger.error('Failed to focus video', { 75 | data, 76 | err, 77 | }); 78 | return; 79 | } 80 | } 81 | 82 | protected sendVideoFocusIndication(data: VideoFocusNotification): void { 83 | this.sendEncryptedSpecificMessage( 84 | MediaMessageId.MEDIA_MESSAGE_VIDEO_FOCUS_NOTIFICATION, 85 | data, 86 | ); 87 | } 88 | 89 | protected override fillChannelDescriptor(channelDescriptor: Service): void { 90 | channelDescriptor.mediaSinkService = new MediaSinkService({ 91 | videoConfigs: this.configs, 92 | displayId: this.config.id, 93 | displayType: this.config.type, 94 | }); 95 | 96 | if (this.config.type === DisplayType.AUXILIARY) { 97 | channelDescriptor.mediaSinkService.initialContentKeycode = 98 | KeyCode.KEYCODE_NAVIGATION; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /android-auto/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AudioInputService.js'; 2 | export * from './AudioOutputService.js'; 3 | export * from './AVOutputService.js'; 4 | export * from './ControlService.js'; 5 | export * from './InputService.js'; 6 | export * from './MediaStatusService.js'; 7 | export * from './NavigationStatusService.js'; 8 | export * from './SensorService.js'; 9 | export * from './Service.js'; 10 | export * from './VideoResolutionUtils.js'; 11 | export * from './VideoService.js'; 12 | -------------------------------------------------------------------------------- /android-auto/src/transport/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Device.js'; 2 | export * from './DeviceHandler.js'; 3 | -------------------------------------------------------------------------------- /android-auto/src/utils/BufferReader.ts: -------------------------------------------------------------------------------- 1 | export class BufferReader { 2 | private cursor = 0; 3 | 4 | private view; 5 | 6 | private constructor(private data: Uint8Array) { 7 | this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); 8 | } 9 | 10 | public static fromBuffer(arr: Uint8Array): BufferReader { 11 | return new BufferReader(arr); 12 | } 13 | 14 | public readUint8(): number { 15 | const value = this.view.getUint8(this.cursor); 16 | this.cursor += 1; 17 | return value; 18 | } 19 | 20 | public readUint16BE(): number { 21 | const value = this.view.getUint16(this.cursor); 22 | this.cursor += 2; 23 | return value; 24 | } 25 | 26 | public readUint32BE(): number { 27 | const value = this.view.getUint16(this.cursor); 28 | this.cursor += 4; 29 | return value; 30 | } 31 | 32 | public readUint64BE(): bigint { 33 | const value = this.view.getBigUint64(this.cursor); 34 | this.cursor += 8; 35 | return value; 36 | } 37 | 38 | public readBufferSize(): number { 39 | return this.data.byteLength - this.cursor; 40 | } 41 | 42 | public totalBufferSize(): number { 43 | return this.data.byteLength; 44 | } 45 | 46 | public readBuffer(size?: number): Uint8Array { 47 | if (size === undefined) { 48 | size = this.readBufferSize(); 49 | } 50 | 51 | if (this.cursor === 0 && size === this.totalBufferSize()) { 52 | this.cursor = this.totalBufferSize(); 53 | return this.data; 54 | } 55 | 56 | const end = this.cursor + size; 57 | if (end > this.totalBufferSize()) { 58 | throw new Error( 59 | `Buffer read out of bounds, start: ${this.cursor}, ` + 60 | `end: ${end}, length: ${this.totalBufferSize()}`, 61 | ); 62 | } 63 | 64 | const buffer = this.data.subarray(this.cursor, end); 65 | this.cursor += buffer.byteLength; 66 | return buffer; 67 | } 68 | 69 | public readSeek(offset: number): this { 70 | this.cursor = offset; 71 | return this; 72 | } 73 | 74 | public getReadOffset(): number { 75 | return this.cursor; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /android-auto/src/utils/BufferWriter.ts: -------------------------------------------------------------------------------- 1 | export class BufferWriter { 2 | private cursor = 0; 3 | 4 | public data; 5 | 6 | private constructor(size: number | undefined) { 7 | if (size === undefined) { 8 | size = 0; 9 | } 10 | 11 | this.data = Buffer.allocUnsafe(size); 12 | } 13 | 14 | public static concat(arr1: Uint8Array, arr2: Uint8Array): Uint8Array { 15 | const writer = this.fromSize(arr1.byteLength + arr2.byteLength); 16 | writer.appendBuffer(arr1); 17 | writer.appendBuffer(arr2); 18 | return writer.data; 19 | } 20 | 21 | public static concatMultiple(arrs: Uint8Array[]): Uint8Array { 22 | let size = 0; 23 | for (const arr of arrs) { 24 | size += arr.byteLength; 25 | } 26 | const writer = this.fromSize(size); 27 | for (const arr of arrs) { 28 | writer.appendBuffer(arr); 29 | } 30 | return writer.data; 31 | } 32 | 33 | public static fromSize(size: number): BufferWriter { 34 | return new BufferWriter(size); 35 | } 36 | 37 | public static empty(): BufferWriter { 38 | return new BufferWriter(0); 39 | } 40 | 41 | public resize(size: number): void { 42 | const data = Buffer.allocUnsafe(size); 43 | this.data.copy(data); 44 | this.data = data; 45 | } 46 | 47 | private appendResizeToFit(size: number): void { 48 | const neededSize = this.cursor + size; 49 | if (neededSize <= this.data.length) { 50 | return; 51 | } 52 | 53 | this.resize(neededSize); 54 | } 55 | 56 | public appendUint8(data: number): void { 57 | const size = 1; 58 | this.appendResizeToFit(size); 59 | this.data.writeUint8(data, this.cursor); 60 | this.cursor += size; 61 | } 62 | 63 | public appendUint16BE(data: number): void { 64 | const size = 2; 65 | this.appendResizeToFit(size); 66 | this.data.writeUint16BE(data, this.cursor); 67 | this.cursor += size; 68 | } 69 | 70 | public appendUint32BE(data: number): void { 71 | const size = 2; 72 | this.appendResizeToFit(size); 73 | this.data.writeUint32BE(data, this.cursor); 74 | this.cursor += size; 75 | } 76 | 77 | public appendUint64BE(data: bigint): void { 78 | const size = 8; 79 | this.appendResizeToFit(size); 80 | this.data.writeBigInt64BE(data, this.cursor); 81 | this.cursor += size; 82 | } 83 | 84 | public appendSeek(offset: number): void { 85 | this.cursor = offset; 86 | } 87 | 88 | public appendBuffer(arr: Uint8Array): void { 89 | const size = arr.byteLength; 90 | this.appendResizeToFit(size); 91 | this.data.set(arr, this.cursor); 92 | this.cursor += size; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /android-auto/src/utils/buffer-utils.ts: -------------------------------------------------------------------------------- 1 | export const bufferWrapUint8Array = (buffer: Uint8Array): Buffer => { 2 | let actualBuffer; 3 | 4 | if (Buffer.isBuffer(buffer)) { 5 | actualBuffer = buffer; 6 | } else { 7 | actualBuffer = Buffer.from( 8 | buffer.buffer, 9 | buffer.byteOffset, 10 | buffer.byteLength, 11 | ); 12 | } 13 | 14 | return actualBuffer; 15 | }; 16 | -------------------------------------------------------------------------------- /android-auto/src/utils/buffer.ts: -------------------------------------------------------------------------------- 1 | export * from './buffer-utils.js'; 2 | export * from './BufferReader.js'; 3 | export * from './BufferWriter.js'; 4 | -------------------------------------------------------------------------------- /android-auto/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buffer.js'; 2 | -------------------------------------------------------------------------------- /android-auto/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export const microsecondsTime = () => { 2 | return BigInt(Date.now()) * 1000n; 3 | }; 4 | 5 | export const milliToMicro = (milli: number) => { 6 | return BigInt(milli) * 1000n; 7 | }; 8 | -------------------------------------------------------------------------------- /android-auto/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../logging" 12 | }, 13 | { 14 | "path": "../android-auto-proto" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /common-ipc/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /common-ipc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/common-ipc", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "types": "./dist/esm/index.d.ts", 8 | "exports": { 9 | ".": "./dist/esm/common.js", 10 | "./renderer.js": "./dist/esm/renderer.js", 11 | "./main.js": "./dist/esm/main.js" 12 | }, 13 | "scripts": { 14 | "build": "tspc", 15 | "clean": "tspc --build --clean", 16 | "lint": "eslint --fix ." 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common-ipc/src/common.ts: -------------------------------------------------------------------------------- 1 | export type IpcServiceFunction = (...args: any[]) => Promise; 2 | export type IpcClientFunction = (...args: any[]) => void; 3 | 4 | export type IpcService = { 5 | [key: string]: IpcServiceFunction; 6 | }; 7 | 8 | export type IpcClient = { 9 | [key: string]: IpcClientFunction; 10 | }; 11 | 12 | export type IpcClientHandlerKey = keyof L & string; 13 | export type IpcServiceHandlerKey = keyof L & string; 14 | 15 | export type IpcResponseEvent = { 16 | replyToId: number; 17 | handle: string; 18 | result: any; 19 | }; 20 | 21 | export type IpcErrorResponseEvent = { 22 | replyToId: number; 23 | handle: string; 24 | err: string; 25 | }; 26 | 27 | export type IpcNotificationEvent = { 28 | handle: string; 29 | name: string; 30 | args: any[]; 31 | }; 32 | 33 | export type IpcRawNotificationEvent = { 34 | handle: string; 35 | name: string; 36 | args?: any[]; 37 | raw: true; 38 | }; 39 | 40 | export type IpcClientEvent = 41 | | IpcResponseEvent 42 | | IpcErrorResponseEvent 43 | | IpcNotificationEvent 44 | | IpcRawNotificationEvent; 45 | 46 | export type IpcCallEvent = { 47 | id: number; 48 | handle: string; 49 | name: string; 50 | args: any[]; 51 | }; 52 | 53 | export type IpcSubscribeEvent = { 54 | handle: string; 55 | name: string; 56 | subscribe: boolean; 57 | }; 58 | 59 | export type IpcServiceEvent = IpcCallEvent | IpcSubscribeEvent; 60 | 61 | export type IpcEvent = IpcClientEvent | IpcServiceEvent; 62 | 63 | export interface IpcSerializer { 64 | serialize(ipcEvent: IpcEvent): any; 65 | deserialize(data: any): IpcEvent; 66 | } 67 | 68 | export type IpcSocketOpenCallback = (socket: IpcSocket) => void; 69 | export type IpcSocketDataCallback = (socket: IpcSocket, data: any) => void; 70 | export type IpcSocketCloseCallback = (socket: IpcSocket) => void; 71 | export type IpcSocket = { 72 | serializer: IpcSerializer; 73 | send(data: any): void; 74 | open(): Promise; 75 | close(): Promise; 76 | }; 77 | 78 | export interface IpcSocketEvents { 79 | onSocketData: IpcSocketDataCallback; 80 | onSocketClose: IpcSocketCloseCallback; 81 | } 82 | 83 | export type IpcSocketCreateCallback = (events: IpcSocketEvents) => IpcSocket; 84 | 85 | export abstract class BaseIpcSocket implements IpcSocket { 86 | public constructor( 87 | public serializer: IpcSerializer, 88 | protected events: IpcSocketEvents, 89 | ) {} 90 | 91 | public abstract send(data: any): void; 92 | public abstract open(): Promise; 93 | public abstract close(): Promise; 94 | } 95 | 96 | export class DummyIpcSerializer implements IpcSerializer { 97 | public constructor() {} 98 | 99 | public serialize(ipcEvent: IpcEvent): any { 100 | return ipcEvent; 101 | } 102 | 103 | public deserialize(data: any): IpcEvent { 104 | return data as IpcEvent; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /common-ipc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.web.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config-loader/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /config-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/config-loader", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "exports": { 8 | "import": "./dist/esm/index.js", 9 | "types": "./dist/esm/index.d.ts" 10 | }, 11 | "scripts": { 12 | "build": "tspc", 13 | "clean": "tspc --build --clean", 14 | "lint": "eslint --fix ." 15 | }, 16 | "dependencies": { 17 | "json5": "^2.2.3", 18 | "type-fest": "^4.18.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config-loader/src/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | 3 | import { readFileSync } from 'fs'; 4 | import JSON5 from 'json5'; 5 | import type { JsonObject, JsonValue, WritableDeep } from 'type-fest'; 6 | 7 | export type Value = WritableDeep; 8 | export type Config = WritableDeep; 9 | export type Variables = WritableDeep; 10 | 11 | export type VariableConfig = { 12 | variables?: Variables; 13 | } & Config; 14 | 15 | const CONFIG_PATH = resolve( 16 | import.meta.dirname, 17 | '..', 18 | '..', 19 | '..', 20 | 'config.json5', 21 | ); 22 | 23 | const lookupVariable = ( 24 | name: string, 25 | variables: Variables, 26 | ): Exclude => { 27 | const value = variables[name]; 28 | if (value === undefined || value === null) { 29 | throw new Error(`Failed to find value for variable ${name}`); 30 | } 31 | 32 | return value; 33 | }; 34 | 35 | const lookupInterpolableVariable = ( 36 | name: string, 37 | variables: Variables, 38 | ): string | number => { 39 | const value = lookupVariable(name, variables); 40 | if (typeof value !== 'string' && typeof value !== 'number') { 41 | throw new Error(`Invalid value for variable ${name}`); 42 | } 43 | 44 | return value; 45 | }; 46 | 47 | const interpolateVariableMatch = ( 48 | match: RegExpMatchArray, 49 | value: string, 50 | variables: Variables, 51 | ): string => { 52 | const variableNameMatch = match[0]; 53 | 54 | const variableName = match[1]; 55 | if (variableName === undefined) { 56 | return value; 57 | } 58 | 59 | const variableValue = lookupInterpolableVariable(variableName, variables); 60 | 61 | return value.replace(variableNameMatch, variableValue.toString()); 62 | }; 63 | 64 | const interpolateVariable = (value: string, variables: Variables): string => { 65 | const matches = value.matchAll(/\$\{(\S+?)\}/g); 66 | 67 | for (const match of matches) { 68 | value = interpolateVariableMatch(match, value, variables); 69 | } 70 | 71 | return value; 72 | }; 73 | 74 | const fixVariable = (value: Value, variables: Variables): Value => { 75 | if (typeof value !== 'string') { 76 | return value; 77 | } 78 | 79 | const interpolateMarker = '`'; 80 | if ( 81 | value.startsWith(interpolateMarker) && 82 | value.endsWith(interpolateMarker) 83 | ) { 84 | const template = value.slice( 85 | interpolateMarker.length, 86 | value.length - interpolateMarker.length, 87 | ); 88 | 89 | return interpolateVariable(template, variables); 90 | } 91 | 92 | const replaceMarker = '='; 93 | if (value.startsWith(replaceMarker)) { 94 | const variableName = value.slice(replaceMarker.length); 95 | return lookupVariable(variableName, variables); 96 | } 97 | 98 | return value; 99 | }; 100 | 101 | const fixVariables = (value: Value, variables: Variables): Value => { 102 | if (Array.isArray(value)) { 103 | for (const [key, v] of value.entries()) { 104 | value[key] = fixVariables(v, variables); 105 | } 106 | } else if (value !== null && typeof value === 'object') { 107 | for (const [key, v] of Object.entries(value)) { 108 | value[key] = fixVariables(v, variables); 109 | } 110 | } else { 111 | return fixVariable(value, variables); 112 | } 113 | 114 | return value; 115 | }; 116 | 117 | export const loadConfig = (assertFn: (input: unknown) => T): T => { 118 | const configString = readFileSync(CONFIG_PATH, 'utf8'); 119 | const config = JSON5.parse(configString); 120 | 121 | const variables = config['variables']; 122 | delete config['variables']; 123 | if (variables !== undefined) { 124 | fixVariables(config, variables); 125 | } 126 | 127 | return assertFn(config); 128 | }; 129 | -------------------------------------------------------------------------------- /config-loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /electron-ipc-preload/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-js'; 2 | 3 | export default [...baseConfig]; 4 | -------------------------------------------------------------------------------- /electron-ipc-preload/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/electron-ipc-preload", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "scripts": { 8 | "build": "", 9 | "lint": "eslint --fix ." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /electron-ipc-preload/preload-run.mjs: -------------------------------------------------------------------------------- 1 | dist/esm/preload-run.js -------------------------------------------------------------------------------- /electron-ipc/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /electron-ipc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/electron-ipc", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "types": "./dist/esm/index.d.ts", 8 | "exports": { 9 | "./main.js": "./dist/esm/main.js", 10 | "./common.js": "./dist/esm/common.js", 11 | "./preload.js": "./dist/esm/preload.js", 12 | "./renderer.js": "./dist/esm/renderer.js" 13 | }, 14 | "scripts": { 15 | "build": "tspc", 16 | "clean": "tspc --build --clean", 17 | "lint": "eslint --fix ." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /electron-ipc/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { IpcClientEvent, IpcServiceEvent } from '@web-auto/common-ipc'; 2 | import type { IpcRendererEvent } from 'electron/renderer'; 3 | 4 | export const ELECTRON_IPC_COMMUNICATION_CHANNEL = 'electron-ipc'; 5 | 6 | export type IpcPreloadOnOffCallback = ( 7 | event: IpcRendererEvent, 8 | ipcEvent: IpcClientEvent, 9 | ) => void; 10 | 11 | export interface IpcPreloadExposed { 12 | on: (name: string, cb: IpcPreloadOnOffCallback) => void; 13 | off: (name: string, cb: IpcPreloadOnOffCallback) => void; 14 | send: (name: string, ipcEvent: IpcServiceEvent) => void; 15 | } 16 | -------------------------------------------------------------------------------- /electron-ipc/src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseIpcSocket, 3 | type IpcSerializer, 4 | type IpcSocketEvents, 5 | } from '@web-auto/common-ipc'; 6 | import { IpcSocketHandler } from '@web-auto/common-ipc/main.js'; 7 | import { app, ipcMain, type IpcMainEvent, type WebContents } from 'electron'; 8 | 9 | class ElectronServiceIpcSocket extends BaseIpcSocket { 10 | private onDataInternalBound: (event: IpcMainEvent, data: any) => void; 11 | private onCloseInternalBound: () => void; 12 | 13 | public constructor( 14 | private channelName: string, 15 | private webContents: WebContents, 16 | serializer: IpcSerializer, 17 | events: IpcSocketEvents, 18 | ) { 19 | super(serializer, events); 20 | 21 | this.onDataInternalBound = this.onDataInternal.bind(this); 22 | this.onCloseInternalBound = this.onCloseInternal.bind(this); 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/require-await 26 | public async open(): Promise { 27 | ipcMain.on(this.channelName, this.onDataInternalBound); 28 | this.webContents.once('destroyed', this.onCloseInternalBound); 29 | } 30 | 31 | // eslint-disable-next-line @typescript-eslint/require-await 32 | public async close(): Promise { 33 | ipcMain.off(this.channelName, this.onDataInternalBound); 34 | this.webContents.off('destroyed', this.onCloseInternalBound); 35 | } 36 | 37 | public onDataInternal(event: IpcMainEvent, data: any): void { 38 | if (event.sender !== this.webContents) { 39 | return; 40 | } 41 | 42 | this.events.onSocketData(this, data); 43 | } 44 | 45 | public onCloseInternal(): void { 46 | this.events.onSocketClose(this); 47 | } 48 | 49 | public send(data: any): void { 50 | this.webContents.send(this.channelName, data); 51 | } 52 | } 53 | 54 | export class ElectronIpcServiceRegistrySocketHandler extends IpcSocketHandler { 55 | private onWebContentsCreatedBound: ( 56 | _event: Electron.Event, 57 | webContents: WebContents, 58 | ) => void; 59 | 60 | public constructor( 61 | serializer: IpcSerializer, 62 | private name: string, 63 | events: IpcSocketEvents, 64 | ) { 65 | super(serializer, events); 66 | 67 | this.onWebContentsCreatedBound = this.onWebContentsCreated.bind(this); 68 | } 69 | 70 | public register(): void { 71 | app.on('web-contents-created', this.onWebContentsCreatedBound); 72 | } 73 | 74 | public unregister(): void { 75 | app.off('web-contents-created', this.onWebContentsCreatedBound); 76 | } 77 | 78 | public onWebContentsCreated( 79 | _event: Electron.Event, 80 | webContents: WebContents, 81 | ): void { 82 | this.addSocket((events) => { 83 | return new ElectronServiceIpcSocket( 84 | this.name, 85 | webContents, 86 | this.serializer, 87 | events, 88 | ); 89 | }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /electron-ipc/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron'; 2 | 3 | import { 4 | ELECTRON_IPC_COMMUNICATION_CHANNEL, 5 | type IpcPreloadExposed, 6 | } from './common.js'; 7 | 8 | export const expose = () => { 9 | const exposed: IpcPreloadExposed = { 10 | on: (name, cb) => ipcRenderer.on(name, cb), 11 | off: (name, cb) => ipcRenderer.off(name, cb), 12 | send: (name, ipcEvent) => ipcRenderer.send(name, ipcEvent), 13 | }; 14 | 15 | contextBridge.exposeInMainWorld( 16 | ELECTRON_IPC_COMMUNICATION_CHANNEL, 17 | exposed, 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /electron-ipc/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseIpcSocket, 3 | DummyIpcSerializer, 4 | type IpcSerializer, 5 | type IpcSocketEvents, 6 | } from '@web-auto/common-ipc'; 7 | import { GenericIpcClientRegistry } from '@web-auto/common-ipc/renderer.js'; 8 | import type { IpcRendererEvent } from 'electron'; 9 | 10 | import { 11 | ELECTRON_IPC_COMMUNICATION_CHANNEL, 12 | type IpcPreloadExposed, 13 | } from './common.js'; 14 | 15 | declare const window: { 16 | [ELECTRON_IPC_COMMUNICATION_CHANNEL]?: IpcPreloadExposed; 17 | }; 18 | 19 | class ElectronClientIpcSocket extends BaseIpcSocket { 20 | private exposed: IpcPreloadExposed; 21 | private onDataInternalBound: (_event: IpcRendererEvent, data: any) => void; 22 | 23 | public constructor( 24 | private channelName: string, 25 | serializer: IpcSerializer, 26 | events: IpcSocketEvents, 27 | ) { 28 | super(serializer, events); 29 | 30 | this.onDataInternalBound = this.onDataInternal.bind(this); 31 | 32 | const exposed = window[ELECTRON_IPC_COMMUNICATION_CHANNEL]; 33 | 34 | if (exposed === undefined) { 35 | throw new Error('IPC communication not exposed'); 36 | } 37 | 38 | this.exposed = exposed; 39 | } 40 | 41 | // eslint-disable-next-line @typescript-eslint/require-await 42 | public async open(): Promise { 43 | this.exposed.on(this.channelName, this.onDataInternalBound); 44 | } 45 | 46 | // eslint-disable-next-line @typescript-eslint/require-await 47 | public async close(): Promise { 48 | this.exposed.off(this.channelName, this.onDataInternalBound); 49 | } 50 | 51 | private onDataInternal(_event: IpcRendererEvent, data: any): void { 52 | this.events.onSocketData(this, data); 53 | } 54 | 55 | public send(data: any): void { 56 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 57 | this.exposed.send(this.channelName, data); 58 | } 59 | } 60 | 61 | export class ElectronIpcClientRegistry extends GenericIpcClientRegistry { 62 | public constructor(name: string) { 63 | const serializer = new DummyIpcSerializer(); 64 | super((events) => { 65 | return new ElectronClientIpcSocket(name, serializer, events); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /electron-ipc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.web.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../common-ipc" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /electron/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /electron/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/electron", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tspc", 9 | "clean": "tspc --build --clean", 10 | "lint": "eslint --fix .", 11 | "start": "electron ./dist/esm/main.js", 12 | "start-debug": "electron --inspect-brk=5858 ./dist/esm/main.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /electron/src/main.ts: -------------------------------------------------------------------------------- 1 | import { DummyIpcSerializer } from '@web-auto/common-ipc'; 2 | import { IpcServiceRegistry } from '@web-auto/common-ipc/main.js'; 3 | import { loadConfig } from '@web-auto/config-loader'; 4 | import { ElectronIpcServiceRegistrySocketHandler } from '@web-auto/electron-ipc/main.js'; 5 | import { getLogger, setConfig } from '@web-auto/logging'; 6 | import { 7 | NodeAndroidAutoServer, 8 | NodeAndroidAutoServerBuilder, 9 | type NodeCommonAndroidAutoConfig, 10 | } from '@web-auto/node-common'; 11 | import { app } from 'electron'; 12 | import { createAssert } from 'typia'; 13 | 14 | import { 15 | ElectronWindowBuilder, 16 | type ElectronWindowBuilderConfig, 17 | } from './ElectronWindowBuilder.js'; 18 | 19 | type ElectronAndroidAutoConfig = { 20 | electronWindowBuilder: ElectronWindowBuilderConfig; 21 | } & NodeCommonAndroidAutoConfig; 22 | 23 | const configAssert = createAssert(); 24 | 25 | const config = loadConfig(configAssert); 26 | 27 | setConfig(config.logging); 28 | 29 | const logger = getLogger('electron'); 30 | 31 | logger.info('Electron config', config); 32 | 33 | let androidAutoServer: NodeAndroidAutoServer | undefined; 34 | let androidAutoIpcServiceRegistry: IpcServiceRegistry | undefined; 35 | 36 | if (config.androidAuto !== undefined) { 37 | androidAutoIpcServiceRegistry = new IpcServiceRegistry((events) => { 38 | return [ 39 | new ElectronIpcServiceRegistrySocketHandler( 40 | new DummyIpcSerializer(), 41 | config.registryName, 42 | events, 43 | ), 44 | ]; 45 | }); 46 | 47 | androidAutoIpcServiceRegistry.register(); 48 | 49 | const builder = new NodeAndroidAutoServerBuilder( 50 | androidAutoIpcServiceRegistry, 51 | config.androidAuto, 52 | ); 53 | 54 | androidAutoServer = builder.buildAndroidAutoServer(); 55 | 56 | androidAutoServer.start().catch((err) => { 57 | logger.error('Failed to start android auto server', err); 58 | }); 59 | } 60 | 61 | const electronWindowBuilder = new ElectronWindowBuilder( 62 | config.electronWindowBuilder, 63 | ); 64 | 65 | app.commandLine.appendSwitch('--enable-features', 'OverlayScrollbar'); 66 | app.commandLine.appendSwitch('ignore-certificate-errors'); 67 | 68 | app.whenReady() 69 | .then(async () => { 70 | electronWindowBuilder.logDisplays(); 71 | 72 | try { 73 | await electronWindowBuilder.buildWindows(); 74 | } catch (err) { 75 | logger.error('Failed to build windows', err); 76 | } 77 | }) 78 | .catch((err) => { 79 | console.error(err); 80 | }); 81 | 82 | let cleanupRan = false; 83 | 84 | const beforeQuit = async (event: { 85 | preventDefault: () => void; 86 | readonly defaultPrevented: boolean; 87 | }) => { 88 | if (cleanupRan) { 89 | return; 90 | } 91 | 92 | event.preventDefault(); 93 | 94 | if (androidAutoServer !== undefined) { 95 | await androidAutoServer.stop(); 96 | androidAutoServer.destroy(); 97 | } 98 | 99 | if (androidAutoIpcServiceRegistry !== undefined) { 100 | androidAutoIpcServiceRegistry.unregister(); 101 | } 102 | 103 | cleanupRan = true; 104 | app.quit(); 105 | }; 106 | 107 | app.on('before-quit', (event) => { 108 | beforeQuit(event) 109 | .then(() => {}) 110 | .catch((err) => { 111 | logger.error('Failed to stop', err); 112 | }); 113 | }); 114 | 115 | app.on('window-all-closed', () => { 116 | if (process.platform !== 'darwin') { 117 | app.quit(); 118 | } 119 | }); 120 | -------------------------------------------------------------------------------- /electron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../logging" 12 | }, 13 | { 14 | "path": "../node-common" 15 | }, 16 | { 17 | "path": "../electron-ipc" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /eslint-config/base-js.mjs: -------------------------------------------------------------------------------- 1 | import stylisticPlugin from '@stylistic/eslint-plugin'; 2 | import js from '@eslint/js'; 3 | 4 | export default [ 5 | js.configs['recommended'], 6 | { 7 | plugins: { 8 | '@stylistic': stylisticPlugin, 9 | }, 10 | rules: { 11 | 'no-undef': 'off', 12 | 13 | 'linebreak-style': ['error', 'unix'], 14 | 15 | '@stylistic/indent': 'off', 16 | '@stylistic/comma-dangle': ['error', 'always-multiline'], 17 | '@stylistic/quotes': ['error', 'single'], 18 | '@stylistic/semi': ['error', 'always'], 19 | '@stylistic/no-extra-semi': ['error'], 20 | '@stylistic/space-before-function-paren': [ 21 | 'error', 22 | { 23 | anonymous: 'always', 24 | asyncArrow: 'always', 25 | named: 'never', 26 | }, 27 | ], 28 | 29 | 'no-unused-vars': [ 30 | 'error', 31 | { 32 | argsIgnorePattern: '^_', 33 | varsIgnorePattern: '^_', 34 | }, 35 | ], 36 | 37 | 'no-constant-condition': [ 38 | 'error', 39 | { 40 | checkLoops: false, 41 | }, 42 | ], 43 | }, 44 | }, 45 | { 46 | ignores: ['dist', 'eslint.config.mjs'], 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /eslint-config/base-ts.mjs: -------------------------------------------------------------------------------- 1 | import { plugin as typescriptPlugin } from 'typescript-eslint'; 2 | 3 | export default [ 4 | { 5 | plugins: { 6 | '@typescript-eslint': typescriptPlugin, 7 | }, 8 | rules: { 9 | 'no-unused-vars': 'off', 10 | '@typescript-eslint/no-unused-vars': [ 11 | 'error', 12 | { 13 | argsIgnorePattern: '^_', 14 | varsIgnorePattern: '^_', 15 | }, 16 | ], 17 | 18 | '@typescript-eslint/no-explicit-any': 'off', 19 | '@typescript-eslint/no-empty-interface': 'off', 20 | 21 | '@typescript-eslint/strict-boolean-expressions': [ 22 | 'error', 23 | { 24 | allowNullableBoolean: true, 25 | }, 26 | ], 27 | 28 | '@typescript-eslint/explicit-member-accessibility': 'error', 29 | 30 | '@typescript-eslint/no-floating-promises': 'error', 31 | }, 32 | }, 33 | ]; 34 | -------------------------------------------------------------------------------- /eslint-config/common-ts.mjs: -------------------------------------------------------------------------------- 1 | import { configs as typescriptConfigs } from 'typescript-eslint'; 2 | import baseJsConfig from './base-js.mjs'; 3 | import baseTsConfig from './base-ts.mjs'; 4 | 5 | export default [ 6 | ...typescriptConfigs['recommendedTypeChecked'], 7 | ...baseJsConfig, 8 | ...baseTsConfig, 9 | ]; 10 | -------------------------------------------------------------------------------- /eslint-config/node-js.mjs: -------------------------------------------------------------------------------- 1 | import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; 2 | import prettierRecommendedConfig from 'eslint-plugin-prettier/recommended'; 3 | import baseJsConfig from './base-js.mjs'; 4 | 5 | export default [ 6 | ...baseJsConfig, 7 | prettierRecommendedConfig, 8 | { 9 | plugins: { 10 | 'simple-import-sort': simpleImportSortPlugin, 11 | }, 12 | rules: { 13 | 'simple-import-sort/imports': 'error', 14 | 'simple-import-sort/exports': 'error', 15 | }, 16 | }, 17 | ]; 18 | -------------------------------------------------------------------------------- /eslint-config/node-ts.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import { configs as typescriptConfigs } from 'typescript-eslint'; 3 | import typescriptParser from '@typescript-eslint/parser'; 4 | import nodeJsConfigs from './node-js.mjs'; 5 | import baseTsConfig from './base-ts.mjs'; 6 | 7 | export default [ 8 | ...typescriptConfigs['recommendedTypeChecked'], 9 | ...nodeJsConfigs, 10 | ...baseTsConfig, 11 | { 12 | languageOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | globals: { 16 | ...globals.node, 17 | }, 18 | parser: typescriptParser, 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | project: 'tsconfig.json', 22 | }, 23 | }, 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/eslint-config", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "exports": { 7 | "./common-ts": "./common-ts.mjs", 8 | "./node-ts": "./node-ts.mjs", 9 | "./node-js": "./node-js.mjs" 10 | }, 11 | "scripts": { 12 | "build": "", 13 | "clean": "" 14 | }, 15 | "devDependencies": { 16 | "@stylistic/eslint-plugin": "^2.9.0", 17 | "@typescript-eslint/eslint-plugin": "^8.8.0", 18 | "@typescript-eslint/parser": "^8.8.0", 19 | "eslint-config-prettier": "^9.0.0", 20 | "eslint-plugin-simple-import-sort": "^12.1.1", 21 | "prettier": "^3.0.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /logging/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /logging/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/logging", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "exports": { 8 | "import": "./dist/esm/index.js", 9 | "types": "./dist/esm/index.d.ts" 10 | }, 11 | "scripts": { 12 | "build": "tspc", 13 | "clean": "tspc --build --clean", 14 | "lint": "eslint --fix ." 15 | }, 16 | "dependencies": { 17 | "winston": "^3.10.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /logging/src/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { inspect } from 'node:util'; 3 | 4 | import { type TransformableInfo } from 'logform'; 5 | import { format, Logger, loggers, transports } from 'winston'; 6 | 7 | export const LOGGER_NAME = 'logger'; 8 | 9 | const LOG_PATH = resolve(import.meta.dirname, '..', '..', '..'); 10 | 11 | type TransformableInfoWithMetadata = TransformableInfo & { 12 | metadata: { 13 | [key: string | symbol]: any; 14 | }; 15 | }; 16 | 17 | const printfCommon = (i: TransformableInfoWithMetadata, colors: boolean) => { 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 19 | const { timestamp, level, label, message, metadata } = i; 20 | let m; 21 | if (metadata !== undefined) { 22 | m = 23 | '\n' + 24 | inspect(metadata, { 25 | sorted: true, 26 | showHidden: false, 27 | depth: null, 28 | maxArrayLength: null, 29 | maxStringLength: null, 30 | colors, 31 | }); 32 | } else { 33 | m = ''; 34 | } 35 | return `${timestamp} ${level} [${label}] ${message}${m}`; 36 | }; 37 | 38 | const printfConsole = (i: TransformableInfo) => { 39 | return printfCommon(i as TransformableInfoWithMetadata, true); 40 | }; 41 | 42 | const printfFile = (i: TransformableInfo) => { 43 | return printfCommon(i as TransformableInfoWithMetadata, false); 44 | }; 45 | 46 | const consoleTransport = new transports.Console({ 47 | format: format.combine( 48 | format.timestamp(), 49 | format.cli(), 50 | format.colorize(), 51 | format.errors({ stack: true }), 52 | format.printf(printfConsole), 53 | ), 54 | }); 55 | 56 | consoleTransport.setMaxListeners(Infinity); 57 | 58 | const fileTransport = new transports.File({ 59 | options: { flags: 'w' }, 60 | format: format.combine( 61 | format.timestamp(), 62 | format.errors({ stack: true }), 63 | format.printf(printfFile), 64 | ), 65 | dirname: LOG_PATH, 66 | filename: 'web-auto.log', 67 | }); 68 | 69 | fileTransport.setMaxListeners(Infinity); 70 | 71 | export interface LoggingConfig { 72 | debug: boolean | string[]; 73 | } 74 | 75 | let config: LoggingConfig = { 76 | debug: false, 77 | }; 78 | 79 | export const setConfig = (newConfig: LoggingConfig) => { 80 | config = newConfig; 81 | }; 82 | 83 | export class LoggerWrapper { 84 | public constructor( 85 | private logger: Logger, 86 | public debuggable: boolean, 87 | ) {} 88 | 89 | public error(message: string, metadata?: any): void { 90 | this.logger.error(message, { 91 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 92 | metadata, 93 | }); 94 | } 95 | 96 | public info(message: string, metadata?: any): void { 97 | this.logger.info(message, { 98 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 99 | metadata, 100 | }); 101 | } 102 | 103 | public debug(message: string, metadata?: any): void { 104 | if (!this.debuggable) { 105 | return; 106 | } 107 | 108 | this.logger.debug(message, { 109 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 110 | metadata, 111 | }); 112 | } 113 | } 114 | 115 | export const getLogger = (label: string): LoggerWrapper => { 116 | let debug; 117 | 118 | if (typeof config.debug === 'boolean') { 119 | debug = config.debug; 120 | } else { 121 | debug = config.debug.includes(label); 122 | } 123 | 124 | if (!loggers.has(label)) { 125 | loggers.add(label, { 126 | transports: [consoleTransport, fileTransport], 127 | level: 'debug', 128 | format: format.label({ label }), 129 | }); 130 | } 131 | 132 | return new LoggerWrapper(loggers.get(label), debug); 133 | }; 134 | -------------------------------------------------------------------------------- /logging/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /node-common/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /node-common/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /node-common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/node-common", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tspc", 9 | "clean": "tspc --build --clean", 10 | "lint": "eslint --fix ." 11 | }, 12 | "types": "./dist/esm/index.d.ts", 13 | "exports": { 14 | ".": "./dist/esm/index.js", 15 | "./ipc.js": "./dist/esm/ipc.js" 16 | }, 17 | "dependencies": { 18 | "@ddc-node/ddc-node": "github:Demon000/ddc-node#main", 19 | "@yume-chan/scrcpy": "^0.0.23", 20 | "arpping": "^4.0.0", 21 | "async-mutex": "^0.5.0", 22 | "audify": "^1.9.0", 23 | "bluetooth-socket": "npm:@tanislav000/bluetooth-socket@^0.6.0", 24 | "bluez": "npm:@tanislav000/bluez@^1.6.0", 25 | "json5": "^2.2.3", 26 | "koffi": "^2.8.8", 27 | "native-duplexpair": "^1.0.0", 28 | "tinyh264": "^0.0.7", 29 | "usb": "^2.13.0" 30 | }, 31 | "optionalDependencies": { 32 | "@ddc-node/ddc-node-linux-x64-gnu": "^1.0.3", 33 | "@ddc-node/ddc-node-linux-arm64-gnu": "^1.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /node-common/src/NodeAndroidAutoServer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AndroidAutoServer, 3 | type AndroidAutoServerBuilder, 4 | Device, 5 | DeviceConnectReason, 6 | GenericDeviceDisconnectReason, 7 | } from '@web-auto/android-auto'; 8 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 9 | 10 | export interface IDevice { 11 | prefix: string; 12 | name: string; 13 | realName: string; 14 | state: string; 15 | uniqueId: string; 16 | } 17 | 18 | export type AndroidAutoServerService = { 19 | connectDeviceName(name: string): Promise; 20 | disconnectDeviceName(name: string): Promise; 21 | getDevices(): Promise; 22 | }; 23 | 24 | export type AndroidAutoServerClient = { 25 | devices: (devices: IDevice[]) => void; 26 | }; 27 | 28 | export class NodeAndroidAutoServer extends AndroidAutoServer { 29 | public constructor( 30 | builder: AndroidAutoServerBuilder, 31 | protected ipcHandler: IpcServiceHandler< 32 | AndroidAutoServerService, 33 | AndroidAutoServerClient 34 | >, 35 | ) { 36 | super(builder); 37 | 38 | this.ipcHandler.on( 39 | 'connectDeviceName', 40 | this.connectDeviceName.bind(this), 41 | ); 42 | this.ipcHandler.on( 43 | 'disconnectDeviceName', 44 | this.disconnectDeviceName.bind(this), 45 | ); 46 | 47 | this.ipcHandler.on('getDevices', this.getDevicesObjects.bind(this)); 48 | } 49 | 50 | public override destroy(): void { 51 | this.ipcHandler.off('connectDeviceName'); 52 | this.ipcHandler.off('disconnectDeviceName'); 53 | this.ipcHandler.off('getDevices'); 54 | } 55 | 56 | protected deviceFromImpl(device: Device): IDevice { 57 | return { 58 | name: device.name, 59 | prefix: device.prefix, 60 | realName: device.realName, 61 | state: device.state, 62 | uniqueId: device.uniqueId, 63 | }; 64 | } 65 | 66 | protected devicesFromImpl(devices: Device[]): IDevice[] { 67 | const ipcDevices: IDevice[] = []; 68 | for (const device of devices) { 69 | ipcDevices.push(this.deviceFromImpl(device)); 70 | } 71 | 72 | return ipcDevices; 73 | } 74 | 75 | // eslint-disable-next-line @typescript-eslint/require-await 76 | public async getDevicesObjects(): Promise { 77 | const devices = this.getDevices(); 78 | return this.devicesFromImpl(devices); 79 | } 80 | 81 | public async connectDeviceName(name: string): Promise { 82 | const device = this.getDeviceByName(name); 83 | if (device === undefined) { 84 | throw new Error(`Unknown device ${name}`); 85 | } 86 | 87 | await this.connectDeviceAsync(device, DeviceConnectReason.USER); 88 | } 89 | 90 | public async disconnectDeviceName(name: string): Promise { 91 | const device = this.getDeviceByName(name); 92 | if (device === undefined) { 93 | throw new Error(`Unknown device ${name}`); 94 | } 95 | 96 | await this.disconnectDeviceAsync( 97 | device, 98 | GenericDeviceDisconnectReason.USER, 99 | ); 100 | } 101 | 102 | protected onDevicesUpdatedCallback(devices: Device[]): void { 103 | const ipcDevices = this.devicesFromImpl(devices); 104 | this.ipcHandler.devices(ipcDevices); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /node-common/src/codec/codec.ts: -------------------------------------------------------------------------------- 1 | export interface CodecParsedConfig { 2 | croppedWidth: number; 3 | croppedHeight: number; 4 | cropLeft: number; 5 | cropRight: number; 6 | cropTop: number; 7 | cropBottom: number; 8 | codec: string; 9 | } 10 | -------------------------------------------------------------------------------- /node-common/src/codec/h264.ts: -------------------------------------------------------------------------------- 1 | import { annexBSplitNalu, h264ParseConfiguration } from '@yume-chan/scrcpy'; 2 | 3 | import { toHex } from '../utils.js'; 4 | import type { CodecParsedConfig } from './codec.js'; 5 | 6 | export const h264HasKeyFrame = (buffer: Uint8Array) => { 7 | for (const nalu of annexBSplitNalu(buffer)) { 8 | const naluType = nalu[0]! & 0x1f; 9 | 10 | if (naluType === 5) { 11 | return true; 12 | } 13 | } 14 | 15 | return false; 16 | }; 17 | 18 | export const parseH264CodecConfig = (buffer: Uint8Array): CodecParsedConfig => { 19 | const { 20 | profileIndex, 21 | constraintSet, 22 | levelIndex, 23 | cropLeft, 24 | cropRight, 25 | cropTop, 26 | cropBottom, 27 | croppedWidth, 28 | croppedHeight, 29 | } = h264ParseConfiguration(buffer); 30 | 31 | const codec = `avc1.${[profileIndex, constraintSet, levelIndex] 32 | .map((num) => toHex(num, 2)) 33 | .join('')}`; 34 | 35 | return { 36 | codec, 37 | cropLeft, 38 | cropRight, 39 | cropTop, 40 | cropBottom, 41 | croppedWidth, 42 | croppedHeight, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /node-common/src/codec/h265.ts: -------------------------------------------------------------------------------- 1 | import { 2 | annexBSplitNalu, 3 | h265ParseConfiguration, 4 | h265ParseNaluHeader, 5 | } from '@yume-chan/scrcpy'; 6 | 7 | import type { CodecParsedConfig } from './codec.js'; 8 | 9 | export const toUint32Le = (data: Uint8Array, offset: number) => { 10 | return ( 11 | data[offset]! | 12 | (data[offset + 1]! << 8) | 13 | (data[offset + 2]! << 16) | 14 | (data[offset + 3]! << 24) 15 | ); 16 | }; 17 | 18 | export const h265HasKeyFrame = (buffer: Uint8Array) => { 19 | for (const nalu of annexBSplitNalu(buffer)) { 20 | const header = h265ParseNaluHeader(nalu); 21 | 22 | if (header.nal_unit_type === 19 || header.nal_unit_type === 20) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | }; 29 | 30 | export const parseH265CodecConfig = (buffer: Uint8Array): CodecParsedConfig => { 31 | const { 32 | generalProfileSpace, 33 | generalProfileIndex, 34 | generalProfileCompatibilitySet, 35 | generalTierFlag, 36 | generalLevelIndex, 37 | generalConstraintSet, 38 | cropLeft, 39 | cropRight, 40 | cropTop, 41 | cropBottom, 42 | croppedWidth, 43 | croppedHeight, 44 | } = h265ParseConfiguration(buffer); 45 | 46 | const codec = [ 47 | 'hev1', 48 | ['', 'A', 'B', 'C'][generalProfileSpace]! + 49 | generalProfileIndex.toString(), 50 | toUint32Le(generalProfileCompatibilitySet, 0).toString(16), 51 | (generalTierFlag ? 'H' : 'L') + generalLevelIndex.toString(), 52 | toUint32Le(generalConstraintSet, 0).toString(16).toUpperCase(), 53 | toUint32Le(generalConstraintSet, 4).toString(16).toUpperCase(), 54 | ].join('.'); 55 | 56 | return { 57 | codec, 58 | cropLeft, 59 | cropRight, 60 | cropTop, 61 | cropBottom, 62 | croppedWidth, 63 | croppedHeight, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /node-common/src/codec/index.ts: -------------------------------------------------------------------------------- 1 | import { MediaCodecType } from '@web-auto/android-auto-proto'; 2 | 3 | import type { CodecParsedConfig } from './codec.js'; 4 | import { h264HasKeyFrame, parseH264CodecConfig } from './h264.js'; 5 | import { h265HasKeyFrame, parseH265CodecConfig } from './h265.js'; 6 | 7 | export const parseCodecConfig = ( 8 | videoCodecType: MediaCodecType, 9 | buffer: Uint8Array, 10 | ): CodecParsedConfig => { 11 | switch (videoCodecType) { 12 | case MediaCodecType.MEDIA_CODEC_VIDEO_H264_BP: 13 | return parseH264CodecConfig(buffer); 14 | case MediaCodecType.MEDIA_CODEC_VIDEO_H265: 15 | return parseH265CodecConfig(buffer); 16 | default: 17 | throw new Error( 18 | `Media codec ${MediaCodecType[videoCodecType]} unimplemented`, 19 | ); 20 | } 21 | }; 22 | 23 | export const hasKeyFrame = ( 24 | videoCodecType: MediaCodecType, 25 | buffer: Uint8Array, 26 | ): boolean => { 27 | switch (videoCodecType) { 28 | case MediaCodecType.MEDIA_CODEC_VIDEO_H264_BP: 29 | return h264HasKeyFrame(buffer); 30 | case MediaCodecType.MEDIA_CODEC_VIDEO_H265: 31 | return h265HasKeyFrame(buffer); 32 | default: 33 | throw new Error( 34 | `Media codec ${MediaCodecType[videoCodecType]} unimplemented`, 35 | ); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /node-common/src/config.ts: -------------------------------------------------------------------------------- 1 | import type { ControlServiceConfig } from '@web-auto/android-auto'; 2 | import { type LoggingConfig } from '@web-auto/logging'; 3 | 4 | import type { NodeCryptorConfig } from './crypto/NodeCryptor.js'; 5 | import type { NodeAudioInputServiceConfig } from './services/NodeAudioInputService.js'; 6 | import type { NodeAudioOutputServiceConfig } from './services/NodeAudioOutputService.js'; 7 | import type { NodeDdcBrightnessServiceConfig } from './services/NodeDdcBrightnessService.js'; 8 | import type { NodeInputServiceConfig } from './services/NodeInputService.js'; 9 | import type { NodeRtAudioInputServiceConfig } from './services/NodeRtAudioInputService.js'; 10 | import type { NodeRtAudioOutputServiceConfig } from './services/NodeRtAudioOutputService.js'; 11 | import type { NodeSensorServiceConfig } from './services/NodeSensorService.js'; 12 | import type { NodeVideoServiceConfig } from './services/NodeVideoService.js'; 13 | import type { BluetoothDeviceHandlerConfig } from './transport/bluetooth/BluetoothDeviceHandler.js'; 14 | import type { TcpDeviceHandlerConfig } from './transport/tcp/TcpDeviceHandler.js'; 15 | import type { UsbDeviceHandlerConfig } from './transport/usb/UsbDeviceHandler.js'; 16 | 17 | export interface NodeAndroidAutoServerConfig { 18 | serverIpcName: string; 19 | deviceHandlers: ( 20 | | ({ name: 'UsbDeviceHandler' } & UsbDeviceHandlerConfig) 21 | | ({ name: 'TcpDeviceHandler' } & TcpDeviceHandlerConfig) 22 | | ({ name: 'BluetoothDeviceHandler' } & BluetoothDeviceHandlerConfig) 23 | )[]; 24 | cryptor: 25 | | ({ 26 | name: 'NodeCryptor'; 27 | } & NodeCryptorConfig) 28 | | { name: 'OpenSSLCryptor' }; 29 | controlService: { 30 | name: 'ControlService'; 31 | } & ControlServiceConfig; 32 | services: ( 33 | | ({ 34 | name: 'NodeSensorService'; 35 | } & NodeSensorServiceConfig) 36 | | ({ 37 | name: 'NodeAudioInputService'; 38 | } & NodeAudioInputServiceConfig) 39 | | ({ 40 | name: 'NodeRtAudioInputService'; 41 | } & NodeRtAudioInputServiceConfig) 42 | | ({ 43 | name: 'NodeAudioOutputService'; 44 | ipcName: string; 45 | } & NodeAudioOutputServiceConfig) 46 | | ({ 47 | name: 'NodeRtAudioOutputService'; 48 | ipcName: string; 49 | } & NodeRtAudioOutputServiceConfig) 50 | | ({ 51 | name: 'NodeVideoService'; 52 | ipcName: string; 53 | } & NodeVideoServiceConfig) 54 | | ({ 55 | name: 'NodeInputService'; 56 | ipcName: string; 57 | } & NodeInputServiceConfig) 58 | | { name: 'NodeNavigationStatusService' } 59 | | { name: 'NodeMediaStatusService'; ipcName: string } 60 | | ({ 61 | name: 'NodeDdcBrightnessService'; 62 | ipcName: string; 63 | } & NodeDdcBrightnessServiceConfig) 64 | )[]; 65 | } 66 | 67 | export interface NodeCommonAndroidAutoConfig { 68 | registryName: string; 69 | logging: LoggingConfig; 70 | androidAuto?: NodeAndroidAutoServerConfig; 71 | } 72 | -------------------------------------------------------------------------------- /node-common/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | NodeAndroidAutoServerConfig, 3 | NodeCommonAndroidAutoConfig, 4 | } from './config.js'; 5 | export { NodeAndroidAutoServer } from './NodeAndroidAutoServer.js'; 6 | export { NodeAndroidAutoServerBuilder } from './NodeAndroidAutoServerBuilder.js'; 7 | -------------------------------------------------------------------------------- /node-common/src/ipc.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | AndroidAutoServerClient, 3 | AndroidAutoServerService, 4 | IDevice, 5 | } from './NodeAndroidAutoServer.js'; 6 | export type { 7 | AndroidAutoAudioOutputClient, 8 | AndroidAutoAudioOutputService, 9 | } from './services/NodeAudioOutputService.js'; 10 | export type { 11 | AndroidAutoBrightnessClient, 12 | AndroidAutoBrightnessService, 13 | } from './services/NodeBrightnessService.js'; 14 | export type { 15 | AndroidAutoInputClient, 16 | AndroidAutoInputService, 17 | } from './services/NodeInputService.js'; 18 | export type { 19 | AndroidAutoMediaStatusClient, 20 | AndroidAutoMediaStatusService, 21 | } from './services/NodeMediaStatusService.js'; 22 | export type { 23 | AndroidAutoVideoClient, 24 | AndroidAutoVideoService, 25 | VideoCodecConfig, 26 | } from './services/NodeVideoService.js'; 27 | -------------------------------------------------------------------------------- /node-common/src/sensors/DummyDrivingStatusSensor.ts: -------------------------------------------------------------------------------- 1 | import { Sensor, type SensorEvents } from '@web-auto/android-auto'; 2 | import { 3 | DrivingStatus, 4 | SensorBatch, 5 | SensorType, 6 | } from '@web-auto/android-auto-proto'; 7 | 8 | export interface DummyDrivingStatusSensorConfig { 9 | status: DrivingStatus; 10 | } 11 | 12 | export class DummyDrivingStatusSensor extends Sensor { 13 | public constructor( 14 | private config: DummyDrivingStatusSensorConfig, 15 | events: SensorEvents, 16 | ) { 17 | super(SensorType.SENSOR_DRIVING_STATUS_DATA, events); 18 | } 19 | 20 | public emit(): void { 21 | this.events.onData( 22 | new SensorBatch({ 23 | drivingStatusData: [ 24 | { 25 | status: this.config.status, 26 | }, 27 | ], 28 | }), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /node-common/src/sensors/DummyNightDataSensor.ts: -------------------------------------------------------------------------------- 1 | import { Sensor, type SensorEvents } from '@web-auto/android-auto'; 2 | import { SensorBatch, SensorType } from '@web-auto/android-auto-proto'; 3 | 4 | export interface DummyNightDataSensorConfig { 5 | nightMode: boolean; 6 | } 7 | 8 | export class DummyNightDataSensor extends Sensor { 9 | public constructor( 10 | private config: DummyNightDataSensorConfig, 11 | events: SensorEvents, 12 | ) { 13 | super(SensorType.SENSOR_NIGHT_MODE, events); 14 | } 15 | 16 | public emit(): void { 17 | this.events.onData( 18 | new SensorBatch({ 19 | nightModeData: [ 20 | { 21 | nightMode: this.config.nightMode, 22 | }, 23 | ], 24 | }), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /node-common/src/services/NodeAudioInputService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioInputService, 3 | type AudioInputServiceConfig, 4 | type ServiceEvents, 5 | } from '@web-auto/android-auto'; 6 | import { MicrophoneRequest } from '@web-auto/android-auto-proto'; 7 | 8 | export interface NodeAudioInputServiceConfig extends AudioInputServiceConfig {} 9 | 10 | export class NodeAudioInputService extends AudioInputService { 11 | public constructor( 12 | protected override config: NodeAudioInputServiceConfig, 13 | events: ServiceEvents, 14 | ) { 15 | super(config, events); 16 | 17 | this.session = 0; 18 | } 19 | 20 | protected inputOpen(_data: MicrophoneRequest): void {} 21 | } 22 | -------------------------------------------------------------------------------- /node-common/src/services/NodeAudioOutputService.ts: -------------------------------------------------------------------------------- 1 | import { AudioOutputService, type ServiceEvents } from '@web-auto/android-auto'; 2 | import { AudioStreamType } from '@web-auto/android-auto-proto'; 3 | import { 4 | type IAudioConfiguration, 5 | stringToAudioStreamType, 6 | } from '@web-auto/android-auto-proto/interfaces.js'; 7 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 8 | 9 | export interface NodeAudioOutputServiceConfig { 10 | audioType: AudioStreamType | string; 11 | configs: IAudioConfiguration[]; 12 | } 13 | 14 | export type AndroidAutoAudioOutputClient = Record; 15 | 16 | export type AndroidAutoAudioOutputService = { 17 | setVolume: (volume: number) => Promise; 18 | getVolume: () => Promise; 19 | }; 20 | 21 | export class NodeAudioOutputService extends AudioOutputService { 22 | public constructor( 23 | private ipcHandler: IpcServiceHandler< 24 | AndroidAutoAudioOutputService, 25 | AndroidAutoAudioOutputClient 26 | >, 27 | config: NodeAudioOutputServiceConfig, 28 | events: ServiceEvents, 29 | ) { 30 | super( 31 | { 32 | ...config, 33 | audioType: stringToAudioStreamType(config.audioType), 34 | }, 35 | events, 36 | ); 37 | 38 | ipcHandler.on('getVolume', this.getVolume.bind(this)); 39 | ipcHandler.on('setVolume', this.setVolume.bind(this)); 40 | } 41 | 42 | public override destroy(): void { 43 | this.ipcHandler.off('getVolume'); 44 | this.ipcHandler.off('setVolume'); 45 | } 46 | 47 | // eslint-disable-next-line @typescript-eslint/require-await 48 | protected async getVolume(): Promise { 49 | return 1; 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/require-await 53 | protected async setVolume(_volume: number): Promise { 54 | throw new Error('Not implemented'); 55 | } 56 | 57 | protected handleData(_buffer: Uint8Array, _timestamp?: bigint): void {} 58 | } 59 | -------------------------------------------------------------------------------- /node-common/src/services/NodeBrightnessService.ts: -------------------------------------------------------------------------------- 1 | import { Service, type ServiceEvents } from '@web-auto/android-auto'; 2 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 3 | 4 | export type AndroidAutoBrightnessClient = Record; 5 | 6 | export type AndroidAutoBrightnessService = { 7 | setBrightness: (brightness: number) => Promise; 8 | getBrightness: () => Promise; 9 | }; 10 | 11 | export abstract class NodeBrightnessService extends Service { 12 | public constructor( 13 | private ipcHandler: IpcServiceHandler< 14 | AndroidAutoBrightnessService, 15 | AndroidAutoBrightnessClient 16 | >, 17 | events: ServiceEvents, 18 | ) { 19 | super(events); 20 | 21 | this.ipcHandler.on('getBrightness', this.getBrightness.bind(this)); 22 | this.ipcHandler.on('setBrightness', this.setBrightness.bind(this)); 23 | } 24 | 25 | public override destroy(): void { 26 | this.ipcHandler.off('getBrightness'); 27 | this.ipcHandler.off('setBrightness'); 28 | } 29 | 30 | protected abstract getBrightness(): Promise; 31 | protected abstract setBrightness(value: number): Promise; 32 | } 33 | -------------------------------------------------------------------------------- /node-common/src/services/NodeDdcBrightnessService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Continuous, 3 | Display, 4 | DisplayManager, 5 | VCPFeatureCode, 6 | VcpValueType, 7 | } from '@ddc-node/ddc-node'; 8 | import type { ServiceEvents } from '@web-auto/android-auto'; 9 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 10 | import assert from 'assert'; 11 | 12 | import { 13 | type AndroidAutoBrightnessClient, 14 | type AndroidAutoBrightnessService, 15 | NodeBrightnessService, 16 | } from './NodeBrightnessService.js'; 17 | 18 | export type NodeDdcBrightnessServiceConfig = { 19 | serialNumber: string; 20 | }; 21 | 22 | type LocalIpcHandler = IpcServiceHandler< 23 | AndroidAutoBrightnessService, 24 | AndroidAutoBrightnessClient 25 | >; 26 | 27 | export class NodeDdcBrightnessService extends NodeBrightnessService { 28 | private maxBrightness: number = 0; 29 | private display: Display | undefined; 30 | 31 | public constructor( 32 | private config: NodeDdcBrightnessServiceConfig, 33 | ipcHandler: LocalIpcHandler, 34 | events: ServiceEvents, 35 | ) { 36 | super(ipcHandler, events); 37 | } 38 | 39 | public override async init(): Promise { 40 | const displayManager = new DisplayManager(); 41 | const displays = await displayManager.collect(); 42 | let foundDisplay; 43 | 44 | for (const display of displays) { 45 | if (display.serialNumber !== this.config.serialNumber) { 46 | continue; 47 | } 48 | 49 | foundDisplay = display; 50 | break; 51 | } 52 | 53 | if (foundDisplay === undefined) { 54 | throw new Error( 55 | 'Failed to find display with serial number ' + 56 | this.config.serialNumber, 57 | ); 58 | } 59 | 60 | this.display = foundDisplay; 61 | 62 | await this.updateMaxBrightness(); 63 | } 64 | 65 | protected async getBrightnessValue(): Promise { 66 | assert(this.display !== undefined); 67 | const value = await this.display.getVcpFeature( 68 | VCPFeatureCode.ImageAdjustment.Luminance, 69 | ); 70 | assert(value.type == VcpValueType.Continuous); 71 | return value; 72 | } 73 | 74 | protected async updateMaxBrightness(): Promise { 75 | const value = await this.getBrightnessValue(); 76 | this.maxBrightness = value.maximumValue; 77 | } 78 | 79 | protected override async getBrightness(): Promise { 80 | const value = await this.getBrightnessValue(); 81 | return value.currentValue / value.maximumValue; 82 | } 83 | 84 | protected override async setBrightness(value: number): Promise { 85 | assert(this.display !== undefined); 86 | await this.display.setVcpFeature( 87 | VCPFeatureCode.ImageAdjustment.Luminance, 88 | value * this.maxBrightness, 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /node-common/src/services/NodeInputService.ts: -------------------------------------------------------------------------------- 1 | import { InputService, type ServiceEvents } from '@web-auto/android-auto'; 2 | import { 3 | InputSourceService, 4 | InputSourceService_TouchScreen, 5 | KeyBindingRequest, 6 | KeyCode, 7 | KeyEvent, 8 | Service, 9 | TouchEvent, 10 | TouchScreenType, 11 | } from '@web-auto/android-auto-proto'; 12 | import { 13 | type ITouchEvent, 14 | stringToKeycode, 15 | stringToTouchscreenType, 16 | } from '@web-auto/android-auto-proto/interfaces.js'; 17 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 18 | 19 | export type AndroidAutoInputService = { 20 | sendTouchEvent: (touchEvent: ITouchEvent) => Promise; 21 | sendKey: (keycode: string | KeyCode) => Promise; 22 | }; 23 | 24 | export type AndroidAutoInputClient = Record; 25 | 26 | export type NodeInputServiceConfig = { 27 | displayId: number; 28 | touchscreen?: { 29 | width: number; 30 | height: number; 31 | type: TouchScreenType | string; 32 | }; 33 | }; 34 | 35 | export class NodeInputService extends InputService { 36 | public constructor( 37 | private ipcHandler: IpcServiceHandler< 38 | AndroidAutoInputService, 39 | AndroidAutoInputClient 40 | >, 41 | private config: NodeInputServiceConfig, 42 | events: ServiceEvents, 43 | ) { 44 | super(events); 45 | 46 | this.ipcHandler.on( 47 | 'sendTouchEvent', 48 | this.sendTouchEventObject.bind(this), 49 | ); 50 | 51 | this.ipcHandler.on('sendKey', this.sendKey.bind(this)); 52 | } 53 | 54 | public override destroy(): void { 55 | this.ipcHandler.off('sendKey'); 56 | } 57 | 58 | protected async bind(_data: KeyBindingRequest): Promise {} 59 | 60 | // eslint-disable-next-line @typescript-eslint/require-await 61 | private async sendTouchEventObject(data: ITouchEvent): Promise { 62 | this.sendTouchEvent(new TouchEvent(data)); 63 | } 64 | 65 | // eslint-disable-next-line @typescript-eslint/require-await 66 | private async sendKey(keycode: string | KeyCode): Promise { 67 | keycode = stringToKeycode(keycode); 68 | 69 | this.sendKeyEvent( 70 | new KeyEvent({ 71 | keys: [ 72 | { 73 | down: true, 74 | keycode, 75 | metastate: 0, 76 | }, 77 | { 78 | down: false, 79 | keycode, 80 | metastate: 0, 81 | }, 82 | ], 83 | }), 84 | ); 85 | } 86 | 87 | protected fillKeycodes(inputSourceService: InputSourceService): void { 88 | for (const keyCode of Object.values(KeyCode)) { 89 | if (typeof keyCode === 'number') { 90 | inputSourceService.keycodesSupported.push(keyCode); 91 | } 92 | } 93 | } 94 | 95 | protected override fillChannelDescriptor(channelDescriptor: Service): void { 96 | channelDescriptor.inputSourceService = new InputSourceService({ 97 | displayId: this.config.displayId, 98 | }); 99 | 100 | if (this.config.touchscreen !== undefined) { 101 | channelDescriptor.inputSourceService.touchscreen.push( 102 | new InputSourceService_TouchScreen({ 103 | width: this.config.touchscreen.width, 104 | height: this.config.touchscreen.height, 105 | type: stringToTouchscreenType(this.config.touchscreen.type), 106 | isSecondary: false, 107 | }), 108 | ); 109 | } 110 | 111 | this.fillKeycodes(channelDescriptor.inputSourceService); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /node-common/src/services/NodeMediaStatusService.ts: -------------------------------------------------------------------------------- 1 | import { MediaStatusService, type ServiceEvents } from '@web-auto/android-auto'; 2 | import { 3 | MediaPlaybackMetadata, 4 | MediaPlaybackStatus, 5 | } from '@web-auto/android-auto-proto'; 6 | import type { 7 | IMediaPlaybackMetadata, 8 | IMediaPlaybackStatus, 9 | } from '@web-auto/android-auto-proto/interfaces.js'; 10 | import type { IpcServiceHandler } from '@web-auto/common-ipc/main.js'; 11 | 12 | export type AndroidAutoMediaStatusService = { 13 | getStatus(): Promise; 14 | getMetadata(): Promise; 15 | }; 16 | 17 | export type AndroidAutoMediaStatusClient = { 18 | status(status: IMediaPlaybackStatus | undefined): void; 19 | metadata(metadata: IMediaPlaybackMetadata | undefined): void; 20 | }; 21 | 22 | export class NodeMediaStatusService extends MediaStatusService { 23 | private metadata: IMediaPlaybackMetadata | undefined; 24 | private status: IMediaPlaybackStatus | undefined; 25 | 26 | public constructor( 27 | private ipcHandler: IpcServiceHandler< 28 | AndroidAutoMediaStatusService, 29 | AndroidAutoMediaStatusClient 30 | >, 31 | events: ServiceEvents, 32 | ) { 33 | super(events); 34 | 35 | this.ipcHandler.on('getMetadata', this.getMetadata.bind(this)); 36 | this.ipcHandler.on('getStatus', this.getStatus.bind(this)); 37 | } 38 | 39 | public override destroy(): void { 40 | this.ipcHandler.off('getMetadata'); 41 | this.ipcHandler.off('getStatus'); 42 | } 43 | 44 | public override stop(): void { 45 | super.stop(); 46 | this.metadata = undefined; 47 | this.ipcHandler.metadata(undefined); 48 | this.status = undefined; 49 | this.ipcHandler.status(undefined); 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/require-await 53 | protected async getMetadata(): Promise { 54 | return this.metadata; 55 | } 56 | 57 | // eslint-disable-next-line @typescript-eslint/require-await 58 | protected async getStatus(): Promise { 59 | return this.status; 60 | } 61 | 62 | // eslint-disable-next-line @typescript-eslint/require-await 63 | protected async handleMetadata(data: MediaPlaybackMetadata): Promise { 64 | this.metadata = { 65 | ...data, 66 | }; 67 | this.ipcHandler.metadata(this.metadata); 68 | } 69 | 70 | // eslint-disable-next-line @typescript-eslint/require-await 71 | protected async handlePlayback(data: MediaPlaybackStatus): Promise { 72 | this.status = { 73 | ...data, 74 | }; 75 | this.ipcHandler.status(this.status); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /node-common/src/services/NodeNavigationService.ts: -------------------------------------------------------------------------------- 1 | import { NavigationStatusService } from '@web-auto/android-auto'; 2 | import { 3 | NavigationCurrentPosition, 4 | NavigationNextTurnDistanceEvent, 5 | NavigationNextTurnEvent, 6 | NavigationState, 7 | NavigationStatus, 8 | NavigationStatusService as NavigationStatusServiceProto, 9 | NavigationStatusService_InstrumentClusterType, 10 | Service, 11 | } from '@web-auto/android-auto-proto'; 12 | 13 | export class NodeNavigationStatusService extends NavigationStatusService { 14 | protected async handleCurrentPosition( 15 | _data: NavigationCurrentPosition, 16 | ): Promise { 17 | // TODO 18 | } 19 | 20 | protected async handleState(_data: NavigationState): Promise { 21 | // TODO 22 | } 23 | 24 | protected async handleStatus(_data: NavigationStatus): Promise { 25 | // TODO 26 | } 27 | 28 | protected async handleDistance( 29 | _data: NavigationNextTurnDistanceEvent, 30 | ): Promise { 31 | // TODO 32 | } 33 | 34 | protected async handleTurn(_data: NavigationNextTurnEvent): Promise { 35 | // TODO 36 | } 37 | 38 | protected override fillChannelDescriptor(channelDescriptor: Service): void { 39 | channelDescriptor.navigationStatusService = 40 | new NavigationStatusServiceProto({ 41 | minimumIntervalMs: 1000, 42 | type: NavigationStatusService_InstrumentClusterType.IMAGE, 43 | imageOptions: { 44 | width: 256, 45 | height: 256, 46 | colourDepthBits: 16, 47 | }, 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /node-common/src/services/NodeRtAudioInputService.ts: -------------------------------------------------------------------------------- 1 | import { type ServiceEvents } from '@web-auto/android-auto'; 2 | import { 3 | ChannelOpenRequest, 4 | MicrophoneRequest, 5 | } from '@web-auto/android-auto-proto'; 6 | import RtAudioPackage from 'audify'; 7 | 8 | import { 9 | NodeAudioInputService, 10 | type NodeAudioInputServiceConfig, 11 | } from './NodeAudioInputService.js'; 12 | 13 | const RTAUDIO_SINT16 = 2; 14 | const { RtAudio } = RtAudioPackage; 15 | 16 | export interface NodeRtAudioInputServiceConfig 17 | extends NodeAudioInputServiceConfig { 18 | chunkSize: number; 19 | } 20 | 21 | export class NodeRtAudioInputService extends NodeAudioInputService { 22 | private rtaudio; 23 | 24 | public constructor( 25 | protected override config: NodeRtAudioInputServiceConfig, 26 | events: ServiceEvents, 27 | ) { 28 | super(config, events); 29 | 30 | this.rtaudio = new RtAudio(); 31 | this.session = 0; 32 | } 33 | 34 | protected override open(_data: ChannelOpenRequest): void { 35 | this.rtaudio.openStream( 36 | null, 37 | { 38 | nChannels: this.config.channelCount, 39 | }, 40 | RTAUDIO_SINT16, 41 | this.config.sampleRate, 42 | this.config.chunkSize, 43 | this.constructor.name, 44 | (data) => { 45 | this.sendAvMediaWithTimestampIndication(data); 46 | }, 47 | null, 48 | ); 49 | } 50 | 51 | public override stop(): void { 52 | super.stop(); 53 | this.rtaudio.closeStream(); 54 | } 55 | 56 | protected override inputOpen(data: MicrophoneRequest): void { 57 | if (data.open) { 58 | this.rtaudio.start(); 59 | } else { 60 | this.rtaudio.stop(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /node-common/src/services/NodeSensorService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Sensor, 3 | type SensorEvents, 4 | SensorService, 5 | type ServiceEvents, 6 | } from '@web-auto/android-auto'; 7 | 8 | import { 9 | DummyDrivingStatusSensor, 10 | type DummyDrivingStatusSensorConfig, 11 | } from '../sensors/DummyDrivingStatusSensor.js'; 12 | import { 13 | DummyNightDataSensor, 14 | type DummyNightDataSensorConfig, 15 | } from '../sensors/DummyNightDataSensor.js'; 16 | 17 | export type NodeSensorConfig = 18 | | ({ 19 | name: 'DummyNightDataSensor'; 20 | } & DummyNightDataSensorConfig) 21 | | ({ 22 | name: 'DummyDrivingStatusSensor'; 23 | } & DummyDrivingStatusSensorConfig); 24 | 25 | export type NodeSensorServiceConfig = { 26 | sensors: NodeSensorConfig[]; 27 | }; 28 | 29 | export class NodeSensorService extends SensorService { 30 | protected override sensors: Sensor[] = []; 31 | 32 | public constructor( 33 | private config: NodeSensorServiceConfig, 34 | events: ServiceEvents, 35 | ) { 36 | super(events); 37 | 38 | for (const sensorConfig of this.config.sensors) { 39 | try { 40 | const sensor = this.buildSensor(sensorConfig, { 41 | onData: this.sendEventIndication.bind(this), 42 | }); 43 | 44 | this.sensors.push(sensor); 45 | } catch (err) { 46 | this.logger.error('Failed to build sensor', err); 47 | } 48 | } 49 | } 50 | 51 | private buildSensor(entry: NodeSensorConfig, events: SensorEvents): Sensor { 52 | switch (entry.name) { 53 | case 'DummyNightDataSensor': 54 | return new DummyNightDataSensor(entry, events); 55 | case 'DummyDrivingStatusSensor': 56 | return new DummyDrivingStatusSensor(entry, events); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /node-common/src/transport/DuplexTransport.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'node:stream'; 2 | 3 | export interface DuplexTransportEvents { 4 | onData: (data: Uint8Array) => void; 5 | onError: (err: Error) => void; 6 | onDisconnected: () => void; 7 | } 8 | 9 | export class DuplexTransport { 10 | private onDataBound: (data: Uint8Array) => void; 11 | private onErrorBound: (err: Error) => void; 12 | private onCloseBound: () => void; 13 | 14 | public constructor( 15 | private socket: Duplex, 16 | private events: DuplexTransportEvents, 17 | ) { 18 | this.onDataBound = this.onData.bind(this); 19 | this.onErrorBound = this.onError.bind(this); 20 | this.onCloseBound = this.onClose.bind(this); 21 | 22 | this.attachEvents(); 23 | } 24 | 25 | private onData(data: Uint8Array): void { 26 | this.events.onData(data); 27 | } 28 | 29 | private onError(err: Error): void { 30 | this.events.onError(err); 31 | } 32 | 33 | private detachEvents(): void { 34 | this.socket.off('error', this.onErrorBound); 35 | this.socket.off('data', this.onDataBound); 36 | this.socket.off('close', this.onCloseBound); 37 | } 38 | 39 | private attachEvents(): void { 40 | this.socket.on('data', this.onDataBound); 41 | this.socket.on('error', this.onErrorBound); 42 | this.socket.on('close', this.onCloseBound); 43 | } 44 | 45 | private onClose(): void { 46 | this.detachEvents(); 47 | 48 | this.events.onDisconnected(); 49 | } 50 | 51 | public disconnect(): void { 52 | this.detachEvents(); 53 | 54 | this.socket.destroy(); 55 | } 56 | 57 | public send(buffer: Uint8Array): void { 58 | this.socket.write(buffer); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /node-common/src/transport/bluetooth/AndroidAutoProfile.ts: -------------------------------------------------------------------------------- 1 | import { BluetoothProfile } from './BluetoothProfile.js'; 2 | 3 | export const ANDROID_AUTO_UUID = '4de17a00-52cb-11e6-bdf4-0800200c9a66'; 4 | export const HSP_AG_UUID = '00001112-0000-1000-8000-00805f9b34fb'; 5 | 6 | const ANDROID_AUTO_RFCOMM_CHANNEL = 8; 7 | 8 | export enum BluetoothServiceInfoUuid { 9 | SERVICE_CLASSS_IDS = 0x0001, 10 | SERVICE_ID = 0x0003, 11 | PROTOCOL_DESCRIPTOR_LIST = 0x0004, 12 | BROWSE_GROUP_LIST = 0x0005, 13 | BLUETOOTH_PROFILE_DESCTIPTOR_LIST = 0x0009, 14 | PRIMARY_LANGUAGE_BASE = 0x0100, 15 | SERVICE_NAME = PRIMARY_LANGUAGE_BASE + 0x0000, 16 | SERVICE_DESCRIPTION = PRIMARY_LANGUAGE_BASE + 0x0001, 17 | SERVICE_PROVIDER = PRIMARY_LANGUAGE_BASE + 0x0002, 18 | } 19 | 20 | export enum BluetoothServiceClassUuid { 21 | PUBLIC_BROWSE_GROUP = 0x1002, 22 | SERIAL_PORT = 0x1101, 23 | } 24 | 25 | export enum BluetoothProtocolUuid { 26 | RFCOMM = 0x0003, 27 | L2CAP = 0x0100, 28 | } 29 | 30 | const hex = (n: number, pad = 4) => `0x${n.toString(16).padStart(pad, '0')}`; 31 | 32 | const ANDROID_AUTO_SERVICE_RECORD = ` 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | `; 78 | 79 | export class AndroidAutoProfile extends BluetoothProfile { 80 | public constructor() { 81 | super(ANDROID_AUTO_UUID, { 82 | Name: 'AA Wireless', 83 | Role: 'server', 84 | Channel: ANDROID_AUTO_RFCOMM_CHANNEL, 85 | ServiceRecord: ANDROID_AUTO_SERVICE_RECORD, 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /node-common/src/transport/bluetooth/BluetoothDeviceTcpConnector.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'node:net'; 2 | import { Duplex } from 'node:stream'; 3 | 4 | import { getLogger, LoggerWrapper } from '@web-auto/logging'; 5 | 6 | export class BluetoothDeviceTcpConnector { 7 | protected logger: LoggerWrapper; 8 | 9 | public constructor( 10 | private tcpServer: Server, 11 | private name: string, 12 | ) { 13 | this.logger = getLogger(`${this.constructor.name}@${this.name}`); 14 | } 15 | 16 | private async connect(abortSignal: AbortSignal): Promise { 17 | return new Promise((resolve, reject) => { 18 | const onAbort = () => { 19 | this.logger.info('Aborted wait for TCP connection'); 20 | this.tcpServer.removeListener('connection', onConnect); 21 | reject(new Error('Aborted')); 22 | }; 23 | 24 | const onConnect = (socket: Duplex) => { 25 | this.logger.info('Received TCP connection'); 26 | abortSignal.removeEventListener('abort', onAbort); 27 | resolve(socket); 28 | }; 29 | 30 | abortSignal.addEventListener('abort', onAbort); 31 | this.tcpServer.once('connection', onConnect); 32 | }); 33 | } 34 | 35 | public async connectWithTimeout(timeoutMs: number): Promise { 36 | const abortSignal = AbortSignal.timeout(timeoutMs); 37 | 38 | return this.connect(abortSignal); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /node-common/src/transport/bluetooth/BluetoothProfile.ts: -------------------------------------------------------------------------------- 1 | import { getLogger } from '@web-auto/logging'; 2 | import assert from 'assert'; 3 | import { Device, type Profile, type ProfileOptions } from 'bluez'; 4 | 5 | import type { BluetoothProfileHandler } from './BluetoothProfileHandler.js'; 6 | 7 | export class BluetoothProfile implements Profile { 8 | private logger = getLogger(this.constructor.name); 9 | 10 | public UUID: string; 11 | public ProfileOptions: Partial; 12 | private addressHandlerMap = new Map(); 13 | 14 | public constructor(uuid: string, options: Partial) { 15 | this.UUID = uuid; 16 | this.ProfileOptions = options; 17 | } 18 | 19 | public addHandler(address: string, handler: BluetoothProfileHandler): void { 20 | assert(!this.addressHandlerMap.has(address)); 21 | this.addressHandlerMap.set(address, handler); 22 | } 23 | 24 | public removeHandler( 25 | address: string, 26 | handler: BluetoothProfileHandler, 27 | ): void { 28 | assert(this.addressHandlerMap.get(address) === handler); 29 | this.addressHandlerMap.delete(address); 30 | } 31 | 32 | private getHandler(address: string): BluetoothProfileHandler | undefined { 33 | return this.addressHandlerMap.get(address); 34 | } 35 | 36 | public async NewConnection( 37 | device: Device, 38 | fd: number, 39 | _options: Record, 40 | ): Promise { 41 | let address: string | undefined; 42 | 43 | try { 44 | address = await device.Address(); 45 | } catch (err) { 46 | this.logger.error('Failed to get device address', err); 47 | return; 48 | } 49 | 50 | const handler = this.getHandler(address); 51 | if (handler === undefined) { 52 | this.logger.error( 53 | `Received new connection from unhandled address ${address}`, 54 | ); 55 | return; 56 | } 57 | 58 | try { 59 | handler.connect(fd); 60 | } catch (err) { 61 | this.logger.error(`Failed to connect address ${address}`, err); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /node-common/src/transport/tcp/TcpDevice.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'node:net'; 2 | 3 | import { 4 | Device, 5 | DeviceConnectReason, 6 | type DeviceEvents, 7 | } from '@web-auto/android-auto'; 8 | 9 | import { DuplexTransport } from '../DuplexTransport.js'; 10 | import { TCP_SERVER_PORT } from './tcp.js'; 11 | 12 | export class TcpDevice extends Device { 13 | private transport: DuplexTransport | undefined; 14 | 15 | public constructor( 16 | private ip: string, 17 | mac: string, 18 | events: DeviceEvents, 19 | ) { 20 | super('TCP', ip, mac, events); 21 | } 22 | 23 | public async connectImpl(_reason: DeviceConnectReason): Promise { 24 | return new Promise((resolve, reject) => { 25 | const socket = new Socket(); 26 | 27 | const timeout = setTimeout(() => { 28 | socket.destroy(new Error('Timed out')); 29 | }, 1000); 30 | 31 | const cancelTimeout = () => { 32 | clearTimeout(timeout); 33 | }; 34 | 35 | const onSocketError = (err: Error) => { 36 | cancelTimeout(); 37 | reject(err); 38 | }; 39 | 40 | socket.once('error', onSocketError); 41 | 42 | socket.once('connect', () => { 43 | cancelTimeout(); 44 | 45 | /* 46 | * Error handling is handed off to the transport, remove 47 | * the handler here. 48 | */ 49 | socket.off('error', onSocketError); 50 | 51 | this.transport = new DuplexTransport(socket, { 52 | onData: this.onDataBound, 53 | 54 | onDisconnected: this.onDisconnectedBound, 55 | 56 | onError: this.onErrorBound, 57 | }); 58 | 59 | resolve(); 60 | }); 61 | 62 | socket.connect(TCP_SERVER_PORT, this.ip); 63 | }); 64 | } 65 | 66 | // eslint-disable-next-line @typescript-eslint/require-await 67 | protected override async disconnectImpl( 68 | _reason: string | undefined, 69 | ): Promise { 70 | if (this.transport !== undefined) { 71 | this.transport.disconnect(); 72 | this.transport = undefined; 73 | } 74 | } 75 | 76 | public override send(buffer: Uint8Array): void { 77 | if (this.transport === undefined) { 78 | this.logger.error('Device has no transport'); 79 | return; 80 | } 81 | 82 | this.transport.send(buffer); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /node-common/src/transport/tcp/TcpDeviceHandler.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | import { 4 | Device, 5 | DeviceHandler, 6 | type DeviceHandlerConfig, 7 | type DeviceHandlerEvents, 8 | } from '@web-auto/android-auto'; 9 | import Arpping from 'arpping'; 10 | 11 | import { TcpDevice } from './TcpDevice.js'; 12 | 13 | type Unpacked = T extends (infer U)[] ? U : T; 14 | type Host = Unpacked>>; 15 | type Interfaces = NonNullable; 16 | 17 | export interface TcpDeviceHandlerConfig extends DeviceHandlerConfig { 18 | scanOptions?: { 19 | interfaces: string[]; 20 | mask: string; 21 | intervalMs: number; 22 | }; 23 | } 24 | 25 | export class TcpDeviceHandler extends DeviceHandler { 26 | private scanBound: () => void; 27 | private scanInternval: ReturnType | undefined; 28 | private arp; 29 | 30 | public constructor( 31 | protected override config: TcpDeviceHandlerConfig, 32 | events: DeviceHandlerEvents, 33 | ) { 34 | super(config, events); 35 | 36 | this.scanBound = this.scan.bind(this); 37 | 38 | if (this.config.scanOptions !== undefined) { 39 | this.arp = new Arpping({ 40 | interfaceFilters: { 41 | interface: this.config.scanOptions.interfaces as Interfaces, 42 | internal: [false], 43 | family: [], 44 | }, 45 | }); 46 | } 47 | } 48 | 49 | protected override dataToString(data: Host): string { 50 | return data.ip; 51 | } 52 | 53 | // eslint-disable-next-line @typescript-eslint/require-await 54 | protected override async createDevice( 55 | data: Host, 56 | ): Promise { 57 | return new TcpDevice(data.ip, data.mac, this.getDeviceEvents()); 58 | } 59 | 60 | private updateDevices(hosts: Host[]): void { 61 | const newAvailableMacs = new Map(); 62 | 63 | for (const host of hosts) { 64 | newAvailableMacs.set(host.mac, host); 65 | } 66 | 67 | for (const mac of this.deviceMap.keys()) { 68 | if (!newAvailableMacs.has(mac)) { 69 | this.removeDevice(mac); 70 | } 71 | } 72 | 73 | for (const [mac, host] of newAvailableMacs.entries()) { 74 | if (!this.deviceMap.has(mac)) { 75 | this.addDevice(host); 76 | } 77 | } 78 | } 79 | 80 | private scan(): void { 81 | assert(this.arp !== undefined); 82 | 83 | this.arp 84 | .discover() 85 | .then((hosts) => { 86 | this.updateDevices(hosts); 87 | }) 88 | .catch((err) => { 89 | this.logger.error('Failed to get ARP table', err); 90 | }); 91 | } 92 | 93 | private startScan(): void { 94 | if (this.config.scanOptions === undefined) { 95 | return; 96 | } 97 | 98 | assert(this.scanInternval === undefined); 99 | this.scanInternval = setInterval( 100 | this.scanBound, 101 | this.config.scanOptions.intervalMs, 102 | ); 103 | this.scan(); 104 | } 105 | 106 | private stopScan(): void { 107 | if (this.config.scanOptions === undefined) { 108 | return; 109 | } 110 | 111 | assert(this.scanInternval !== undefined); 112 | clearInterval(this.scanInternval); 113 | this.scanInternval = undefined; 114 | } 115 | 116 | // eslint-disable-next-line @typescript-eslint/require-await 117 | public async waitForDevices(): Promise { 118 | this.startScan(); 119 | } 120 | 121 | // eslint-disable-next-line @typescript-eslint/require-await 122 | public override async stopWaitingForDevices(): Promise { 123 | this.stopScan(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /node-common/src/transport/tcp/tcp.ts: -------------------------------------------------------------------------------- 1 | export const TCP_SERVER_PORT = 5277; 2 | -------------------------------------------------------------------------------- /node-common/src/transport/usb/UsbDeviceHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Device, 3 | DeviceHandler, 4 | type DeviceHandlerConfig, 5 | type DeviceHandlerEvents, 6 | } from '@web-auto/android-auto'; 7 | import { Device as UsbDeviceImpl, usb } from 'usb'; 8 | 9 | import { UsbDevice } from './UsbDevice.js'; 10 | import { toHex } from '../../utils.js'; 11 | 12 | export interface UsbDeviceHandlerConfig extends DeviceHandlerConfig {} 13 | 14 | export class UsbDeviceHandler extends DeviceHandler { 15 | private implUniqueIdMap = new Map(); 16 | private removeDeviceImplBound: (data: UsbDeviceImpl) => void; 17 | 18 | public constructor( 19 | protected override config: UsbDeviceHandlerConfig, 20 | events: DeviceHandlerEvents, 21 | ) { 22 | super(config, events); 23 | 24 | this.removeDeviceImplBound = this.removeDeviceImpl.bind(this); 25 | } 26 | 27 | protected override dataToString(data: UsbDeviceImpl): string { 28 | const toHexString = (num: number) => toHex(num, 4).toLowerCase(); 29 | const vendorId = toHexString(data.deviceDescriptor.idVendor); 30 | const productId = toHexString(data.deviceDescriptor.idProduct); 31 | return `${vendorId}:${productId}`; 32 | } 33 | 34 | protected override createDevice( 35 | data: UsbDeviceImpl, 36 | ): Promise { 37 | return UsbDevice.create(data, this.getDeviceEvents()); 38 | } 39 | 40 | protected override addDeviceHook( 41 | data: UsbDeviceImpl, 42 | device: Device, 43 | ): void { 44 | this.implUniqueIdMap.set(data, device.uniqueId); 45 | } 46 | 47 | protected override removeDeviceHook(device: Device): void { 48 | for (const [data, uniqueId] of this.implUniqueIdMap.entries()) { 49 | if (uniqueId === device.uniqueId) { 50 | this.implUniqueIdMap.delete(data); 51 | } 52 | } 53 | } 54 | 55 | private removeDeviceImpl(data: UsbDeviceImpl): void { 56 | const uniqueId = this.implUniqueIdMap.get(data); 57 | if (uniqueId === undefined) { 58 | return; 59 | } 60 | 61 | this.removeDevice(uniqueId); 62 | } 63 | 64 | public async waitForDevices(): Promise { 65 | this.logger.info('Starting new device connection handler'); 66 | 67 | this.logger.info('Processing already connected devices'); 68 | 69 | usb.on('attach', this.addDeviceBound); 70 | usb.on('detach', this.removeDeviceImplBound); 71 | 72 | const aoapDevices = usb.getDeviceList(); 73 | for (const device of aoapDevices) { 74 | await this.addDeviceAsync(device, true); 75 | } 76 | 77 | this.logger.info('Finshed processing already connected devices'); 78 | } 79 | 80 | // eslint-disable-next-line @typescript-eslint/require-await 81 | public override async stopWaitingForDevices(): Promise { 82 | usb.off('attach', this.addDeviceBound); 83 | usb.off('detach', this.removeDeviceImplBound); 84 | 85 | this.logger.info('Stopped new device connection handler'); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /node-common/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const toHex = (value: number, padding: number) => 2 | value.toString(16).padStart(padding, '0').toUpperCase(); 3 | -------------------------------------------------------------------------------- /node-common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../logging" 12 | }, 13 | { 14 | "path": "../android-auto" 15 | }, 16 | { 17 | "path": "../common-ipc" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /node/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /node/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/node", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tspc", 9 | "clean": "tspc --build --clean", 10 | "lint": "eslint --fix .", 11 | "start": "node ./dist/esm/main.js" 12 | }, 13 | "exports": { 14 | "import": "./dist/esm/index.js", 15 | "types": "./dist/esm/index.d.ts" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /node/src/index.ts: -------------------------------------------------------------------------------- 1 | export { type NodeAndroidAutoConfig } from './main.js'; 2 | -------------------------------------------------------------------------------- /node/src/main.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import { createServer, Server } from 'node:https'; 3 | 4 | import { IpcServiceRegistry } from '@web-auto/common-ipc/main.js'; 5 | import { loadConfig } from '@web-auto/config-loader'; 6 | import { getLogger, setConfig } from '@web-auto/logging'; 7 | import { 8 | NodeAndroidAutoServerBuilder, 9 | type NodeCommonAndroidAutoConfig, 10 | } from '@web-auto/node-common'; 11 | import { MessagePackIpcSerializer } from '@web-auto/socket-ipc/common.js'; 12 | import { SocketIpcServiceRegistrySocketHandler } from '@web-auto/socket-ipc/main.js'; 13 | import { createAssert } from 'typia'; 14 | 15 | export type NodeAndroidAutoConfig = { 16 | nodeAndroidAuto: { 17 | webSocketServer: { 18 | port: number; 19 | host: string; 20 | }; 21 | }; 22 | } & NodeCommonAndroidAutoConfig; 23 | 24 | const configAssert = createAssert(); 25 | 26 | const config = loadConfig(configAssert); 27 | 28 | setConfig(config.logging); 29 | 30 | const logger = getLogger('node'); 31 | 32 | logger.info('Config', config); 33 | 34 | const startAndroidAuto = async (server: Server): Promise => { 35 | if (config.androidAuto === undefined) { 36 | return; 37 | } 38 | 39 | const androidAutoIpcServiceRegistry = new IpcServiceRegistry((events) => { 40 | return [ 41 | new SocketIpcServiceRegistrySocketHandler( 42 | new MessagePackIpcSerializer(), 43 | config.registryName, 44 | server, 45 | events, 46 | ), 47 | ]; 48 | }); 49 | 50 | androidAutoIpcServiceRegistry.register(); 51 | 52 | const builder = new NodeAndroidAutoServerBuilder( 53 | androidAutoIpcServiceRegistry, 54 | config.androidAuto, 55 | ); 56 | 57 | const androidAutoServer = builder.buildAndroidAutoServer(); 58 | 59 | try { 60 | await androidAutoServer.start(); 61 | } catch (err) { 62 | logger.error('Failed to start android auto server', err); 63 | } 64 | }; 65 | 66 | (async () => { 67 | const server = createServer({ 68 | cert: readFileSync('../cert.crt'), 69 | key: readFileSync('../cert.key'), 70 | }); 71 | 72 | await startAndroidAuto(server); 73 | 74 | const port = config.nodeAndroidAuto.webSocketServer.port; 75 | const host = config.nodeAndroidAuto.webSocketServer.host; 76 | 77 | server.listen(port, host); 78 | })() 79 | .then(() => {}) 80 | .catch((err) => { 81 | logger.error('Failed to start', err); 82 | }); 83 | -------------------------------------------------------------------------------- /node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.node.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../logging" 12 | }, 13 | { 14 | "path": "../node-common" 15 | }, 16 | { 17 | "path": "../socket-ipc" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": { 4 | "packages": [ 5 | "config-loader", 6 | "eslint-config", 7 | "prettier-config", 8 | "logging", 9 | "android-auto-proto", 10 | "common-ipc", 11 | "electron-ipc", 12 | "electron-ipc-preload", 13 | "socket-ipc", 14 | "android-auto", 15 | "node-common", 16 | "node", 17 | "electron", 18 | "web" 19 | ] 20 | }, 21 | "scripts": { 22 | "build": "npm run build --workspaces", 23 | "clean": "npm run clean --workspaces", 24 | "lint": "npm run lint --workspaces --if-present", 25 | "start-electron": "npm run start -w electron", 26 | "start-node": "npm run start -w node", 27 | "start-web": "npm run start -w web", 28 | "dev-web": "npm run dev -w web", 29 | "prepare-electron": "electron-rebuild -f -m node_modules/bluetooth-socket && electron-rebuild -f -m node_modules/usocket", 30 | "prepare-node": "npm rebuild", 31 | "dev": "tspc -b --watch" 32 | }, 33 | "dependencies": { 34 | "typia": "^6.10.0" 35 | }, 36 | "devDependencies": { 37 | "@electron/rebuild": "^3.6.0", 38 | "@tsconfig/node20": "^20.1.4", 39 | "@types/native-duplexpair": "^1.0.0", 40 | "@types/node": "^20.12.13", 41 | "@webgpu/types": "^0.1.42", 42 | "electron": "^30.0.9", 43 | "eslint": "^9.12.0", 44 | "ts-patch": "^3.2.1", 45 | "typescript": "^5.5.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /socket-ipc/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@web-auto/eslint-config/node-ts'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | tsconfigRootDir: import.meta.dirname, 9 | }, 10 | }, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /socket-ipc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/socket-ipc", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "type": "module", 7 | "types": "./dist/esm/index.d.ts", 8 | "exports": { 9 | "./main.js": "./dist/esm/main.js", 10 | "./common.js": "./dist/esm/common.js", 11 | "./renderer.js": "./dist/esm/renderer.js" 12 | }, 13 | "scripts": { 14 | "build": "tspc", 15 | "clean": "tspc --build --clean", 16 | "lint": "eslint --fix ." 17 | }, 18 | "dependencies": { 19 | "@types/ws": "^8.5.10", 20 | "bufferutil": "^4.0.8", 21 | "msgpackr": "^1.10.2", 22 | "ws": "^8.17.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /socket-ipc/src/common.ts: -------------------------------------------------------------------------------- 1 | import type { IpcEvent, IpcSerializer } from '@web-auto/common-ipc'; 2 | import { pack, unpack } from 'msgpackr'; 3 | 4 | export class MessagePackIpcSerializer implements IpcSerializer { 5 | public serialize(ipcEvent: IpcEvent): Uint8Array { 6 | return pack(ipcEvent); 7 | } 8 | 9 | public deserialize(data: Uint8Array): IpcEvent { 10 | return unpack(data) as IpcEvent; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /socket-ipc/src/main.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, Server } from 'node:http'; 2 | import type { Duplex } from 'node:stream'; 3 | 4 | import { 5 | BaseIpcSocket, 6 | type IpcSerializer, 7 | type IpcSocketEvents, 8 | } from '@web-auto/common-ipc'; 9 | import { IpcSocketHandler } from '@web-auto/common-ipc/main.js'; 10 | import { 11 | type CloseEvent, 12 | type MessageEvent, 13 | WebSocket, 14 | WebSocketServer, 15 | } from 'ws'; 16 | 17 | class SocketServiceIpcSocket extends BaseIpcSocket { 18 | private onDataInternalBound: (event: MessageEvent) => void; 19 | private onCloseInternalBound: (_event: CloseEvent) => void; 20 | 21 | public constructor( 22 | private socket: WebSocket, 23 | serializer: IpcSerializer, 24 | events: IpcSocketEvents, 25 | ) { 26 | super(serializer, events); 27 | 28 | this.onDataInternalBound = this.onDataInternal.bind(this); 29 | this.onCloseInternalBound = this.onCloseInternal.bind(this); 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/require-await 33 | public async open(): Promise { 34 | this.socket.addEventListener('message', this.onDataInternalBound); 35 | this.socket.addEventListener('close', this.onCloseInternalBound); 36 | } 37 | 38 | public async close(): Promise { 39 | this.socket.removeEventListener('message', this.onDataInternalBound); 40 | this.socket.removeEventListener('close', this.onCloseInternalBound); 41 | 42 | return new Promise((resolve) => { 43 | const onClose = () => { 44 | this.socket.removeEventListener('close', onClose); 45 | resolve(); 46 | }; 47 | 48 | this.socket.addEventListener('close', onClose); 49 | 50 | this.socket.close(); 51 | }); 52 | } 53 | 54 | public onDataInternal(event: MessageEvent): void { 55 | this.events.onSocketData(this, event.data); 56 | } 57 | 58 | public onCloseInternal(_event: CloseEvent): void { 59 | this.socket.removeEventListener('message', this.onDataInternalBound); 60 | this.socket.removeEventListener('close', this.onCloseInternalBound); 61 | this.events.onSocketClose(this); 62 | } 63 | 64 | public send(data: any): void { 65 | if (this.socket === undefined) { 66 | throw new Error('Cannot call send before calling open'); 67 | } 68 | 69 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 70 | this.socket.send(data, { 71 | binary: true, 72 | compress: false, 73 | }); 74 | } 75 | } 76 | 77 | export class SocketIpcServiceRegistrySocketHandler extends IpcSocketHandler { 78 | private wss: WebSocketServer; 79 | private onServerUpgradeBound: ( 80 | req: InstanceType, 81 | socket: Duplex, 82 | head: Buffer, 83 | ) => void; 84 | private onConnectionBound: (webSocket: WebSocket) => void; 85 | 86 | public constructor( 87 | serializer: IpcSerializer, 88 | private name: string, 89 | private server: Server, 90 | events: IpcSocketEvents, 91 | ) { 92 | super(serializer, events); 93 | 94 | this.onServerUpgradeBound = this.onServerUpgrade.bind(this); 95 | this.onConnectionBound = this.onConnection.bind(this); 96 | 97 | this.wss = new WebSocketServer({ noServer: true }); 98 | } 99 | 100 | public register() { 101 | this.wss.on('connection', this.onConnectionBound); 102 | this.server.prependListener('upgrade', this.onServerUpgradeBound); 103 | } 104 | 105 | public unregister() { 106 | this.server.removeListener('upgrade', this.onServerUpgradeBound); 107 | this.wss.off('connection', this.onConnectionBound); 108 | } 109 | 110 | private onServerUpgrade( 111 | req: InstanceType, 112 | socket: Duplex, 113 | head: Buffer, 114 | ) { 115 | if (req.url === `/${this.name}`) { 116 | this.wss.handleUpgrade(req, socket, head, (ws) => { 117 | this.wss.emit('connection', ws, req); 118 | }); 119 | } 120 | } 121 | 122 | private onConnection(webSocket: WebSocket): void { 123 | this.addSocket((events) => { 124 | return new SocketServiceIpcSocket( 125 | webSocket, 126 | this.serializer, 127 | events, 128 | ); 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /socket-ipc/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseIpcSocket, 3 | type IpcSerializer, 4 | type IpcSocketEvents, 5 | } from '@web-auto/common-ipc'; 6 | import { GenericIpcClientRegistry } from '@web-auto/common-ipc/renderer.js'; 7 | 8 | import { MessagePackIpcSerializer } from './common.js'; 9 | 10 | class SocketClientIpcSocket extends BaseIpcSocket { 11 | private socket: WebSocket | undefined; 12 | private onDataInternalBound: (message: MessageEvent) => void; 13 | private onCloseInternalBound: () => void; 14 | 15 | public constructor( 16 | private url: string, 17 | serializer: IpcSerializer, 18 | events: IpcSocketEvents, 19 | ) { 20 | super(serializer, events); 21 | 22 | this.onDataInternalBound = this.onDataInternal.bind(this); 23 | this.onCloseInternalBound = this.onCloseInternal.bind(this); 24 | } 25 | 26 | public async open(): Promise { 27 | const socket = new WebSocket(this.url); 28 | 29 | socket.binaryType = 'arraybuffer'; 30 | 31 | return new Promise((resolve, reject) => { 32 | const cleanup = () => { 33 | socket.removeEventListener('open', onOpen); 34 | socket.removeEventListener('error', onError); 35 | }; 36 | 37 | const onOpen = () => { 38 | this.socket = socket; 39 | socket.addEventListener('close', this.onCloseInternalBound); 40 | socket.addEventListener('message', this.onDataInternalBound); 41 | cleanup(); 42 | resolve(); 43 | }; 44 | 45 | const onError = () => { 46 | cleanup(); 47 | reject(); 48 | }; 49 | 50 | socket.addEventListener('open', onOpen); 51 | socket.addEventListener('error', onError); 52 | }); 53 | } 54 | 55 | private onDataInternal(message: MessageEvent): void { 56 | const buffer = new Uint8Array(message.data as ArrayBuffer); 57 | this.events.onSocketData(this, buffer); 58 | } 59 | 60 | private onCloseInternal(): void { 61 | if (this.socket === undefined) { 62 | console.error('Cannot receive close event before open'); 63 | return; 64 | } 65 | 66 | const socket = this.socket; 67 | 68 | socket.removeEventListener('close', this.onCloseInternalBound); 69 | socket.removeEventListener('message', this.onDataInternalBound); 70 | this.events.onSocketClose(this); 71 | } 72 | 73 | public async close(): Promise { 74 | if (this.socket === undefined) { 75 | throw new Error('Cannot call close before calling open'); 76 | } 77 | 78 | const socket = this.socket; 79 | 80 | return new Promise((resolve) => { 81 | const onClose = () => { 82 | this.socket = undefined; 83 | socket.removeEventListener('close', onClose); 84 | resolve(); 85 | }; 86 | 87 | socket.addEventListener('close', onClose); 88 | 89 | socket.close(); 90 | }); 91 | } 92 | 93 | public send(data: any): void { 94 | if (this.socket === undefined) { 95 | throw new Error('Cannot call send before calling open'); 96 | } 97 | 98 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 99 | this.socket.send(data); 100 | } 101 | } 102 | 103 | export class SocketIpcClientRegistry extends GenericIpcClientRegistry { 104 | public constructor(host: string, port: number, name: string) { 105 | const serializer = new MessagePackIpcSerializer(); 106 | super((events) => { 107 | return new SocketClientIpcSocket( 108 | `wss://${host}:${port}/${name}`, 109 | serializer, 110 | events, 111 | ); 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /socket-ipc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig/tsconfig.web.json", 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "rootDir": "./src", 6 | "outDir": "./dist/esm", 7 | "baseUrl": "." 8 | }, 9 | "references": [ 10 | { 11 | "path": "../common-ipc" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./electron" }] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler", 12 | "types": ["node"], 13 | "verbatimModuleSyntax": true, 14 | "strict": true, 15 | "alwaysStrict": true, 16 | "exactOptionalPropertyTypes": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noImplicitAny": true, 19 | "noImplicitOverride": true, 20 | "noImplicitReturns": true, 21 | "noImplicitThis": true, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "noUncheckedIndexedAccess": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "useUnknownInCatchVariables": true, 27 | "plugins": [ 28 | { 29 | "transform": "typia/lib/transform" 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig/tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.node.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "es2023"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | # Environment variables 31 | .env.local 32 | 33 | # Browser data 34 | browser-data 35 | -------------------------------------------------------------------------------- /web/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import vuePlugin from 'eslint-plugin-vue'; 2 | import vueParser from 'vue-eslint-parser'; 3 | import vueTsEslintConfig from '@vue/eslint-config-typescript'; 4 | import skipFormattingConfig from '@vue/eslint-config-prettier/skip-formatting'; 5 | import baseConfig from '@web-auto/eslint-config/common-ts'; 6 | 7 | export default [ 8 | ...vuePlugin.configs['flat/essential'], 9 | ...vueTsEslintConfig(), 10 | ...[skipFormattingConfig], 11 | ...baseConfig, 12 | { 13 | languageOptions: { 14 | parser: vueParser, 15 | parserOptions: { 16 | parser: '@typescript-eslint/parser', 17 | sourceType: 'module', 18 | tsconfigRootDir: import.meta.dirname, 19 | project: ['./tsconfig.json', './tsconfig.node.json'], 20 | }, 21 | }, 22 | rules: { 23 | 'vue/multi-word-component-names': ['off'], 24 | 'vue/no-deprecated-slot-attribute': ['off'], 25 | }, 26 | }, 27 | { 28 | ignores: ['browser-data', 'vite.config.ts', 'env.d.ts'], 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | WebAuto 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@web-auto/web", 3 | "version": "1.0.0", 4 | "author": "Demon000", 5 | "license": "GPL-3.0", 6 | "main": "dist/index.html", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "vue-tsc && vite build", 11 | "clean": "rm -rf ./dist", 12 | "start": "vite preview", 13 | "lint": "eslint --fix ." 14 | }, 15 | "dependencies": { 16 | "@fontsource/nunito-sans": "^5.0.13", 17 | "@fontsource/roboto": "^5.0.13", 18 | "@material/material-color-utilities": "^0.2.7", 19 | "@material/web": "^1.5.1", 20 | "json5": "^2.2.3", 21 | "material-symbols": "^0.19.0", 22 | "object-fit-math": "^1.0.0", 23 | "pinia": "^2.1.7", 24 | "vue": "^3.4.27", 25 | "vue-router": "^4.3.2" 26 | }, 27 | "devDependencies": { 28 | "@vitejs/plugin-vue": "^5.0.5", 29 | "@vue/eslint-config-prettier": "^10.0.0", 30 | "@vue/eslint-config-typescript": "^14.0.0", 31 | "eslint-plugin-vue": "^9.28.0", 32 | "vite": "^5.4.8", 33 | "vue-tsc": "^2.1.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Demon000/web-auto/6c867dedb56d62b7eb802f0c4f73ca482f5eb907/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | body { 7 | width: 100%; 8 | height: 100%; 9 | padding: 0; 10 | margin: 0; 11 | 12 | font-family: 'Roboto'; 13 | background: var(--md-sys-color-shadow); 14 | color: #fff; 15 | touch-action: none; 16 | user-select: none; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | #app { 24 | width: 100%; 25 | height: 100%; 26 | } 27 | -------------------------------------------------------------------------------- /web/src/codec/Canvas2DRenderer.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from './Renderer.js'; 2 | import { VideoCodecConfig } from '@web-auto/node-common/ipc.js'; 3 | 4 | export class Canvas2DRenderer implements Renderer { 5 | private context: OffscreenCanvasRenderingContext2D; 6 | 7 | public constructor( 8 | private canvas: OffscreenCanvas, 9 | private config: VideoCodecConfig, 10 | ) { 11 | const context = canvas.getContext('2d'); 12 | if (context === null) { 13 | throw new Error('Failed to create canvas context'); 14 | } 15 | 16 | this.context = context; 17 | 18 | this.setConfig(config); 19 | } 20 | 21 | public setConfig(config: VideoCodecConfig): void { 22 | this.config = config; 23 | this.canvas.width = config.croppedWidth; 24 | this.canvas.height = config.croppedHeight; 25 | } 26 | 27 | public async draw(frame: VideoFrame): Promise { 28 | this.context.drawImage( 29 | frame, 30 | this.config.margins.left, 31 | this.config.margins.top, 32 | this.config.width, 33 | this.config.height, 34 | 0, 35 | 0, 36 | this.config.width, 37 | this.config.height, 38 | ); 39 | } 40 | 41 | public free(): void {} 42 | } 43 | -------------------------------------------------------------------------------- /web/src/codec/DecoderWorkerMessages.ts: -------------------------------------------------------------------------------- 1 | import { VideoCodecConfig } from '@web-auto/node-common/ipc.js'; 2 | 3 | export enum DecoderWorkerRenderer { 4 | _2D = '2d', 5 | WEBGL = 'webgl', 6 | WEBGL2 = 'webgl2', 7 | } 8 | 9 | export enum DecoderWorkerMessageType { 10 | CREATE_RENDERER, 11 | DESTROY_RENDERER, 12 | CONFIGURE_DECODER, 13 | DECODE_KEYFRAME, 14 | DECODE_DELTA, 15 | RESET_DECODER, 16 | } 17 | 18 | export type DecoderWorkerMessage = 19 | | { 20 | type: DecoderWorkerMessageType.CREATE_RENDERER; 21 | rendererName: string; 22 | canvas: OffscreenCanvas; 23 | cookie: bigint; 24 | } 25 | | { 26 | type: DecoderWorkerMessageType.DESTROY_RENDERER; 27 | cookie: bigint; 28 | } 29 | | { 30 | type: DecoderWorkerMessageType.CONFIGURE_DECODER; 31 | config: VideoCodecConfig; 32 | } 33 | | { 34 | type: DecoderWorkerMessageType.DECODE_KEYFRAME; 35 | data: Uint8Array; 36 | timestamp?: bigint; 37 | } 38 | | { 39 | type: DecoderWorkerMessageType.DECODE_DELTA; 40 | data: Uint8Array; 41 | timestamp?: bigint; 42 | } 43 | | { 44 | type: DecoderWorkerMessageType.RESET_DECODER; 45 | }; 46 | -------------------------------------------------------------------------------- /web/src/codec/DecoderWorkerWrapper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AndroidAutoVideoClient, 3 | AndroidAutoVideoService, 4 | VideoCodecConfig, 5 | } from '@web-auto/node-common/ipc.js'; 6 | import { DecoderWorkerMessageType } from './DecoderWorkerMessages.js'; 7 | import { IpcClientHandler } from '@web-auto/common-ipc/renderer.js'; 8 | import { ipcClientRegistry } from '../ipc.js'; 9 | 10 | export interface DecoderWorkerConfig { 11 | videoServiceIpcName: string; 12 | renderer: string; 13 | } 14 | 15 | export class DecoderWorker { 16 | private acceptData = false; 17 | private worker: Worker; 18 | private service: IpcClientHandler< 19 | AndroidAutoVideoClient, 20 | AndroidAutoVideoService 21 | >; 22 | 23 | public constructor(private config: DecoderWorkerConfig) { 24 | this.worker = new Worker( 25 | new URL('./DecoderWorker.ts', import.meta.url), 26 | { 27 | type: 'module', 28 | }, 29 | ); 30 | 31 | this.service = ipcClientRegistry.registerIpcClient< 32 | AndroidAutoVideoClient, 33 | AndroidAutoVideoService 34 | >(this.config.videoServiceIpcName); 35 | 36 | this.onChannelStart = this.onChannelStart.bind(this); 37 | this.onCodecConfig = this.onCodecConfig.bind(this); 38 | this.onFirstFrameData = this.onFirstFrameData.bind(this); 39 | this.onFrameData = this.onFrameData.bind(this); 40 | this.onChannelStop = this.onChannelStop.bind(this); 41 | } 42 | 43 | public start(): void { 44 | this.service.on('channelStart', this.onChannelStart); 45 | } 46 | 47 | public createRenderer(canvas: OffscreenCanvas, cookie: bigint): void { 48 | this.worker.postMessage( 49 | { 50 | type: DecoderWorkerMessageType.CREATE_RENDERER, 51 | rendererName: this.config.renderer, 52 | canvas: canvas, 53 | cookie, 54 | }, 55 | [canvas], 56 | ); 57 | } 58 | 59 | public destroyRenderer(cookie: bigint): void { 60 | this.worker.postMessage({ 61 | type: DecoderWorkerMessageType.DESTROY_RENDERER, 62 | cookie, 63 | }); 64 | } 65 | 66 | private onFirstFrameData(data: Uint8Array, timestamp?: bigint): void { 67 | this.worker.postMessage( 68 | { 69 | type: DecoderWorkerMessageType.DECODE_KEYFRAME, 70 | data, 71 | timestamp, 72 | }, 73 | [data.buffer], 74 | ); 75 | } 76 | 77 | private onFrameData(data: Uint8Array, timestamp?: bigint): void { 78 | if (!this.acceptData) { 79 | return; 80 | } 81 | 82 | this.worker.postMessage( 83 | { 84 | type: DecoderWorkerMessageType.DECODE_DELTA, 85 | data, 86 | timestamp, 87 | }, 88 | [data.buffer], 89 | ); 90 | } 91 | 92 | private onCodecConfig(config: VideoCodecConfig): void { 93 | this.worker.postMessage({ 94 | type: DecoderWorkerMessageType.CONFIGURE_DECODER, 95 | config, 96 | }); 97 | } 98 | 99 | private onChannelStop(): void { 100 | this.acceptData = false; 101 | 102 | this.worker.postMessage({ 103 | type: DecoderWorkerMessageType.RESET_DECODER, 104 | }); 105 | 106 | this.service.off('codecConfig', this.onCodecConfig); 107 | this.service.off('firstFrame', this.onFirstFrameData); 108 | this.service.off('data', this.onFrameData); 109 | this.service.off('channelStop', this.onChannelStop); 110 | } 111 | 112 | private onChannelStart(): void { 113 | this.acceptData = true; 114 | 115 | this.service.on('codecConfig', this.onCodecConfig); 116 | this.service.on('firstFrame', this.onFirstFrameData); 117 | this.service.on('data', this.onFrameData); 118 | this.service.on('channelStop', this.onChannelStop); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /web/src/codec/Renderer.ts: -------------------------------------------------------------------------------- 1 | import { VideoCodecConfig } from '@web-auto/node-common/ipc.js'; 2 | 3 | export interface Renderer { 4 | draw(frame: VideoFrame): Promise; 5 | setConfig(config: VideoCodecConfig): void; 6 | free(): void; 7 | } 8 | -------------------------------------------------------------------------------- /web/src/codec/m4.ts: -------------------------------------------------------------------------------- 1 | export const orthographic = ( 2 | left: number, 3 | right: number, 4 | bottom: number, 5 | top: number, 6 | near: number, 7 | far: number, 8 | dst?: Float32Array, 9 | ): Float32Array => { 10 | if (dst === undefined) { 11 | dst = new Float32Array(16); 12 | } 13 | 14 | dst[0] = 2 / (right - left); 15 | dst[1] = 0; 16 | dst[2] = 0; 17 | dst[3] = 0; 18 | dst[4] = 0; 19 | dst[5] = 2 / (top - bottom); 20 | dst[6] = 0; 21 | dst[7] = 0; 22 | dst[8] = 0; 23 | dst[9] = 0; 24 | dst[10] = 2 / (near - far); 25 | dst[11] = 0; 26 | dst[12] = (left + right) / (left - right); 27 | dst[13] = (bottom + top) / (bottom - top); 28 | dst[14] = (near + far) / (near - far); 29 | dst[15] = 1; 30 | 31 | return dst; 32 | }; 33 | 34 | export const translate = ( 35 | m: Float32Array, 36 | tx: number, 37 | ty: number, 38 | tz: number, 39 | dst?: Float32Array, 40 | ) => { 41 | if (dst === undefined) { 42 | dst = new Float32Array(16); 43 | } 44 | 45 | const m00 = m[0]; 46 | const m01 = m[1]; 47 | const m02 = m[2]; 48 | const m03 = m[3]; 49 | const m10 = m[1 * 4 + 0]; 50 | const m11 = m[1 * 4 + 1]; 51 | const m12 = m[1 * 4 + 2]; 52 | const m13 = m[1 * 4 + 3]; 53 | const m20 = m[2 * 4 + 0]; 54 | const m21 = m[2 * 4 + 1]; 55 | const m22 = m[2 * 4 + 2]; 56 | const m23 = m[2 * 4 + 3]; 57 | const m30 = m[3 * 4 + 0]; 58 | const m31 = m[3 * 4 + 1]; 59 | const m32 = m[3 * 4 + 2]; 60 | const m33 = m[3 * 4 + 3]; 61 | 62 | if (m !== dst) { 63 | dst[0] = m00; 64 | dst[1] = m01; 65 | dst[2] = m02; 66 | dst[3] = m03; 67 | dst[4] = m10; 68 | dst[5] = m11; 69 | dst[6] = m12; 70 | dst[7] = m13; 71 | dst[8] = m20; 72 | dst[9] = m21; 73 | dst[10] = m22; 74 | dst[11] = m23; 75 | } 76 | 77 | dst[12] = m00 * tx + m10 * ty + m20 * tz + m30; 78 | dst[13] = m01 * tx + m11 * ty + m21 * tz + m31; 79 | dst[14] = m02 * tx + m12 * ty + m22 * tz + m32; 80 | dst[15] = m03 * tx + m13 * ty + m23 * tz + m33; 81 | 82 | return dst; 83 | }; 84 | 85 | export const scale = ( 86 | m: Float32Array, 87 | sx: number, 88 | sy: number, 89 | sz: number, 90 | dst?: Float32Array, 91 | ) => { 92 | if (dst === undefined) { 93 | dst = new Float32Array(16); 94 | } 95 | 96 | dst[0] = sx * m[0 * 4 + 0]; 97 | dst[1] = sx * m[0 * 4 + 1]; 98 | dst[2] = sx * m[0 * 4 + 2]; 99 | dst[3] = sx * m[0 * 4 + 3]; 100 | dst[4] = sy * m[1 * 4 + 0]; 101 | dst[5] = sy * m[1 * 4 + 1]; 102 | dst[6] = sy * m[1 * 4 + 2]; 103 | dst[7] = sy * m[1 * 4 + 3]; 104 | dst[8] = sz * m[2 * 4 + 0]; 105 | dst[9] = sz * m[2 * 4 + 1]; 106 | dst[10] = sz * m[2 * 4 + 2]; 107 | dst[11] = sz * m[2 * 4 + 3]; 108 | 109 | if (m !== dst) { 110 | dst[12] = m[12]; 111 | dst[13] = m[13]; 112 | dst[14] = m[14]; 113 | dst[15] = m[15]; 114 | } 115 | 116 | return dst; 117 | }; 118 | 119 | export const translation = ( 120 | tx: number, 121 | ty: number, 122 | tz: number, 123 | dst?: Float32Array, 124 | ) => { 125 | if (dst === undefined) { 126 | dst = new Float32Array(16); 127 | } 128 | 129 | dst[0] = 1; 130 | dst[1] = 0; 131 | dst[2] = 0; 132 | dst[3] = 0; 133 | dst[4] = 0; 134 | dst[5] = 1; 135 | dst[6] = 0; 136 | dst[7] = 0; 137 | dst[8] = 0; 138 | dst[9] = 0; 139 | dst[10] = 1; 140 | dst[11] = 0; 141 | dst[12] = tx; 142 | dst[13] = ty; 143 | dst[14] = tz; 144 | dst[15] = 1; 145 | 146 | return dst; 147 | }; 148 | -------------------------------------------------------------------------------- /web/src/components/AppBar.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 68 | 69 | 81 | -------------------------------------------------------------------------------- /web/src/components/Device.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 49 | 96 | -------------------------------------------------------------------------------- /web/src/components/DeviceNotConnected.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /web/src/components/DeviceSelector.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 51 | 52 | 78 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarBrightness.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 51 | 52 | 58 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarIcon.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | 30 | 52 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarKeyIcon.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 36 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarRouteIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarSpacer.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/src/components/app-bar-icons/AppBarVolume.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 57 | 58 | 64 | -------------------------------------------------------------------------------- /web/src/components/home-tiles/MiniVideo.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 61 | 62 | 81 | -------------------------------------------------------------------------------- /web/src/components/views/ConnectionsView.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 57 | 58 | 83 | -------------------------------------------------------------------------------- /web/src/components/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 56 | 57 | 81 | -------------------------------------------------------------------------------- /web/src/components/views/VideoView.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 84 | 85 | 91 | -------------------------------------------------------------------------------- /web/src/config.ts: -------------------------------------------------------------------------------- 1 | import { NodeAndroidAutoConfig } from '@web-auto/node'; 2 | import { DecoderWorkerConfig } from './codec/DecoderWorkerWrapper.js'; 3 | import { AppBarProps } from './components/AppBar.vue'; 4 | 5 | export type WebAndroidAutoConfig = { 6 | web: { 7 | registryName: string; 8 | themeColor: string; 9 | decoders: DecoderWorkerConfig[]; 10 | appBar: AppBarProps; 11 | views: { 12 | path: string; 13 | component: string; 14 | }[]; 15 | }; 16 | } & NodeAndroidAutoConfig; 17 | 18 | export const CONFIG = import.meta.env.CONFIG as WebAndroidAutoConfig; 19 | export const WEB_CONFIG = CONFIG.web; 20 | -------------------------------------------------------------------------------- /web/src/decoders.ts: -------------------------------------------------------------------------------- 1 | import { DecoderWorker } from './codec/DecoderWorkerWrapper.js'; 2 | import { WEB_CONFIG } from './config.js'; 3 | 4 | const decoders = new Map(); 5 | 6 | export const initializeDecoders = () => { 7 | for (const config of WEB_CONFIG.decoders) { 8 | const decoder = new DecoderWorker(config); 9 | decoders.set(config.videoServiceIpcName, decoder); 10 | decoder.start(); 11 | } 12 | }; 13 | 14 | export const getDecoder = (name: string): DecoderWorker => { 15 | const decoder = decoders.get(name); 16 | if (decoder === undefined) { 17 | throw new Error(`Failed to find decoder with name ${name}`); 18 | } 19 | 20 | return decoder; 21 | }; 22 | -------------------------------------------------------------------------------- /web/src/ipc.ts: -------------------------------------------------------------------------------- 1 | import { IpcClientRegistry } from '@web-auto/common-ipc/renderer.js'; 2 | import { ElectronIpcClientRegistry } from '@web-auto/electron-ipc/renderer.js'; 3 | import { SocketIpcClientRegistry } from '@web-auto/socket-ipc/renderer.js'; 4 | import { CONFIG } from './config.js'; 5 | 6 | export let ipcClientRegistry: IpcClientRegistry; 7 | 8 | try { 9 | ipcClientRegistry = new ElectronIpcClientRegistry(CONFIG.registryName); 10 | } catch (err) { 11 | ipcClientRegistry = new SocketIpcClientRegistry( 12 | CONFIG.nodeAndroidAuto.webSocketServer.host, 13 | CONFIG.nodeAndroidAuto.webSocketServer.port, 14 | CONFIG.registryName, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import './theme.js'; 2 | import './decoders.js'; 3 | 4 | import { ipcClientRegistry } from './ipc.js'; 5 | 6 | import { createApp } from 'vue'; 7 | import { createPinia } from 'pinia'; 8 | 9 | import App from './App.vue'; 10 | import router from './router/index.js'; 11 | import { initializeDecoders } from './decoders.js'; 12 | 13 | const app = createApp(App); 14 | 15 | app.use(createPinia()); 16 | app.use(router); 17 | 18 | const initialize = async () => { 19 | await ipcClientRegistry.register(); 20 | 21 | initializeDecoders(); 22 | 23 | app.mount('#app'); 24 | }; 25 | 26 | initialize() 27 | .then(() => {}) 28 | .catch((err) => { 29 | console.error('Failed to initialize', err); 30 | }); 31 | -------------------------------------------------------------------------------- /web/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw, createRouter, createWebHistory } from 'vue-router'; 2 | import { WEB_CONFIG } from '../config.js'; 3 | 4 | const routes: RouteRecordRaw[] = []; 5 | 6 | for (const config of WEB_CONFIG.views) { 7 | routes.push({ 8 | path: config.path, 9 | component: () => import(`../components/views/${config.component}.vue`), 10 | props: config, 11 | }); 12 | } 13 | 14 | const router = createRouter({ 15 | history: createWebHistory('/'), 16 | routes, 17 | }); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /web/src/stores/brightness-store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { Ref, ref } from 'vue'; 3 | import { 4 | AndroidAutoBrightnessClient, 5 | AndroidAutoBrightnessService, 6 | } from '@web-auto/node-common/ipc.js'; 7 | import { IpcClientHandler } from '@web-auto/common-ipc/renderer.js'; 8 | 9 | export const useBrightnessStore = ( 10 | service: IpcClientHandler< 11 | AndroidAutoBrightnessClient, 12 | AndroidAutoBrightnessService 13 | >, 14 | ) => 15 | defineStore(service.handle, () => { 16 | const brightness: Ref = ref(0); 17 | let initialized = false; 18 | 19 | async function initialize() { 20 | if (initialized) { 21 | return; 22 | } 23 | 24 | brightness.value = await service.getBrightness(); 25 | 26 | initialized = true; 27 | } 28 | 29 | const setBrightness = async (value: number) => { 30 | await service.setBrightness(value); 31 | brightness.value = value; 32 | }; 33 | 34 | return { brightness, setBrightness, initialize }; 35 | })(); 36 | -------------------------------------------------------------------------------- /web/src/stores/device-store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { Ref, computed, ref } from 'vue'; 3 | import { 4 | AndroidAutoServerClient, 5 | AndroidAutoServerService, 6 | IDevice, 7 | } from '@web-auto/node-common/ipc.js'; 8 | import { IpcClientHandler } from '@web-auto/common-ipc/renderer.js'; 9 | 10 | export const useDeviceStore = ( 11 | service: IpcClientHandler< 12 | AndroidAutoServerClient, 13 | AndroidAutoServerService 14 | >, 15 | ) => 16 | defineStore(service.handle, () => { 17 | const devices: Ref = ref([]); 18 | let initialized = false; 19 | 20 | async function initialize() { 21 | if (initialized) { 22 | return; 23 | } 24 | 25 | devices.value = await service.getDevices(); 26 | 27 | service.on('devices', (newDevices) => { 28 | devices.value = newDevices; 29 | }); 30 | 31 | initialized = true; 32 | } 33 | 34 | const supportedDevices = computed(() => { 35 | const supportedDevices = []; 36 | for (const device of devices.value) { 37 | if (device.state !== 'unsupported') { 38 | supportedDevices.push(device); 39 | } 40 | } 41 | 42 | return supportedDevices; 43 | }); 44 | 45 | const connectedDevice = computed(() => { 46 | for (const device of devices.value) { 47 | if (device.state === 'connected') { 48 | return device; 49 | } 50 | } 51 | 52 | return undefined; 53 | }); 54 | 55 | return { devices, supportedDevices, connectedDevice, initialize }; 56 | })(); 57 | -------------------------------------------------------------------------------- /web/src/stores/media-status-store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IMediaPlaybackStatus, 3 | IMediaPlaybackMetadata, 4 | } from '@web-auto/android-auto-proto/interfaces.js'; 5 | import { defineStore } from 'pinia'; 6 | import { Ref, ref } from 'vue'; 7 | import { IpcClientHandler } from '@web-auto/common-ipc/renderer.js'; 8 | import { 9 | AndroidAutoMediaStatusClient, 10 | AndroidAutoMediaStatusService, 11 | } from '@web-auto/node-common/ipc.js'; 12 | 13 | export const useMediaStatusStore = ( 14 | service: IpcClientHandler< 15 | AndroidAutoMediaStatusClient, 16 | AndroidAutoMediaStatusService 17 | >, 18 | ) => 19 | defineStore(service.handle, () => { 20 | let initialized = false; 21 | 22 | const status: Ref = ref(undefined); 23 | const metadata: Ref = 24 | ref(undefined); 25 | 26 | async function initialize() { 27 | if (initialized) { 28 | return; 29 | } 30 | 31 | metadata.value = await service.getMetadata(); 32 | status.value = await service.getStatus(); 33 | 34 | service.on('metadata', (newMetadata) => { 35 | metadata.value = newMetadata; 36 | }); 37 | 38 | service.on('status', (newStatus) => { 39 | status.value = newStatus; 40 | }); 41 | 42 | initialized = true; 43 | } 44 | 45 | return { status, metadata, initialize }; 46 | })(); 47 | -------------------------------------------------------------------------------- /web/src/stores/volume-store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { Ref, ref } from 'vue'; 3 | import { 4 | AndroidAutoAudioOutputClient, 5 | AndroidAutoAudioOutputService, 6 | } from '@web-auto/node-common/ipc.js'; 7 | import { IpcClientHandler } from '@web-auto/common-ipc/renderer.js'; 8 | 9 | export interface VolumeStore { 10 | volume: number; 11 | setVolume: (value: number) => Promise; 12 | } 13 | 14 | export const useVolumeStore = ( 15 | service: IpcClientHandler< 16 | AndroidAutoAudioOutputClient, 17 | AndroidAutoAudioOutputService 18 | >, 19 | ) => 20 | defineStore(service.handle, () => { 21 | const volume: Ref = ref(0); 22 | let initialized = false; 23 | 24 | async function initialize() { 25 | if (initialized) { 26 | return; 27 | } 28 | 29 | volume.value = await service.getVolume(); 30 | 31 | initialized = true; 32 | } 33 | 34 | const setVolume = async (value: number) => { 35 | await service.setVolume(value); 36 | volume.value = value; 37 | }; 38 | 39 | return { volume, setVolume, initialize }; 40 | })(); 41 | -------------------------------------------------------------------------------- /web/src/theme.ts: -------------------------------------------------------------------------------- 1 | import '@fontsource/roboto'; 2 | import 'material-symbols'; 3 | 4 | import './assets/main.css'; 5 | 6 | import { 7 | argbFromHex, 8 | themeFromSourceColor, 9 | applyTheme, 10 | } from '@material/material-color-utilities'; 11 | import { WEB_CONFIG } from './config.js'; 12 | 13 | const theme = themeFromSourceColor(argbFromHex(WEB_CONFIG.themeColor)); 14 | 15 | applyTheme(theme, { target: document.body, dark: true }); 16 | -------------------------------------------------------------------------------- /web/src/utils/objectId.ts: -------------------------------------------------------------------------------- 1 | export const objectId = (() => { 2 | const map = new WeakMap(); 3 | let counter = 0n; 4 | 5 | return (obj: any): bigint => { 6 | let id = map.get(obj); 7 | if (id === undefined) { 8 | id = counter++; 9 | map.set(obj, id); 10 | } 11 | 12 | return id; 13 | }; 14 | })(); 15 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { WebAndroidAutoConfig } from '../config.js'; 3 | 4 | interface ImportMetaEnv { 5 | readonly VITE_SOCKET_IPC_CLIENT_HOST: string; 6 | readonly VITE_SOCKET_IPC_CLIENT_PORT: string; 7 | readonly CONFIG: WebAndroidAutoConfig; 8 | readonly WEB_CONFIG: WebAndroidAutoConfig['web']; 9 | } 10 | 11 | interface ImportMeta { 12 | readonly env: ImportMetaEnv; 13 | } 14 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import { resolve } from 'node:path'; 4 | import { readFileSync } from 'node:fs'; 5 | import { loadConfig } from '@web-auto/config-loader'; 6 | import { NodeAndroidAutoConfig } from '@web-auto/node'; 7 | 8 | const config = loadConfig( 9 | (data) => data as NodeAndroidAutoConfig, 10 | ); 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | server: { 15 | host: config.nodeAndroidAuto.webSocketServer.host, 16 | https: { 17 | cert: readFileSync('../cert.crt'), 18 | key: readFileSync('../cert.key'), 19 | }, 20 | }, 21 | build: { 22 | target: 'esnext', 23 | rollupOptions: { 24 | input: { 25 | main: resolve(__dirname, 'index.html'), 26 | }, 27 | }, 28 | }, 29 | optimizeDeps: { 30 | esbuildOptions: { 31 | target: 'esnext', 32 | }, 33 | }, 34 | plugins: [ 35 | vue({ 36 | template: { 37 | compilerOptions: { 38 | isCustomElement(tag) { 39 | return [ 40 | 'md-icon', 41 | 'md-icon-button', 42 | 'md-filled-icon-button', 43 | 'md-fab', 44 | 'md-linear-progress', 45 | 'md-slider', 46 | ].includes(tag); 47 | }, 48 | }, 49 | }, 50 | }), 51 | ], 52 | define: { 53 | 'import.meta.env.CONFIG': JSON.stringify(config), 54 | }, 55 | }); 56 | --------------------------------------------------------------------------------