diff --git a/AdbLoopbackController/.gitignore b/AdbLoopbackController/.gitignore
new file mode 100644
index 0000000..08fba81
--- /dev/null
+++ b/AdbLoopbackController/.gitignore
@@ -0,0 +1,6 @@
+/build/
+/app/build/
+/.idea/
+/.gradle/
+config.gradle
+/local.properties
diff --git a/AdbLoopbackController/app/build.gradle b/AdbLoopbackController/app/build.gradle
new file mode 100644
index 0000000..cc1e3ba
--- /dev/null
+++ b/AdbLoopbackController/app/build.gradle
@@ -0,0 +1,73 @@
+plugins {
+ id 'com.android.application'
+}
+
+def releaseTime() {
+ return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
+}
+
+def appName() {
+ return "AdbLoopbackController"
+}
+
+android {
+ namespace 'com.ttstd.adbloopback'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.ttstd.adbloopback"
+ minSdk 24
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ buildFeatures {
+ buildConfig true
+ }
+
+ signingConfigs {
+ keypub {
+ storeFile file(rootProject.ext.signingConfigs.keypub.storeFile)
+ storePassword rootProject.ext.signingConfigs.keypub.storePassword
+ keyAlias rootProject.ext.signingConfigs.keypub.keyAlias
+ keyPassword rootProject.ext.signingConfigs.keypub.keyPassword
+ }
+ }
+
+ buildTypes {
+ debug {
+ versionNameSuffix "_debug"
+ debuggable true
+ minifyEnabled false
+ signingConfig signingConfigs.keypub
+ }
+
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ signingConfig signingConfigs.keypub
+ }
+ }
+
+ applicationVariants.all { variant ->
+ variant.outputs.each { output ->
+ def buildType = variant.buildType.name
+ def fileName = (buildType.contains("debug"))
+ ? "${appName()}_V${defaultConfig.versionName}_${releaseTime()}.apk"
+ : "${appName()}_${variant.versionCode}_V${variant.versionName}_${releaseTime()}_${buildType}.apk"
+ output.outputFileName = fileName
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.11.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+}
diff --git a/AdbLoopbackController/app/proguard-rules.pro b/AdbLoopbackController/app/proguard-rules.pro
new file mode 100644
index 0000000..cdbb478
--- /dev/null
+++ b/AdbLoopbackController/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# proguard 规则(demo 无需混淆)
+-dontwarn **
diff --git a/AdbLoopbackController/app/src/main/AndroidManifest.xml b/AdbLoopbackController/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3fcfba7
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/AdbManager.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/AdbManager.java
new file mode 100644
index 0000000..f75e54c
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/AdbManager.java
@@ -0,0 +1,89 @@
+package com.ttstd.adbloopback;
+
+import com.ttstd.adbloopback.adb.AdbConnection;
+import com.ttstd.adbloopback.adb.AdbKeyPair;
+import com.ttstd.adbloopback.adb.AdbPairing;
+import com.ttstd.adbloopback.adb.AdbStream;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * ADB 管理器:封装密钥持久化、配对、连接及 Shell 指令执行。
+ * 它是上层 UI 与底层 ADB 协议库之间的桥梁。
+ */
+public class AdbManager {
+ private final File privateKeyFile;
+ private final File publicKeyFile;
+ private AdbKeyPair keyPair;
+ private AdbConnection connection;
+
+ public AdbManager(File filesDir) {
+ this.privateKeyFile = new File(filesDir, "adb_private.key");
+ this.publicKeyFile = new File(filesDir, "adb_public.key");
+ loadOrCreateKeyPair();
+ }
+
+ private void loadOrCreateKeyPair() {
+ try {
+ if (privateKeyFile.exists() && publicKeyFile.exists()) {
+ keyPair = AdbKeyPair.read(privateKeyFile, publicKeyFile);
+ } else {
+ keyPair = AdbKeyPair.generate();
+ keyPair.write(privateKeyFile, publicKeyFile);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ /** 发起无线调试配对(TLS-PSK) */
+ public void pair(int port, String pairingCode) throws Exception {
+ AdbPairing.pair("127.0.0.1", port, pairingCode, keyPair);
+ }
+
+ /** 连接无线调试主端口 */
+ public void connect(int port) throws Exception {
+ if (connection != null) {
+ try { connection.close(); } catch (Exception ignored) {}
+ }
+ connection = AdbConnection.connect("127.0.0.1", port, keyPair);
+ }
+
+ /** 断开连接 */
+ public void disconnect() {
+ if (connection != null) {
+ try {
+ connection.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ connection = null;
+ }
+ }
+
+ /** 执行 Shell 命令并获取全部输出 */
+ public String execShell(String command) throws Exception {
+ if (connection == null || !connection.isConnected()) {
+ throw new IOException("ADB 未连接");
+ }
+ AdbStream stream = connection.open("shell:" + command);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] data;
+ while ((data = stream.read()) != null) {
+ bos.write(data);
+ }
+ stream.close();
+ return bos.toString("UTF-8");
+ }
+
+ /** 快速发送点击指令 */
+ public void tap(int x, int y) throws Exception {
+ execShell("input tap " + x + " " + y);
+ }
+
+ public boolean isConnected() {
+ return connection != null && connection.isConnected();
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/MainActivity.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/MainActivity.java
new file mode 100644
index 0000000..49cae2b
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/MainActivity.java
@@ -0,0 +1,154 @@
+package com.ttstd.adbloopback;
+
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.textfield.TextInputEditText;
+
+public class MainActivity extends AppCompatActivity {
+
+ private AdbManager adbManager;
+
+ private TextInputEditText etPairPort;
+ private TextInputEditText etPairCode;
+ private TextInputEditText etMainPort;
+ private TextInputEditText etTapX;
+ private TextInputEditText etTapY;
+ private TextInputEditText etCmd;
+ private TextView tvStatus;
+ private TextView tvLog;
+ private ScrollView scrollLog;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ adbManager = new AdbManager(getFilesDir());
+
+ etPairPort = findViewById(R.id.et_pair_port);
+ etPairCode = findViewById(R.id.et_pair_code);
+ etMainPort = findViewById(R.id.et_main_port);
+ etTapX = findViewById(R.id.et_tap_x);
+ etTapY = findViewById(R.id.et_tap_y);
+ etCmd = findViewById(R.id.et_cmd);
+ tvStatus = findViewById(R.id.tv_status);
+ tvLog = findViewById(R.id.tv_log);
+ scrollLog = findViewById(R.id.scroll_root);
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ log("⚠️ 当前系统低于 Android 11(API 30),无线调试不可用。");
+ }
+
+ findViewById(R.id.btn_open_settings).setOnClickListener(v -> openDeveloperOptions());
+ findViewById(R.id.btn_pair).setOnClickListener(v -> doPair());
+ findViewById(R.id.btn_connect).setOnClickListener(v -> doConnect());
+ findViewById(R.id.btn_disconnect).setOnClickListener(v -> doDisconnect());
+ findViewById(R.id.btn_tap).setOnClickListener(v -> doTap());
+ findViewById(R.id.btn_exec).setOnClickListener(v -> doExec());
+ }
+
+ private void openDeveloperOptions() {
+ try {
+ startActivity(new Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS));
+ Toast.makeText(this, "请开启「无线调试」,并记录配对端口/码与主端口", Toast.LENGTH_LONG).show();
+ } catch (Exception e) {
+ Toast.makeText(this, "无法打开开发者选项,请手动进入", Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void doPair() {
+ String portStr = etPairPort.getText().toString().trim();
+ String code = etPairCode.getText().toString().trim();
+ if (portStr.isEmpty() || code.isEmpty()) {
+ Toast.makeText(this, "请填写配对端口与配对码", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ int port = Integer.parseInt(portStr);
+ runBg("配对", () -> {
+ adbManager.pair(port, code);
+ log("✅ 配对成功(TLS-PSK 完成,公钥已加入 adbd 授权列表)");
+ });
+ }
+
+ private void doConnect() {
+ String portStr = etMainPort.getText().toString().trim();
+ if (portStr.isEmpty()) {
+ Toast.makeText(this, "请填写无线调试主端口", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ int port = Integer.parseInt(portStr);
+ runBg("连接", () -> {
+ adbManager.connect(port);
+ log("✅ 已连接无线调试主端口 " + port + ",获得 ADB 权限管道");
+ runOnUiThread(() -> tvStatus.setText("状态:已连接(ADB 权限)"));
+ });
+ }
+
+ private void doDisconnect() {
+ runBg("断开", () -> {
+ adbManager.disconnect();
+ runOnUiThread(() -> tvStatus.setText("状态:未连接"));
+ log("🔌 已断开连接");
+ });
+ }
+
+ private void doTap() {
+ String xStr = etTapX.getText().toString().trim();
+ String yStr = etTapY.getText().toString().trim();
+ if (xStr.isEmpty() || yStr.isEmpty()) {
+ Toast.makeText(this, "请填写 X/Y 坐标", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ int x = Integer.parseInt(xStr);
+ int y = Integer.parseInt(yStr);
+ runBg("input tap", () -> {
+ adbManager.tap(x, y);
+ log("👆 已发送 input tap " + x + " " + y);
+ });
+ }
+
+ private void doExec() {
+ String cmd = etCmd.getText().toString().trim();
+ if (cmd.isEmpty()) {
+ Toast.makeText(this, "请输入 shell 命令", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ runBg("exec: " + cmd, () -> {
+ String out = adbManager.execShell(cmd);
+ log("▶ " + cmd + "\n" + (out.isEmpty() ? "(无输出)" : out));
+ });
+ }
+
+ private interface ThrowingRunnable {
+ void run() throws Exception;
+ }
+
+ private void runBg(String name, ThrowingRunnable task) {
+ log("⏳ 执行 " + name + " …");
+ new Thread(() -> {
+ try {
+ task.run();
+ } catch (Exception e) {
+ log("❌ " + name + " 失败:" + e.getMessage());
+ e.printStackTrace();
+ }
+ }).start();
+ }
+
+ private void log(String line) {
+ runOnUiThread(() -> {
+ tvLog.append(line + "\n");
+ scrollLog.post(() -> scrollLog.fullScroll(View.FOCUS_DOWN));
+ });
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConnection.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConnection.java
new file mode 100644
index 0000000..a3a948c
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConnection.java
@@ -0,0 +1,110 @@
+package com.ttstd.adbloopback.adb;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 自建 ADB 客户端连接:与系统 adbd(无线调试守护进程)建立明文 TCP 连接,
+ * 完成 CNXN / AUTH 握手,然后可开启 shell 流执行指令。
+ */
+public class AdbConnection {
+ private final Socket socket;
+ private final AdbKeyPair keyPair;
+ private InputStream in;
+ private OutputStream out;
+ private boolean connected = false;
+ private int lastLocalId = 1;
+
+ public AdbConnection(Socket socket, AdbKeyPair keyPair) {
+ this.socket = socket;
+ this.keyPair = keyPair;
+ }
+
+ public static AdbConnection connect(String host, int port, AdbKeyPair keyPair) throws Exception {
+ Socket s = new Socket();
+ s.connect(new InetSocketAddress(host, port), 5000);
+ s.setSoTimeout(2000); // 读超时,供 shell 读取在无输出时及时返回
+ s.setTcpNoDelay(true);
+ AdbConnection c = new AdbConnection(s, keyPair);
+ c.connect();
+ return c;
+ }
+
+ private void connect() throws Exception {
+ in = socket.getInputStream();
+ out = socket.getOutputStream();
+
+ // 1) 发送 CNXN
+ AdbMessage.write(out, new AdbMessage(AdbConstants.CNXN, AdbConstants.VERSION,
+ AdbConstants.MAX_PAYLOAD, "device::adb-loopback-demo".getBytes("UTF-8")));
+
+ // 2) 处理 AUTH 挑战,直到收到 CNXN 确认
+ while (true) {
+ AdbMessage resp = readMessage();
+ if (resp.command == AdbConstants.AUTH) {
+ int sub = resp.arg0;
+ if (sub == AdbConstants.AUTH_TOKEN) {
+ byte[] signature = keyPair.sign(resp.data);
+ AdbMessage.write(out, new AdbMessage(AdbConstants.AUTH,
+ AdbConstants.AUTH_SIGNATURE, 0, signature));
+ } else if (sub == AdbConstants.AUTH_RSAPUBLICKEY) {
+ // 某些场景下服务端主动索要公钥,一并提交
+ AdbMessage.write(out, new AdbMessage(AdbConstants.AUTH,
+ AdbConstants.AUTH_RSAPUBLICKEY, 0,
+ keyPair.getAdbPublicKey().getBytes("UTF-8")));
+ } else {
+ throw new IOException("ADB AUTH 失败,sub=" + sub);
+ }
+ } else if (resp.command == AdbConstants.CNXN) {
+ connected = true;
+ break;
+ } else {
+ throw new IOException("ADB 握手收到未知消息: 0x"
+ + Integer.toHexString(resp.command));
+ }
+ }
+ if (!connected) {
+ throw new IOException("ADB 连接建立失败");
+ }
+ }
+
+ public AdbStream open(String destination) throws Exception {
+ int localId = lastLocalId++;
+ AdbMessage.write(out, new AdbMessage(AdbConstants.OPEN, localId, 0,
+ (destination + "\0").getBytes("UTF-8")));
+
+ AdbMessage resp = readMessage();
+ if (resp.command == AdbConstants.OKAY) {
+ AdbStream stream = new AdbStream(this, localId, destination);
+ stream.setRemoteId(resp.arg0);
+ return stream;
+ } else if (resp.command == AdbConstants.CLSE) {
+ throw new IOException("ADB 流打开被拒绝");
+ } else {
+ throw new IOException("ADB 打开流收到未知响应: 0x"
+ + Integer.toHexString(resp.command));
+ }
+ }
+
+ synchronized void sendInternal(int command, int arg0, int arg1, byte[] data) throws IOException {
+ AdbMessage.write(out, new AdbMessage(command, arg0, arg1, data));
+ }
+
+ synchronized AdbMessage readMessage() throws IOException {
+ return AdbMessage.read(in);
+ }
+
+ public void close() throws IOException {
+ socket.close();
+ connected = false;
+ }
+
+ public boolean isConnected() {
+ return connected;
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConstants.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConstants.java
new file mode 100644
index 0000000..ae45333
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbConstants.java
@@ -0,0 +1,24 @@
+package com.ttstd.adbloopback.adb;
+
+/** ADB 网络协议常量(协议字段均为小端序 / little-endian)。 */
+public final class AdbConstants {
+ // 消息命令(4 字节 ASCII,按小端读取)
+ public static final int CNXN = 0x4e584e43; // "CNXN"
+ public static final int AUTH = 0x48545541; // "AUTH"
+ public static final int OPEN = 0x4e45504f; // "OPEN"
+ public static final int OKAY = 0x59414b4f; // "OKAY"
+ public static final int CLSE = 0x45534c43; // "CLSE"
+ public static final int WRTE = 0x45545257; // "WRTE"
+
+ // AUTH 子类型
+ public static final int AUTH_TOKEN = 1; // 服务端下发的随机挑战
+ public static final int AUTH_SIGNATURE = 2; // 客户端对挑战的签名
+ public static final int AUTH_RSAPUBLICKEY = 3; // 客户端公钥
+
+ public static final int VERSION = 0x01000000; // ADB 协议版本 1.0(小端)
+ public static final int MAX_PAYLOAD = 4096;
+
+ public static final String TAG = "AdbLoopback";
+
+ private AdbConstants() {}
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbKeyPair.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbKeyPair.java
new file mode 100644
index 0000000..11b21d7
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbKeyPair.java
@@ -0,0 +1,144 @@
+package com.ttstd.adbloopback.adb;
+
+import android.util.Base64;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.RSAPublicKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.security.interfaces.RSAPublicKey;
+
+/**
+ * ADB 密钥对:负责生成 RSA 密钥、对 AUTH 挑战做 SHA1withRSA 签名,
+ * 以及把公钥编码成 adbd 可识别的 adb 公钥格式(base64(PKCS#1 RSAPublicKey) + 注释)。
+ */
+public class AdbKeyPair {
+ private final PrivateKey privateKey;
+ private final PublicKey publicKey;
+
+ public AdbKeyPair(PrivateKey privateKey, PublicKey publicKey) {
+ this.privateKey = privateKey;
+ this.publicKey = publicKey;
+ }
+
+ public static AdbKeyPair generate() throws Exception {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(2048, new SecureRandom());
+ KeyPair kp = gen.generateKeyPair();
+ return new AdbKeyPair(kp.getPrivate(), kp.getPublic());
+ }
+
+ public static AdbKeyPair read(File privateFile, File publicFile) throws Exception {
+ byte[] pk = readAllBytes(privateFile);
+ byte[] pb = readAllBytes(publicFile);
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ PrivateKey priv = kf.generatePrivate(new PKCS8EncodedKeySpec(pk));
+ PublicKey pub = kf.generatePublic(new X509EncodedKeySpec(pb));
+ return new AdbKeyPair(priv, pub);
+ }
+
+ public void write(File privateFile, File publicFile) throws Exception {
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ byte[] pk = kf.getKeySpec(privateKey, PKCS8EncodedKeySpec.class).getEncoded();
+ byte[] pb = kf.getKeySpec(publicKey, X509EncodedKeySpec.class).getEncoded();
+ writeAllBytes(privateFile, pk);
+ writeAllBytes(publicFile, pb);
+ }
+
+ public PrivateKey getPrivateKey() {
+ return privateKey;
+ }
+
+ public PublicKey getPublicKey() {
+ return publicKey;
+ }
+
+ /** 对服务端下发的 AUTH token 做签名:SHA1(token) 再用 RSA PKCS#1 v1.5 签名。 */
+ public byte[] sign(byte[] token) throws Exception {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+ byte[] hash = md.digest(token);
+ Signature sig = Signature.getInstance("SHA1withRSA");
+ sig.initSign(privateKey);
+ sig.update(hash);
+ return sig.sign();
+ }
+
+ /**
+ * 生成 adb 格式公钥字符串:base64( DER(PKCS#1 RSAPublicKey) ) + " " + 注释 + "\n"
+ * adbd 解析时只取 base64 部分,按 (modulus || exponent) 还原 RSA 公钥。
+ */
+ public String getAdbPublicKey() throws Exception {
+ RSAPublicKey rsa = (RSAPublicKey) publicKey;
+ byte[] modulus = toDerInteger(rsa.getModulus());
+ byte[] exponent = toDerInteger(rsa.getPublicExponent());
+ byte[] pkcs1 = encodeSequence(modulus, exponent);
+ String b64 = Base64.encodeToString(pkcs1, Base64.NO_WRAP);
+ return b64 + " unknown@unknown\n";
+ }
+
+ // ---- DER 编码辅助 ----
+
+ private static byte[] toDerInteger(BigInteger v) {
+ return derTlv((byte) 0x02, v.toByteArray());
+ }
+
+ private static byte[] encodeSequence(byte[]... parts) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ for (byte[] p : parts) bos.write(p);
+ return derTlv((byte) 0x30, bos.toByteArray());
+ }
+
+ private static byte[] derTlv(byte tag, byte[] content) {
+ return concat(new byte[]{tag}, derLen(content.length), content);
+ }
+
+ private static byte[] derLen(int len) {
+ if (len < 0x80) {
+ return new byte[]{(byte) len};
+ } else if (len < 0x100) {
+ return new byte[]{(byte) 0x81, (byte) len};
+ } else {
+ return new byte[]{(byte) 0x82, (byte) (len >> 8), (byte) len};
+ }
+ }
+
+ private static byte[] concat(byte[] a, byte[] b) {
+ byte[] r = new byte[a.length + b.length];
+ System.arraycopy(a, 0, r, 0, a.length);
+ System.arraycopy(b, 0, r, a.length, b.length);
+ return r;
+ }
+
+ private static byte[] concat(byte[] a, byte[] b, byte[] c) {
+ return concat(concat(a, b), c);
+ }
+
+ private static byte[] readAllBytes(File f) throws IOException {
+ try (FileInputStream in = new FileInputStream(f)) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) > 0) bos.write(buf, 0, n);
+ return bos.toByteArray();
+ }
+ }
+
+ private static void writeAllBytes(File f, byte[] data) throws IOException {
+ try (FileOutputStream out = new FileOutputStream(f)) {
+ out.write(data);
+ }
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbMessage.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbMessage.java
new file mode 100644
index 0000000..41610de
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbMessage.java
@@ -0,0 +1,84 @@
+package com.ttstd.adbloopback.adb;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.zip.CRC32;
+
+/**
+ * ADB 消息封装与读写。
+ *
+ *
+ * struct message {
+ * unsigned command; // 命令
+ * unsigned arg0; // 第一参数(本地 id)
+ * unsigned arg1; // 第二参数(远端 id)
+ * unsigned data_length; // 负载长度
+ * unsigned data_crc32; // 负载 CRC32
+ * unsigned magic; // command ^ 0xffffffff
+ * };
+ *
+ * 所有整型字段均为小端序(little-endian)。
+ */
+public class AdbMessage {
+ public int command;
+ public int arg0;
+ public int arg1;
+ public byte[] data;
+
+ public AdbMessage(int command, int arg0, int arg1, byte[] data) {
+ this.command = command;
+ this.arg0 = arg0;
+ this.arg1 = arg1;
+ this.data = data != null ? data : new byte[0];
+ }
+
+ public static void write(OutputStream out, AdbMessage msg) throws IOException {
+ byte[] payload = msg.data;
+ int crc = crc32(payload);
+ ByteBuffer buf = ByteBuffer.allocate(24 + payload.length);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(msg.command);
+ buf.putInt(msg.arg0);
+ buf.putInt(msg.arg1);
+ buf.putInt(payload.length);
+ buf.putInt(crc);
+ buf.putInt(msg.command ^ 0xffffffff);
+ buf.put(payload);
+ out.write(buf.array());
+ out.flush();
+ }
+
+ public static AdbMessage read(InputStream in) throws IOException {
+ byte[] header = readExactly(in, 24);
+ ByteBuffer buf = ByteBuffer.wrap(header);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ int command = buf.getInt();
+ int arg0 = buf.getInt();
+ int arg1 = buf.getInt();
+ int len = buf.getInt();
+ /* int crc = */ buf.getInt();
+ /* int magic = */ buf.getInt();
+ byte[] data = len > 0 ? readExactly(in, len) : new byte[0];
+ return new AdbMessage(command, arg0, arg1, data);
+ }
+
+ public static byte[] readExactly(InputStream in, int n) throws IOException {
+ byte[] b = new byte[n];
+ int off = 0;
+ while (off < n) {
+ int r = in.read(b, off, n - off);
+ if (r < 0) throw new IOException("ADB 连接意外断开 (EOF)");
+ off += r;
+ }
+ return b;
+ }
+
+ public static int crc32(byte[] data) {
+ CRC32 c = new CRC32();
+ c.update(data);
+ return (int) c.getValue();
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbPairing.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbPairing.java
new file mode 100644
index 0000000..4d0e4b5
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbPairing.java
@@ -0,0 +1,88 @@
+package com.ttstd.adbloopback.adb;
+
+import com.ttstd.adbloopback.tls.TlsPskClient;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * 无线调试配对:在 TLS-PSK 通道之上,与 adbd pairing 守护进程交换 RSA 公钥。
+ *
+ * 配对成功后,本 App 的公钥被加入 adbd 的授权列表,之后即可用同一密钥对
+ * 无线调试主端口发起 ADB 连接(无需电脑 / Shizuku)。
+ *
+ *
线规(AOSP pairing 协议):每条消息 = 4 字节头 [type(1)][reserved(1)][len(2, 大端)] + payload。
+ */
+public class AdbPairing {
+
+ // AOSP pairing.h MsgType
+ private static final byte MSG_PING = 0;
+ private static final byte MSG_PONG = 1;
+ private static final byte MSG_PAIRING_REQ = 2;
+ private static final byte MSG_PAIRING_RESULT = 3;
+ private static final byte MSG_PAIRING_ACK = 4;
+
+ public static void pair(String host, int port, String pairingCode, AdbKeyPair keyPair) throws Exception {
+ // 配对码同时作为 TLS-PSK 的 PSK 与 identity(与 AOSP adb pair 一致)
+ byte[] psk = pairingCode.getBytes("UTF-8");
+ TlsPskClient tls = new TlsPskClient(host, port, pairingCode, psk);
+ tls.handshake();
+
+ InputStream in = tls.getInputStream();
+ OutputStream out = tls.getOutputStream();
+
+ // 向服务端提交本 App 的 ADB 公钥
+ byte[] pubKey = keyPair.getAdbPublicKey().getBytes("UTF-8");
+ writePairingMsg(out, MSG_PAIRING_REQ, pubKey);
+
+ // 读取服务端回应(可能先有 Ping,然后 PairingResult)
+ boolean paired = false;
+ for (int i = 0; i < 4; i++) {
+ PairingMsg msg = readPairingMsg(in);
+ if (msg.type == MSG_PING) {
+ writePairingMsg(out, MSG_PONG, new byte[0]);
+ } else if (msg.type == MSG_PAIRING_RESULT) {
+ paired = true;
+ break;
+ } else {
+ throw new IOException("无线调试配对收到意外消息类型: " + msg.type);
+ }
+ }
+ if (!paired) {
+ throw new IOException("无线调试配对未完成");
+ }
+
+ // 回 ACK(部分实现需要)
+ try {
+ writePairingMsg(out, MSG_PAIRING_ACK, new byte[0]);
+ } catch (IOException ignored) {
+ }
+ }
+
+ private static void writePairingMsg(OutputStream out, byte type, byte[] payload) throws IOException {
+ byte[] header = {type, 0x00, (byte) (payload.length >> 8), (byte) payload.length};
+ out.write(header);
+ out.write(payload);
+ out.flush();
+ }
+
+ private static PairingMsg readPairingMsg(InputStream in) throws IOException {
+ byte[] header = AdbMessage.readExactly(in, 4);
+ byte type = header[0];
+ int len = ((header[2] & 0xff) << 8) | (header[3] & 0xff);
+ byte[] payload = len > 0 ? AdbMessage.readExactly(in, len) : new byte[0];
+ return new PairingMsg(type, payload);
+ }
+
+ private static class PairingMsg {
+ final byte type;
+ @SuppressWarnings("unused")
+ final byte[] payload;
+
+ PairingMsg(byte type, byte[] payload) {
+ this.type = type;
+ this.payload = payload;
+ }
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbStream.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbStream.java
new file mode 100644
index 0000000..c0113d2
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/adb/AdbStream.java
@@ -0,0 +1,96 @@
+package com.ttstd.adbloopback.adb;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.Queue;
+
+/**
+ * ADB 流(对应 ADB 协议里的 OPEN/WRTE/OKAY/CLSE 多路复用)。
+ * 本 demo 仅在单流场景下使用,内部通过 connection 的同步读写防止消息交错。
+ */
+public class AdbStream {
+ private final AdbConnection connection;
+ private final int localId;
+ private final String destination;
+ private int remoteId = -1;
+ private boolean closed = false;
+ private final Queue incoming = new LinkedList<>();
+ private final Object lock = new Object();
+
+ AdbStream(AdbConnection connection, int localId, String destination) {
+ this.connection = connection;
+ this.localId = localId;
+ this.destination = destination;
+ }
+
+ void setRemoteId(int remoteId) {
+ this.remoteId = remoteId;
+ }
+
+ public int write(byte[] data) throws IOException {
+ synchronized (lock) {
+ connection.sendInternal(AdbConstants.WRTE, localId, remoteId, data);
+ // 等待对端 OKAY,其间若收到 WRTE 数据则先缓存起来
+ while (true) {
+ AdbMessage msg = connection.readMessage();
+ if (msg.command == AdbConstants.OKAY) {
+ break;
+ } else if (msg.command == AdbConstants.WRTE) {
+ incoming.add(msg.data);
+ } else if (msg.command == AdbConstants.CLSE) {
+ closed = true;
+ break;
+ } else {
+ break;
+ }
+ }
+ }
+ return data.length;
+ }
+
+ /** 读取对端发来的数据;无数据且对端未关闭时按 socket 超时返回 null。 */
+ public byte[] read() throws IOException {
+ synchronized (lock) {
+ if (!incoming.isEmpty()) {
+ return incoming.poll();
+ }
+ while (!closed) {
+ try {
+ AdbMessage msg = connection.readMessage();
+ if (msg.command == AdbConstants.WRTE) {
+ incoming.add(msg.data);
+ return incoming.poll();
+ } else if (msg.command == AdbConstants.OKAY) {
+ // 暂无数据,继续等待
+ } else if (msg.command == AdbConstants.CLSE) {
+ closed = true;
+ break;
+ }
+ } catch (IOException e) {
+ // 多为 socket 读超时,返回当前缓存(可能为 null)
+ break;
+ }
+ }
+ return incoming.poll();
+ }
+ }
+
+ public void close() throws IOException {
+ synchronized (lock) {
+ if (closed) return;
+ try {
+ connection.sendInternal(AdbConstants.CLSE, localId, remoteId, new byte[0]);
+ } catch (IOException ignored) {
+ }
+ closed = true;
+ }
+ }
+
+ public boolean isClosed() {
+ return closed;
+ }
+
+ public String getDestination() {
+ return destination;
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsCrypto.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsCrypto.java
new file mode 100644
index 0000000..131b902
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsCrypto.java
@@ -0,0 +1,72 @@
+package com.ttstd.adbloopback.tls;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.MessageDigest;
+
+/** TLS 1.2 所需的密码学原语:SHA-256、HMAC-SHA256、TLS PRF、AES-128-GCM。 */
+public final class TlsCrypto {
+
+ public static byte[] sha256(byte[] data) throws Exception {
+ return MessageDigest.getInstance("SHA-256").digest(data);
+ }
+
+ public static byte[] hmacSha256(byte[] key, byte[] data) throws Exception {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(data);
+ }
+
+ /**
+ * TLS 1.2 PRF,使用 SHA-256:
+ * PRF(secret, label, seed) = P_SHA256(secret, label + seed)
+ */
+ public static byte[] prf(byte[] secret, String label, byte[] seed, int outLen) throws Exception {
+ byte[] labelSeed = concat(label.getBytes("ASCII"), seed);
+ byte[] result = new byte[outLen];
+ byte[] prev = hmacSha256(secret, labelSeed); // A(1)
+ int generated = 0;
+ while (generated < outLen) {
+ byte[] block = hmacSha256(secret, concat(prev, labelSeed));
+ int take = Math.min(block.length, outLen - generated);
+ System.arraycopy(block, 0, result, generated, take);
+ generated += take;
+ prev = hmacSha256(secret, prev); // A(i+1)
+ }
+ return result;
+ }
+
+ public static byte[] aesGcmEncrypt(byte[] key, byte[] nonce12, byte[] plaintext, byte[] aad) throws Exception {
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce12));
+ c.updateAAD(aad);
+ return c.doFinal(plaintext);
+ }
+
+ public static byte[] aesGcmDecrypt(byte[] key, byte[] nonce12, byte[] ciphertextWithTag, byte[] aad) throws Exception {
+ Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
+ c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce12));
+ c.updateAAD(aad);
+ return c.doFinal(ciphertextWithTag);
+ }
+
+ public static byte[] concat(byte[]... arrays) {
+ int len = 0;
+ for (byte[] a : arrays) {
+ if (a != null) len += a.length;
+ }
+ byte[] r = new byte[len];
+ int off = 0;
+ for (byte[] a : arrays) {
+ if (a != null) {
+ System.arraycopy(a, 0, r, off, a.length);
+ off += a.length;
+ }
+ }
+ return r;
+ }
+
+ private TlsCrypto() {}
+}
diff --git a/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsPskClient.java b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsPskClient.java
new file mode 100644
index 0000000..d7cade1
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/java/com/ttstd/adbloopback/tls/TlsPskClient.java
@@ -0,0 +1,332 @@
+package com.ttstd.adbloopback.tls;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+/**
+ * 最小化 TLS 1.2 PSK 客户端,仅支持 {@code TLS_PSK_WITH_AES_128_GCM_SHA256}。
+ *
+ * Android 11+ 的「无线调试 → 使用配对码配对设备」走的就是这条 TLS-PSK 通道:
+ * 配对码同时作为 PSK 与 PSK identity。标准 Android (JSSE / Conscrypt) 不暴露 PSK 接口,
+ * 故这里用 Android 自带密码学原语(AES-GCM / HMAC / SHA-256)手搓实现。
+ *
+ *
握手完成后,通过 {@link #getInputStream()}/{@link #getOutputStream()} 获得透明加密的流,
+ * 上层即可在其上跑 adb 的配对公钥交换协议。
+ */
+public class TlsPskClient {
+
+ // 仅支持这一个密码套件(与 AOSP adbd pairing 一致)
+ private static final int CIPHER_PSK_AES128_GCM_SHA256 = 0x00A8;
+
+ private final Socket socket;
+ private InputStream in;
+ private OutputStream out;
+ private final byte[] psk;
+ private final byte[] identity;
+
+ private final byte[] clientRandom = new byte[32];
+ private final byte[] serverRandom = new byte[32];
+ private byte[] clientWriteKey, serverWriteKey, clientWriteIV, serverWriteIV;
+ private byte[] masterSecret;
+ private long clientSeq = 0;
+ private long serverSeq = 0;
+
+ private final ByteArrayOutputStream handshakeBuf = new ByteArrayOutputStream();
+
+ public TlsPskClient(String host, int port, String identityStr, byte[] psk) throws IOException {
+ this.psk = psk;
+ this.identity = identityStr.getBytes("UTF-8");
+ this.socket = new Socket();
+ this.socket.connect(new InetSocketAddress(host, port), 8000);
+ this.socket.setSoTimeout(8000);
+ this.socket.setTcpNoDelay(true);
+ this.in = socket.getInputStream();
+ this.out = socket.getOutputStream();
+ new SecureRandom().nextBytes(clientRandom);
+ }
+
+ public InputStream getInputStream() {
+ return new TlsInputStream();
+ }
+
+ public OutputStream getOutputStream() {
+ return new TlsOutputStream();
+ }
+
+ public void handshake() throws Exception {
+ sendClientHello();
+ readServerHelloAndDone();
+ deriveKeys();
+ sendClientKeyExchange();
+ sendChangeCipherSpec();
+ sendFinished();
+ readServerFinished();
+ }
+
+ // ---------- 握手各阶段 ----------
+
+ private void sendClientHello() throws Exception {
+ byte[] sigAlgs = {
+ 0x00, 0x0c, // 签名算法列表长度 = 12
+ 0x04, 0x03, // sha256 + ecdsa
+ 0x05, 0x03, // sha384 + ecdsa
+ 0x06, 0x03, // sha512 + ecdsa
+ 0x04, 0x01, // sha256 + rsa
+ 0x05, 0x01, // sha384 + rsa
+ 0x06, 0x01, // sha512 + rsa
+ 0x02, 0x01 // sha1 + rsa
+ };
+ byte[] ext = concat(shortBytes(0x000d), shortBytes(sigAlgs.length), sigAlgs);
+ byte[] extensions = concat(shortBytes(ext.length), ext);
+
+ byte[] cipherSuites = concat(shortBytes(2), new byte[]{(byte) 0x00, (byte) 0xA8});
+ byte[] clientHello = concat(
+ new byte[]{0x03, 0x03}, // client_version TLS 1.2
+ clientRandom, // 32 字节
+ new byte[]{0x00}, // session_id 长度 0
+ cipherSuites, // 4 字节
+ new byte[]{0x01, 0x00}, // compression: 1 个方法, null(0x00)
+ extensions
+ );
+ byte[] body = concat(new byte[]{0x01}, int24(clientHello.length), clientHello);
+ out.write(buildPlainRecord((byte) 22, body));
+ out.flush();
+ handshakeBuf.write(body);
+ }
+
+ private void readServerHelloAndDone() throws Exception {
+ boolean gotServerHello = false;
+ boolean gotDone = false;
+ while (!gotDone) {
+ Record rec = readRecordAny();
+ if (rec.type == 21) {
+ throw new IOException("TLS 握手收到 alert: "
+ + (rec.payload.length > 1 ? (rec.payload[1] & 0xff) : 0));
+ }
+ if (rec.type != 22) {
+ throw new IOException("期望握手记录,实际类型: " + rec.type);
+ }
+ int off = 0;
+ byte[] p = rec.payload;
+ while (off < p.length) {
+ int htype = p[off] & 0xff;
+ int hlen = ((p[off + 1] & 0xff) << 16) | ((p[off + 2] & 0xff) << 8) | (p[off + 3] & 0xff);
+ byte[] hbody = Arrays.copyOfRange(p, off + 4, off + 4 + hlen);
+ handshakeBuf.write(p, off, 4 + hlen);
+ if (htype == 2) {
+ parseServerHello(hbody);
+ gotServerHello = true;
+ } else if (htype == 14) {
+ gotDone = true;
+ }
+ off += 4 + hlen;
+ }
+ }
+ if (!gotServerHello) {
+ throw new IOException("未收到 ServerHello");
+ }
+ }
+
+ private void parseServerHello(byte[] b) throws IOException {
+ System.arraycopy(b, 2, serverRandom, 0, 32); // version(2) + random(32)
+ int sidLen = b[34] & 0xff;
+ int pos = 35 + sidLen;
+ int cs = ((b[pos] & 0xff) << 8) | (b[pos + 1] & 0xff);
+ if (cs != CIPHER_PSK_AES128_GCM_SHA256) {
+ throw new IOException("服务端未选择 PSK_AES_128_GCM 密码套件,实际: 0x"
+ + Integer.toHexString(cs));
+ }
+ }
+
+ private void deriveKeys() throws Exception {
+ // PSK 预主密钥:other_secret(空) || psk
+ byte[] premaster = concat(new byte[]{0x00, 0x00}, shortBytes(psk.length), psk);
+ masterSecret = TlsCrypto.prf(premaster, "master secret",
+ concat(clientRandom, serverRandom), 48);
+ byte[] keyBlock = TlsCrypto.prf(masterSecret, "key expansion",
+ concat(serverRandom, clientRandom), 40);
+ clientWriteKey = Arrays.copyOfRange(keyBlock, 0, 16);
+ serverWriteKey = Arrays.copyOfRange(keyBlock, 16, 32);
+ clientWriteIV = Arrays.copyOfRange(keyBlock, 32, 36);
+ serverWriteIV = Arrays.copyOfRange(keyBlock, 36, 40);
+ }
+
+ private void sendClientKeyExchange() throws Exception {
+ byte[] body = concat(new byte[]{0x10}, int24(2 + identity.length),
+ shortBytes(identity.length), identity);
+ out.write(buildPlainRecord((byte) 22, body));
+ out.flush();
+ handshakeBuf.write(body);
+ }
+
+ private void sendChangeCipherSpec() throws IOException {
+ out.write(new byte[]{20, 3, 3, 0, 1, 1});
+ out.flush();
+ }
+
+ private void sendFinished() throws Exception {
+ byte[] hash = TlsCrypto.sha256(handshakeBuf.toByteArray());
+ byte[] verify = TlsCrypto.prf(masterSecret, "client finished", hash, 12);
+ byte[] body = concat(new byte[]{0x14}, int24(12), verify);
+ handshakeBuf.write(body); // 计入,供服务端 Finished 校验
+ byte[] record = buildEncryptedRecord((byte) 23, body);
+ out.write(record);
+ out.flush();
+ }
+
+ private void readServerFinished() throws Exception {
+ Record r = readRecordAny();
+ if (r.type == 20) { // 服务端 ChangeCipherSpec(明文)
+ r = readRecordAny();
+ }
+ if (r.type != 23) {
+ throw new IOException("期望加密 Finished,实际类型: " + r.type);
+ }
+ byte[] plaintext = decryptRecord(r);
+ byte[] expected = TlsCrypto.prf(masterSecret, "server finished",
+ TlsCrypto.sha256(handshakeBuf.toByteArray()), 12);
+ byte[] actual = Arrays.copyOfRange(plaintext, 4, 16);
+ if (!Arrays.equals(expected, actual)) {
+ throw new IOException("TLS Finished 校验失败");
+ }
+ }
+
+ // ---------- 记录层 ----------
+
+ private static class Record {
+ int type;
+ byte[] payload;
+ Record(int type, byte[] payload) {
+ this.type = type;
+ this.payload = payload;
+ }
+ }
+
+ private Record readRecordAny() throws IOException {
+ byte[] header = readExactly(5);
+ int type = header[0] & 0xff;
+ int len = ((header[3] & 0xff) << 8) | (header[4] & 0xff);
+ byte[] payload = readExactly(len);
+ return new Record(type, payload);
+ }
+
+ private byte[] buildPlainRecord(byte contentType, byte[] body) {
+ byte[] header = {contentType, 3, 3, (byte) (body.length >> 8), (byte) body.length};
+ return concat(header, body);
+ }
+
+ private byte[] buildEncryptedRecord(byte contentType, byte[] plaintext) throws Exception {
+ long seq = clientSeq++;
+ byte[] explicit = longTo8(seq);
+ byte[] nonce = concat(clientWriteIV, explicit);
+ byte[] aad = {contentType, 3, 3, (byte) (plaintext.length >> 8), (byte) plaintext.length};
+ byte[] ct = TlsCrypto.aesGcmEncrypt(clientWriteKey, nonce, plaintext, aad);
+ byte[] payload = concat(explicit, ct);
+ byte[] header = {contentType, 3, 3, (byte) (payload.length >> 8), (byte) payload.length};
+ return concat(header, payload);
+ }
+
+ private byte[] decryptRecord(Record r) throws Exception {
+ byte[] payload = r.payload;
+ byte[] explicit = Arrays.copyOfRange(payload, 0, 8);
+ byte[] ct = Arrays.copyOfRange(payload, 8, payload.length);
+ byte[] nonce = concat(serverWriteIV, explicit);
+ int plainLen = payload.length - 8 - 16;
+ byte[] aad = {23, 3, 3, (byte) (plainLen >> 8), (byte) plainLen};
+ return TlsCrypto.aesGcmDecrypt(serverWriteKey, nonce, ct, aad);
+ }
+
+ private byte[] readExactly(int n) throws IOException {
+ byte[] b = new byte[n];
+ int off = 0;
+ while (off < n) {
+ int r = in.read(b, off, n - off);
+ if (r < 0) throw new IOException("TLS 连接断开 (EOF)");
+ off += r;
+ }
+ return b;
+ }
+
+ // ---------- 透明加密流 ----------
+
+ private class TlsInputStream extends InputStream {
+ private byte[] buf = new byte[0];
+ private int bufPos = 0;
+
+ @Override
+ public int read() throws IOException {
+ byte[] b = new byte[1];
+ int n = read(b, 0, 1);
+ return n <= 0 ? -1 : (b[0] & 0xff);
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (bufPos >= buf.length) {
+ Record r = readRecordAny();
+ if (r.type != 23) {
+ throw new IOException("TLS 流期望应用数据记录,实际: " + r.type);
+ }
+ try {
+ buf = decryptRecord(r);
+ } catch (Exception e) {
+ throw new IOException("TLS 解密失败", e);
+ }
+ bufPos = 0;
+ }
+ int avail = buf.length - bufPos;
+ int n = Math.min(len, avail);
+ System.arraycopy(buf, bufPos, b, off, n);
+ bufPos += n;
+ return n;
+ }
+ }
+
+ private class TlsOutputStream extends OutputStream {
+ @Override
+ public void write(int b) throws IOException {
+ write(new byte[]{(byte) b}, 0, 1);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ byte[] slice = Arrays.copyOfRange(b, off, off + len);
+ try {
+ byte[] record = buildEncryptedRecord((byte) 23, slice);
+ out.write(record);
+ out.flush();
+ } catch (Exception e) {
+ throw new IOException("TLS 加密失败", e);
+ }
+ }
+ }
+
+ // ---------- 字节工具 ----------
+
+ private static byte[] shortBytes(int v) {
+ return new byte[]{(byte) (v >> 8), (byte) v};
+ }
+
+ private static byte[] int24(int v) {
+ return new byte[]{(byte) (v >> 16), (byte) (v >> 8), (byte) v};
+ }
+
+ private static byte[] longTo8(long v) {
+ byte[] b = new byte[8];
+ for (int i = 7; i >= 0; i--) {
+ b[i] = (byte) (v & 0xff);
+ v >>= 8;
+ }
+ return b;
+ }
+
+ private static byte[] concat(byte[]... arrays) {
+ return TlsCrypto.concat(arrays);
+ }
+}
diff --git a/AdbLoopbackController/app/src/main/res/layout/activity_main.xml b/AdbLoopbackController/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..0cb5d1b
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AdbLoopbackController/app/src/main/res/mipmap-hdpi/ic_launcher.png b/AdbLoopbackController/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..cde69bc
Binary files /dev/null and b/AdbLoopbackController/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/AdbLoopbackController/app/src/main/res/mipmap-mdpi/ic_launcher.png b/AdbLoopbackController/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c133a0c
Binary files /dev/null and b/AdbLoopbackController/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/AdbLoopbackController/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/AdbLoopbackController/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..bfa42f0
Binary files /dev/null and b/AdbLoopbackController/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/AdbLoopbackController/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/AdbLoopbackController/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..324e72c
Binary files /dev/null and b/AdbLoopbackController/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/AdbLoopbackController/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/AdbLoopbackController/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..aee44e1
Binary files /dev/null and b/AdbLoopbackController/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/AdbLoopbackController/app/src/main/res/values/strings.xml b/AdbLoopbackController/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..df51bf2
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ ADB 环回控制端
+
diff --git a/AdbLoopbackController/app/src/main/res/xml/network_security_config.xml b/AdbLoopbackController/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d7b4192
--- /dev/null
+++ b/AdbLoopbackController/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/AdbLoopbackController/build.gradle b/AdbLoopbackController/build.gradle
new file mode 100644
index 0000000..05c39aa
--- /dev/null
+++ b/AdbLoopbackController/build.gradle
@@ -0,0 +1,5 @@
+// Top-level build file
+plugins {
+ id 'com.android.application' version '8.1.4' apply false
+}
+apply from: "config.gradle"
diff --git a/AdbLoopbackController/gradle.properties b/AdbLoopbackController/gradle.properties
new file mode 100644
index 0000000..f55b0ed
--- /dev/null
+++ b/AdbLoopbackController/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.enableJetifier=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/AdbLoopbackController/gradle/wrapper/gradle-wrapper.jar b/AdbLoopbackController/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/AdbLoopbackController/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/AdbLoopbackController/gradle/wrapper/gradle-wrapper.properties b/AdbLoopbackController/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..3f63540
--- /dev/null
+++ b/AdbLoopbackController/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Mar 16 15:27:10 CST 2021
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
diff --git a/AdbLoopbackController/settings.gradle b/AdbLoopbackController/settings.gradle
new file mode 100644
index 0000000..176b91e
--- /dev/null
+++ b/AdbLoopbackController/settings.gradle
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url "https://jitpack.io" }
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url "https://jitpack.io" }
+ }
+}
+
+rootProject.name = "AdbLoopbackController"
+include ':app'
diff --git a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
index 17893d9..002e8a8 100644
--- a/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
+++ b/WebRTCControlled/app/src/main/java/com/ttstd/controlled/webrtc/WebRtcClient.java
@@ -68,6 +68,12 @@ public class WebRtcClient {
private RemoteDisconnectCallback remoteDisconnectCallback;
private boolean remoteDisconnectNotified = false;
+ // 关键修复:控制端(网页)会在被控端弹出"接受"对话框期间就把 ICE 候选发过来,
+ // 此时 peerConnection 尚未创建 / 远端描述(offer)尚未设置,直接 addIceCandidate 会被丢弃。
+ // 因此先把候选缓存起来,等 setRemoteDescription(offer) 完成后再统一 flush。
+ private boolean remoteDescriptionSet = false;
+ private final List pendingRemoteCandidates = new ArrayList<>();
+
public interface InputCommandCallback {
void onCommandReceived(byte[] data);
}
@@ -162,6 +168,8 @@ public class WebRtcClient {
}
this.currentControllerId = controllerId;
this.remoteDisconnectNotified = false;
+ this.remoteDescriptionSet = false;
+ this.pendingRemoteCandidates.clear();
List iceServers = new ArrayList<>();
// 建议:如果你有 TURN 服务器,请务必在此处添加,例如:
@@ -237,6 +245,9 @@ public class WebRtcClient {
@Override
public void onSetSuccess() {
+ // 远端描述(offer)已就绪,先 flush 之前缓存的控制端候选。
+ remoteDescriptionSet = true;
+ flushPendingCandidates();
// 创建 Answer
MediaConstraints constraints = new MediaConstraints();
peerConnection.createAnswer(new AppSdpObserver(new AppSdpObserver.SdpEventListener() {
@@ -297,11 +308,24 @@ public class WebRtcClient {
}
public void addIceCandidate(IceCandidate candidate) {
+ if (peerConnection == null || !remoteDescriptionSet) {
+ // 连接尚未就绪(还在等待用户接受 / 远端描述未设置),先缓存避免候选被丢弃。
+ pendingRemoteCandidates.add(candidate);
+ return;
+ }
if (peerConnection != null) {
peerConnection.addIceCandidate(candidate);
}
}
+ private void flushPendingCandidates() {
+ if (peerConnection == null) return;
+ for (IceCandidate c : pendingRemoteCandidates) {
+ peerConnection.addIceCandidate(c);
+ }
+ pendingRemoteCandidates.clear();
+ }
+
public void sendInputResponse(String responseJson) {
if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) {
DataChannel.Buffer buffer = new DataChannel.Buffer(
@@ -319,6 +343,8 @@ public class WebRtcClient {
}
private void closeCurrentConnection() {
+ remoteDescriptionSet = false;
+ pendingRemoteCandidates.clear();
if (dataChannel != null) {
try {
dataChannel.unregisterObserver();
diff --git a/WebRTCControllerWeb/.gitignore b/WebRTCControllerWeb/.gitignore
new file mode 100644
index 0000000..2d177ef
--- /dev/null
+++ b/WebRTCControllerWeb/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+*.log
+.DS_Store
+.idea/
+.vscode/
diff --git a/WebRTCControllerWeb/README.md b/WebRTCControllerWeb/README.md
new file mode 100644
index 0000000..b80ae69
--- /dev/null
+++ b/WebRTCControllerWeb/README.md
@@ -0,0 +1,105 @@
+# WebRTC 网页远程控制 (WebRTCControllerWeb)
+
+基于 **Vue3 + Vite** 的 WebRTC 网页远程控制端,参考并兼容以下既有项目:
+
+- `WebRTCSignalServer` —— Java/Spring 信令服务器(WebSocket,端点 `/ws/signal`)
+- `WebRTCControlled` —— Android 被控端(采集屏幕、接收控制指令)
+- `WebRTCController` / `webrtc_controller_flutter` —— Android / Flutter 控制端
+
+本项目的定位是 **网页版控制端 (CONTROLLER)**,与原有信令协议、protobuf 控制协议 **完全兼容**:
+
+- 通过 WebSocket 连接信令服务器并注册为 `CONTROLLER`
+- 作为 OFFER 方创建 WebRTC 连接(仅接收远端视频 `recvonly` + 一条控制用 `DataChannel`)
+- 在浏览器中显示被控端画面,并通过触摸/鼠标/导航按键采集输入
+- 将输入转换为相对坐标 (0.0~1.0) 的 protobuf `ControlMessage`,经 DataChannel 发送给被控端执行
+
+## 目录结构
+
+```
+WebRTCControllerWeb/
+├── index.html
+├── package.json
+├── vite.config.js
+├── public/
+│ └── control_message.proto # 与 Android/Flutter 完全一致的 protobuf 定义
+├── server/ # 可选:轻量 Node 信令服务器(与 Spring 版协议一致)
+│ ├── index.js
+│ └── package.json
+└── src/
+ ├── main.js
+ ├── App.vue
+ ├── style.css
+ ├── proto/controlMessage.js # protobufjs 运行时编码
+ ├── services/
+ │ ├── SignalingClient.js # WebSocket 信令(对应 Android WebSocketClient)
+ │ └── WebRtcController.js # WebRTC + DataChannel(对应 WebRtcClient)
+ ├── store/controllerStore.js # 全局状态与流程编排(对应 RemoteController)
+ └── components/
+ ├── ConnectionPanel.vue # 信令连接 / 设备列表 / 发起控制
+ ├── RemoteScreen.vue # 远端视频 + 触摸/滑动采集层
+ ├── ControlBar.vue # 主页/返回/多任务/音量/电源等导航键
+ └── StatsBar.vue # 连接统计(分辨率/帧率/下载速率等)
+```
+
+## 运行
+
+### 1. 准备信令服务器(二选一)
+
+**方式 A:复用现有 Spring 信令服务器**
+直接启动 `WebRTCSignalServer` 即可(默认 `ws://:8088/ws/signal`)。
+
+**方式 B:使用本项目自带的 Node 信令服务器**
+```bash
+cd server
+npm install
+npm start # 监听 ws://localhost:8088/ws/signal
+```
+
+### 2. 启动被控端
+在安卓设备安装并运行 `WebRTCControlled`,保持其在线并连接到同一信令服务器。
+
+### 3. 启动网页控制端
+```bash
+cd WebRTCControllerWeb
+npm install
+npm run dev # 默认 http://localhost:5173
+```
+
+> 浏览器需支持 WebRTC。本地 `http://localhost` 可直接使用;
+> 部署到远程地址时建议使用 `https://` 与 `wss://`,否则部分浏览器会拦截。
+
+### 4. 使用
+1. 左侧填写信令服务器地址与「本机设备 ID」(已自动生成随机 ID)。
+2. 点击「连接信令服务器」,再点击「刷新设备列表」获取在线被控端。
+3. 选择目标设备,点击「发起远程控制」。
+4. 被控端弹出连接请求并「接受」后,右侧出现屏幕画面。
+5. 在画面上 **触摸/滑动** 即可操控;底部按钮对应系统导航键(主页/返回/多任务/菜单/音量/电源)。
+
+## 控制协议要点
+
+- **信令消息**(`SignalMessage`):`type / fromDeviceId / toDeviceId / deviceType / payload(JSON 字符串)`,
+ 类型含 `REGISTER / OFFER / ANSWER / ICE_CANDIDATE / DEVICE_LIST` 等。
+- **DataChannel** 标签固定为 `control_channel`,创建参数 `ordered:false, maxRetransmits:0`(非可靠、无序,最低延迟)。
+- **控制指令**(`ControlMessage`,protobuf 二进制):
+ - `TOUCH=1` 单击(x,y)
+ - `SWIPE=2` 滑动(x1,y1,x2,y2,duration)
+ - `KEY=3` 按键(key_code;被控端自动完成 按下+抬起)
+ - `LONG_PRESS=4` 长按(x,y)
+ - `MOTION_EVENT=5` 原始指针动作(motion_action: 0=DOWN/1=UP/2=MOVE;x,y)
+
+坐标均为相对屏幕百分比 (0.0~1.0),因此不同分辨率设备间可直接换算。
+
+## 与原有项目的对应关系
+
+| 能力 | Android/Flutter | 本项目 (Vue3) |
+| --- | --- | --- |
+| 信令注册 | `WebSocketClient.registerDevice()` | `SignalingClient.register()` |
+| 创建连接 | `WebRtcClient.createOffer()` | `WebRtcController.createOffer()` |
+| 触摸采集 | `RemoteTouchView` | `RemoteScreen.vue`(pointer 事件 + 相对坐标映射) |
+| 指令构造 | `ControlCommands` | `WebRtcController.sendTouch/...` + `controlMessage.js` |
+| 流程编排 | `MainActivity` / `RemoteController` | `controllerStore.js` |
+
+## 备注
+
+- ICE/TURN 配置见 `src/store/controllerStore.js` 的 `DEFAULT_ICE_SERVERS`,请按需替换为你自己的 TURN 凭据。
+- 本网页端作为 **控制端**,被控端仍为 Android(`WebRTCControlled`)。
diff --git a/WebRTCControllerWeb/index.html b/WebRTCControllerWeb/index.html
new file mode 100644
index 0000000..0437b70
--- /dev/null
+++ b/WebRTCControllerWeb/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ WebRTC 网页远程控制
+
+
+
+
+
+
diff --git a/WebRTCControllerWeb/package-lock.json b/WebRTCControllerWeb/package-lock.json
new file mode 100644
index 0000000..04b1e33
--- /dev/null
+++ b/WebRTCControllerWeb/package-lock.json
@@ -0,0 +1,1346 @@
+{
+ "name": "webrtc-controller-web",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "webrtc-controller-web",
+ "version": "1.0.0",
+ "dependencies": {
+ "protobufjs": "^7.3.2",
+ "vue": "^3.4.21"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.2.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
+ "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/shared": "3.5.40",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
+ "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
+ "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/compiler-core": "3.5.40",
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/compiler-ssr": "3.5.40",
+ "@vue/shared": "3.5.40",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.19",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
+ "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.40.tgz",
+ "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
+ "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
+ "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.40",
+ "@vue/runtime-core": "3.5.40",
+ "@vue/shared": "3.5.40",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
+ "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.40",
+ "@vue/runtime-dom": "3.5.40",
+ "@vue/shared": "3.5.40"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.40.tgz",
+ "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/protobufjs": {
+ "version": "7.6.5",
+ "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.6.5.tgz",
+ "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.40",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.40.tgz",
+ "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.40",
+ "@vue/compiler-sfc": "3.5.40",
+ "@vue/runtime-dom": "3.5.40",
+ "@vue/server-renderer": "3.5.40",
+ "@vue/shared": "3.5.40"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/WebRTCControllerWeb/package.json b/WebRTCControllerWeb/package.json
new file mode 100644
index 0000000..ee92625
--- /dev/null
+++ b/WebRTCControllerWeb/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "webrtc-controller-web",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "description": "基于 Vue3 的 WebRTC 网页远程控制端,兼容现有信令服务器与 Android 被控端(对应 WebRTCController / webrtc_controller_flutter)",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "protobufjs": "^7.3.2",
+ "vue": "^3.4.21"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/WebRTCControllerWeb/pnpm-lock.yaml b/WebRTCControllerWeb/pnpm-lock.yaml
new file mode 100644
index 0000000..454e730
--- /dev/null
+++ b/WebRTCControllerWeb/pnpm-lock.yaml
@@ -0,0 +1,844 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ protobufjs:
+ specifier: ^7.3.2
+ version: 7.6.5
+ vue:
+ specifier: ^3.4.21
+ version: 3.5.40
+ devDependencies:
+ '@vitejs/plugin-vue':
+ specifier: ^5.0.4
+ version: 5.2.4(vite@5.4.21(@types/node@26.1.1))(vue@3.5.40)
+ vite:
+ specifier: ^5.2.0
+ version: 5.4.21(@types/node@26.1.1)
+
+packages:
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.5':
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+
+ '@protobufjs/eventemitter@1.1.1':
+ resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
+
+ '@protobufjs/fetch@1.1.1':
+ resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.2':
+ resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
+
+ '@rollup/rollup-android-arm-eabi@4.62.2':
+ resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.62.2':
+ resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.62.2':
+ resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.62.2':
+ resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.62.2':
+ resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.62.2':
+ resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+ resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+ resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.2':
+ resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.62.2':
+ resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.2':
+ resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.62.2':
+ resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+ resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.2':
+ resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+ resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.2':
+ resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.2':
+ resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.62.2':
+ resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.62.2':
+ resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.62.2':
+ resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.62.2':
+ resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.2':
+ resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.2':
+ resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.62.2':
+ resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.62.2':
+ resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@26.1.1':
+ resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
+
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
+ '@vue/compiler-core@3.5.40':
+ resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
+
+ '@vue/compiler-dom@3.5.40':
+ resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==}
+
+ '@vue/compiler-sfc@3.5.40':
+ resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==}
+
+ '@vue/compiler-ssr@3.5.40':
+ resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
+
+ '@vue/reactivity@3.5.40':
+ resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==}
+
+ '@vue/runtime-core@3.5.40':
+ resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==}
+
+ '@vue/runtime-dom@3.5.40':
+ resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==}
+
+ '@vue/server-renderer@3.5.40':
+ resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==}
+
+ '@vue/shared@3.5.40':
+ resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ postcss@8.5.19:
+ resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ protobufjs@7.6.5:
+ resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
+ engines: {node: '>=12.0.0'}
+
+ rollup@4.62.2:
+ resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ undici-types@8.3.0:
+ resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
+ vue@3.5.40:
+ resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+snapshots:
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.5': {}
+
+ '@protobufjs/eventemitter@1.1.1': {}
+
+ '@protobufjs/fetch@1.1.1':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.2': {}
+
+ '@rollup/rollup-android-arm-eabi@4.62.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.62.2':
+ optional: true
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@26.1.1':
+ dependencies:
+ undici-types: 8.3.0
+
+ '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@26.1.1))(vue@3.5.40)':
+ dependencies:
+ vite: 5.4.21(@types/node@26.1.1)
+ vue: 3.5.40
+
+ '@vue/compiler-core@3.5.40':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@vue/shared': 3.5.40
+ entities: 7.0.1
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.40':
+ dependencies:
+ '@vue/compiler-core': 3.5.40
+ '@vue/shared': 3.5.40
+
+ '@vue/compiler-sfc@3.5.40':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@vue/compiler-core': 3.5.40
+ '@vue/compiler-dom': 3.5.40
+ '@vue/compiler-ssr': 3.5.40
+ '@vue/shared': 3.5.40
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.19
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.40':
+ dependencies:
+ '@vue/compiler-dom': 3.5.40
+ '@vue/shared': 3.5.40
+
+ '@vue/reactivity@3.5.40':
+ dependencies:
+ '@vue/shared': 3.5.40
+
+ '@vue/runtime-core@3.5.40':
+ dependencies:
+ '@vue/reactivity': 3.5.40
+ '@vue/shared': 3.5.40
+
+ '@vue/runtime-dom@3.5.40':
+ dependencies:
+ '@vue/reactivity': 3.5.40
+ '@vue/runtime-core': 3.5.40
+ '@vue/shared': 3.5.40
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.40':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.40
+ '@vue/runtime-dom': 3.5.40
+ '@vue/shared': 3.5.40
+
+ '@vue/shared@3.5.40': {}
+
+ csstype@3.2.3: {}
+
+ entities@7.0.1: {}
+
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
+ estree-walker@2.0.2: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ long@5.3.2: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ nanoid@3.3.16: {}
+
+ picocolors@1.1.1: {}
+
+ postcss@8.5.19:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ protobufjs@7.6.5:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.5
+ '@protobufjs/eventemitter': 1.1.1
+ '@protobufjs/fetch': 1.1.1
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.2
+ '@types/node': 26.1.1
+ long: 5.3.2
+
+ rollup@4.62.2:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.62.2
+ '@rollup/rollup-android-arm64': 4.62.2
+ '@rollup/rollup-darwin-arm64': 4.62.2
+ '@rollup/rollup-darwin-x64': 4.62.2
+ '@rollup/rollup-freebsd-arm64': 4.62.2
+ '@rollup/rollup-freebsd-x64': 4.62.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.62.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.62.2
+ '@rollup/rollup-linux-arm64-gnu': 4.62.2
+ '@rollup/rollup-linux-arm64-musl': 4.62.2
+ '@rollup/rollup-linux-loong64-gnu': 4.62.2
+ '@rollup/rollup-linux-loong64-musl': 4.62.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.62.2
+ '@rollup/rollup-linux-ppc64-musl': 4.62.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.62.2
+ '@rollup/rollup-linux-riscv64-musl': 4.62.2
+ '@rollup/rollup-linux-s390x-gnu': 4.62.2
+ '@rollup/rollup-linux-x64-gnu': 4.62.2
+ '@rollup/rollup-linux-x64-musl': 4.62.2
+ '@rollup/rollup-openbsd-x64': 4.62.2
+ '@rollup/rollup-openharmony-arm64': 4.62.2
+ '@rollup/rollup-win32-arm64-msvc': 4.62.2
+ '@rollup/rollup-win32-ia32-msvc': 4.62.2
+ '@rollup/rollup-win32-x64-gnu': 4.62.2
+ '@rollup/rollup-win32-x64-msvc': 4.62.2
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ undici-types@8.3.0: {}
+
+ vite@5.4.21(@types/node@26.1.1):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.19
+ rollup: 4.62.2
+ optionalDependencies:
+ '@types/node': 26.1.1
+ fsevents: 2.3.3
+
+ vue@3.5.40:
+ dependencies:
+ '@vue/compiler-dom': 3.5.40
+ '@vue/compiler-sfc': 3.5.40
+ '@vue/runtime-dom': 3.5.40
+ '@vue/server-renderer': 3.5.40
+ '@vue/shared': 3.5.40
diff --git a/WebRTCControllerWeb/public/control_message.proto b/WebRTCControllerWeb/public/control_message.proto
new file mode 100644
index 0000000..e72212d
--- /dev/null
+++ b/WebRTCControllerWeb/public/control_message.proto
@@ -0,0 +1,41 @@
+syntax = "proto3";
+
+package com.ttstd.control;
+
+option java_package = "com.ttstd.control";
+option java_multiple_files = true;
+
+// 控制指令类型,对应原 JSON 字段 action。
+enum Action {
+ ACTION_UNKNOWN = 0;
+ TOUCH = 1;
+ SWIPE = 2;
+ KEY = 3;
+ LONG_PRESS = 4;
+ MOTION_EVENT = 5;
+}
+
+// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。
+// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。
+message ControlMessage {
+ // 指令类型
+ Action action = 1;
+
+ // 单点坐标(TOUCH / LONG_PRESS / MOTION_EVENT)
+ double x = 2;
+ double y = 3;
+
+ // 滑动起止坐标(SWIPE)
+ double x1 = 4;
+ double y1 = 5;
+ double x2 = 6;
+ double y2 = 7;
+ int64 duration = 8;
+
+ // 按键(KEY):key_action 0=按下 1=抬起(对应 Android KeyEvent ACTION_DOWN/UP)
+ int32 key_code = 9;
+ int32 key_action = 10;
+
+ // 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE)
+ int32 motion_action = 11;
+}
diff --git a/WebRTCControllerWeb/server/index.js b/WebRTCControllerWeb/server/index.js
new file mode 100644
index 0000000..cc3eb96
--- /dev/null
+++ b/WebRTCControllerWeb/server/index.js
@@ -0,0 +1,109 @@
+// 轻量信令服务器,协议与 WebRTCSignalServer (Spring) 完全兼容。
+// 端点: ws://host:PORT/ws/signal
+// 支持消息: REGISTER / DEVICE_LIST / OFFER / ANSWER / ICE_CANDIDATE / CONTROL_COMMAND / CONNECTION_REJECTED
+// 支持通知: REGISTER_SUCCESS / DEVICE_LIST / TARGET_OFFLINE / REQUEST_ERROR / REQUEST_TIMEOUT
+import { WebSocketServer } from 'ws';
+
+const PORT = Number(process.env.PORT || 8088);
+const PATH = process.env.WS_PATH || '/ws/signal';
+
+const wss = new WebSocketServer({ port: PORT, path: PATH });
+
+// deviceId -> { ws, type }
+const sessions = new Map();
+// "from->to" -> true(等待被控端确认)
+const pending = new Map();
+
+const keyOf = (a, b) => `${a}->${b}`;
+
+function send(ws, obj) {
+ if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
+}
+
+wss.on('connection', (ws) => {
+ ws.on('message', (data) => {
+ let msg;
+ try {
+ msg = JSON.parse(data.toString());
+ } catch {
+ return;
+ }
+ const type = (msg.type || '').toUpperCase();
+ switch (type) {
+ case 'REGISTER': handleRegister(ws, msg); break;
+ case 'DEVICE_LIST': handleDeviceList(ws); break;
+ case 'OFFER': handleOffer(msg); break;
+ case 'ANSWER':
+ case 'CONNECTION_REJECTED':
+ pending.delete(keyOf(msg.toDeviceId, msg.fromDeviceId));
+ forward(msg);
+ break;
+ case 'ICE_CANDIDATE':
+ case 'CONTROL_COMMAND':
+ forward(msg);
+ break;
+ default:
+ forward(msg);
+ }
+ });
+
+ ws.on('close', () => {
+ for (const [id, s] of sessions) {
+ if (s.ws === ws) { sessions.delete(id); break; }
+ }
+ });
+});
+
+function handleRegister(ws, msg) {
+ const id = msg.fromDeviceId;
+ const type = (msg.deviceType || '').toUpperCase();
+ if (!id || !type) return;
+ sessions.set(id, { ws, type });
+ send(ws, { type: 'REGISTER_SUCCESS', deviceId: id });
+ console.log(`设备注册: ${id} (${type})`);
+}
+
+function handleDeviceList(ws) {
+ const controllers = [];
+ const controlled = [];
+ for (const [id, s] of sessions) {
+ if (s.type === 'CONTROLLER') controllers.push(id);
+ else if (s.type === 'CONTROLLED') controlled.push(id);
+ }
+ send(ws, { type: 'DEVICE_LIST', controllers, controlled });
+}
+
+function handleOffer(msg) {
+ const from = msg.fromDeviceId;
+ const to = msg.toDeviceId;
+ const fromType = sessions.get(from)?.type;
+ const toType = sessions.get(to)?.type;
+
+ if (fromType !== 'CONTROLLER' || toType !== 'CONTROLLED') {
+ send(sessions.get(from)?.ws, {
+ type: 'REQUEST_ERROR',
+ toDeviceId: to,
+ payload: '连接请求只能由 CONTROLLER 发往 CONTROLLED',
+ });
+ return;
+ }
+ const target = sessions.get(to)?.ws;
+ if (!target) {
+ send(sessions.get(from)?.ws, {
+ type: 'TARGET_OFFLINE',
+ toDeviceId: to,
+ payload: '目标被控端不在线,请确认设备已开启并连接服务器',
+ });
+ return;
+ }
+ pending.set(keyOf(from, to), true);
+ forward(msg);
+}
+
+function forward(msg) {
+ const target = sessions.get(msg.toDeviceId)?.ws;
+ if (!target) return;
+ send(target, msg);
+}
+
+console.log(`信令服务器已启动: ws://localhost:${PORT}${PATH}`);
diff --git a/WebRTCControllerWeb/server/package.json b/WebRTCControllerWeb/server/package.json
new file mode 100644
index 0000000..126c886
--- /dev/null
+++ b/WebRTCControllerWeb/server/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "webrtc-controller-web-signaling",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "description": "与现有 Spring 信令服务器协议兼容的轻量 Node 信令服务(可选,便于本地独立运行)",
+ "scripts": {
+ "start": "node index.js"
+ },
+ "dependencies": {
+ "ws": "^8.17.0"
+ }
+}
diff --git a/WebRTCControllerWeb/src/App.vue b/WebRTCControllerWeb/src/App.vue
new file mode 100644
index 0000000..82f9070
--- /dev/null
+++ b/WebRTCControllerWeb/src/App.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+ WebRTC 网页远程控制
+ {{ store.statusText }}
+
+ 信令
+ 注册
+ P2P
+ 通道
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WebRTCControllerWeb/src/components/ConnectionPanel.vue b/WebRTCControllerWeb/src/components/ConnectionPanel.vue
new file mode 100644
index 0000000..4467c44
--- /dev/null
+++ b/WebRTCControllerWeb/src/components/ConnectionPanel.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
被控端设备 (CONTROLLED)
+
+
+
+ 暂无在线被控端。请确认 Android 被控端 (WebRTCControlled) 已启动并连接到同一信令服务器。
+
+
+
+ {{ id }}
+ 在线
+
+
+
+
+
+
+
+
+
+
{{ store.error }}
+
+
说明
+
+ • 本页面作为 控制端 (CONTROLLER),对应原 Android / Flutter 控制端。
+ • 连接后可在右侧屏幕区域触摸/滑动来操控被控设备。
+ • 控制指令以 protobuf 经 DataChannel 发送,与原 Android 被控端完全兼容。
+
+
+
diff --git a/WebRTCControllerWeb/src/components/ControlBar.vue b/WebRTCControllerWeb/src/components/ControlBar.vue
new file mode 100644
index 0000000..2e4e0d4
--- /dev/null
+++ b/WebRTCControllerWeb/src/components/ControlBar.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/WebRTCControllerWeb/src/components/RemoteScreen.vue b/WebRTCControllerWeb/src/components/RemoteScreen.vue
new file mode 100644
index 0000000..5a3fea9
--- /dev/null
+++ b/WebRTCControllerWeb/src/components/RemoteScreen.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
📱
+
请先在左侧连接信令服务器
+
请选择并连接一个被控端设备
+
正在等待被控端画面…
+
+
+
diff --git a/WebRTCControllerWeb/src/components/StatsBar.vue b/WebRTCControllerWeb/src/components/StatsBar.vue
new file mode 100644
index 0000000..d996132
--- /dev/null
+++ b/WebRTCControllerWeb/src/components/StatsBar.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ 分辨率: {{ store.stats ? store.stats.width + '×' + store.stats.height : '-' }}
+ 帧率: {{ store.stats ? store.stats.fps : '-' }}
+ 解码: {{ store.stats ? store.stats.codec : '-' }}
+ ↓下载: {{ store.stats ? store.stats.downSpeed : '-' }}
+ 时长: {{ store.stats ? store.stats.duration : '-' }}
+ 控制通道: {{ store.dataChannelOpen ? '已连接' : '未连接' }}
+ 目标: {{ store.targetDeviceId || '-' }}
+
+
diff --git a/WebRTCControllerWeb/src/main.js b/WebRTCControllerWeb/src/main.js
new file mode 100644
index 0000000..8dd6bc1
--- /dev/null
+++ b/WebRTCControllerWeb/src/main.js
@@ -0,0 +1,5 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+import './style.css';
+
+createApp(App).mount('#app');
diff --git a/WebRTCControllerWeb/src/proto/controlMessage.js b/WebRTCControllerWeb/src/proto/controlMessage.js
new file mode 100644
index 0000000..916029f
--- /dev/null
+++ b/WebRTCControllerWeb/src/proto/controlMessage.js
@@ -0,0 +1,31 @@
+import protobuf from 'protobufjs';
+
+// 与 webrtc_controller_flutter/proto/control_message.proto 完全一致的运行时编码。
+// 浏览器端通过 protobufjs 动态加载 .proto,构造 ControlMessage 并序列化为二进制,
+// 经 DataChannel 发送,由 Android 被控端 InputCommandHandler 解析执行。
+
+let ControlMessage = null;
+
+export const Action = {
+ ACTION_UNKNOWN: 0,
+ TOUCH: 1,
+ SWIPE: 2,
+ KEY: 3,
+ LONG_PRESS: 4,
+ MOTION_EVENT: 5,
+};
+
+export async function loadProto() {
+ if (ControlMessage) return;
+ const root = await protobuf.load(`${import.meta.env.BASE_URL}control_message.proto`);
+ ControlMessage = root.lookupType('com.ttstd.control.ControlMessage');
+}
+
+export function encodeControlMessage(fields) {
+ if (!ControlMessage) throw new Error('protobuf 尚未加载,请先调用 loadProto()');
+ const err = ControlMessage.verify(fields);
+ if (err) throw new Error(err);
+ const message = ControlMessage.create(fields);
+ // 返回 Uint8Array,可直接通过 RTCDataChannel.send 发送(二进制)。
+ return ControlMessage.encode(message).finish();
+}
diff --git a/WebRTCControllerWeb/src/services/SignalingClient.js b/WebRTCControllerWeb/src/services/SignalingClient.js
new file mode 100644
index 0000000..082e4c2
--- /dev/null
+++ b/WebRTCControllerWeb/src/services/SignalingClient.js
@@ -0,0 +1,91 @@
+// 信令客户端:对应 Android 端 WebSocketClient / Flutter signaling_client.dart。
+// 连接成功后自动发送 REGISTER(deviceType=CONTROLLER),
+// 负责 OFFER / ICE_CANDIDATE 的发送,以及 ANSWER / ICE_CANDIDATE / 通知类的接收与转发。
+export class SignalingClient {
+ constructor({ serverUrl, deviceId, onConnected, onDisconnected, onError, onMessage }) {
+ this.serverUrl = serverUrl;
+ this.deviceId = deviceId;
+ this.onConnected = onConnected;
+ this.onDisconnected = onDisconnected;
+ this.onError = onError;
+ this.onMessage = onMessage;
+ this.ws = null;
+ }
+
+ connect() {
+ try {
+ this.ws = new WebSocket(this.serverUrl);
+ this.ws.onopen = () => {
+ this.register();
+ this.onConnected && this.onConnected();
+ };
+ this.ws.onmessage = (ev) => {
+ let msg;
+ try {
+ msg = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+ this.onMessage && this.onMessage(msg);
+ };
+ this.ws.onclose = () => this.onDisconnected && this.onDisconnected();
+ this.ws.onerror = (e) => this.onError && this.onError(e?.message || 'WebSocket 错误');
+ } catch (e) {
+ this.onError && this.onError(e.message);
+ }
+ }
+
+ register() {
+ this.send({
+ type: 'REGISTER',
+ fromDeviceId: this.deviceId,
+ deviceType: 'CONTROLLER',
+ });
+ }
+
+ requestDeviceList() {
+ this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId });
+ }
+
+ sendOffer(sdp, toDeviceId) {
+ this.send({
+ type: 'OFFER',
+ fromDeviceId: this.deviceId,
+ toDeviceId,
+ deviceType: 'CONTROLLER',
+ payload: JSON.stringify({ sdp }),
+ });
+ }
+
+ sendIceCandidate(candidate, toDeviceId) {
+ const payload = {
+ sdpMid: candidate.sdpMid,
+ sdpMLineIndex: candidate.sdpMLineIndex,
+ candidate: candidate.candidate,
+ };
+ this.send({
+ type: 'ICE_CANDIDATE',
+ fromDeviceId: this.deviceId,
+ toDeviceId,
+ deviceType: 'CONTROLLER',
+ payload: JSON.stringify(payload),
+ });
+ }
+
+ send(msg) {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(msg));
+ }
+ }
+
+ disconnect() {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ get isConnected() {
+ return this.ws && this.ws.readyState === WebSocket.OPEN;
+ }
+}
diff --git a/WebRTCControllerWeb/src/services/WebRtcController.js b/WebRTCControllerWeb/src/services/WebRtcController.js
new file mode 100644
index 0000000..6ebf9f1
--- /dev/null
+++ b/WebRTCControllerWeb/src/services/WebRtcController.js
@@ -0,0 +1,205 @@
+import { encodeControlMessage } from '../proto/controlMessage';
+
+// 对应 Android 端 WebRtcClient / Flutter webrtc_controller.dart。
+// 作为 OFFER 方:仅接收远端视频(recvonly)+ 创建控制用 DataChannel,
+// 并将屏幕触摸/按键转换为 protobuf 控制指令经 DataChannel 发送给被控端。
+const DATA_CHANNEL_LABEL = 'control_channel';
+
+export class WebRtcController {
+ constructor({ iceServers, deviceId, targetDeviceId, signaling, onIceState, onDataChannelState, onStream, onStats, onError }) {
+ this.iceServers = iceServers;
+ this.deviceId = deviceId;
+ this.targetDeviceId = targetDeviceId;
+ this.signaling = signaling;
+ this.onIceState = onIceState;
+ this.onDataChannelState = onDataChannelState;
+ this.onStream = onStream;
+ this.onStats = onStats;
+ this.onError = onError;
+
+ this.pc = null;
+ this.dataChannel = null;
+ this._statsTimer = null;
+ this._prevBytes = 0;
+ this._prevTs = 0;
+ this._connectedAt = 0;
+ this._gatheredRelay = false;
+ this._gatheredSrflx = false;
+ this._gatheredHost = false;
+
+ // 关键修复:浏览器要求 addIceCandidate 必须在 setRemoteDescription(answer) 之后调用。
+ // 被控端会在一瞬间突发大量候选,若此时远端描述尚未设置则会整批失败被丢弃,导致 ICE 无法配对。
+ // 因此先把远端候选缓存起来,等 answer 设置完成后再统一 flush。
+ this._remoteDescSet = false;
+ this._pendingCandidates = [];
+ }
+
+ async createOffer() {
+ this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
+
+ this.pc.onicecandidate = (e) => {
+ if (e.candidate) {
+ if (e.candidate.candidate.includes('typ relay')) this._gatheredRelay = true;
+ else if (e.candidate.candidate.includes('typ srflx')) this._gatheredSrflx = true;
+ else if (e.candidate.candidate.includes('typ host')) this._gatheredHost = true;
+ this.signaling.sendIceCandidate(e.candidate, this.targetDeviceId);
+ } else {
+ console.info('[ICE] 本端候选收集完成 relay=%s srflx=%s host=%s',
+ this._gatheredRelay, this._gatheredSrflx, this._gatheredHost);
+ }
+ };
+ // 关键诊断:TURN/STUN 分配失败时浏览器会触发该事件,原代码未捕获导致原因被吞掉。
+ this.pc.onicecandidateerror = (e) => {
+ const url = e.url || '';
+ console.warn('[ICE 候选错误] url=%s code=%s text=%s', url, e.errorCode, e.errorText);
+ };
+ this.pc.oniceconnectionstatechange = () => {
+ const s = this.pc.iceConnectionState;
+ if (s === 'connected') this._connectedAt = Date.now();
+ this.onIceState && this.onIceState(s);
+ };
+ this.pc.onconnectionstatechange = () => {
+ const s = this.pc.connectionState;
+ if (s === 'failed') {
+ // ICE 彻底失败:通常是双方没有可达的候选路径(需 TURN 中继或同一网络)。
+ const relayInfo = this._gatheredRelay
+ ? '本端已拿到 TURN 中继候选'
+ : '本端未拿到任何 TURN 中继候选(中继分配很可能失败,见上方 TURN 错误)';
+ this.onError && this.onError('WebRTC 连接失败(connectionState=failed)。iceConnectionState='
+ + this.pc.iceConnectionState + ';' + relayInfo
+ + '。请确认浏览器能访问 TURN 服务器 175.178.213.60:3478(UDP/TCP 至少一种可达),且被控端与本机网络可互通。');
+ } else if (s === 'connecting') {
+ this.onError && this.onError('');
+ }
+ this.onIceState && this.onIceState(s);
+ };
+ this.pc.ontrack = (e) => {
+ if (e.streams && e.streams[0]) this.onStream && this.onStream(e.streams[0]);
+ };
+ this.pc.ondatachannel = (e) => this.setupDataChannel(e.channel);
+
+ // 仅接收被控端屏幕视频
+ this.pc.addTransceiver('video', { direction: 'recvonly' });
+
+ // 控制用 DataChannel:非可靠、无序,降低延迟(与被控端协商一致)
+ const dc = this.pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: false, maxRetransmits: 0 });
+ this.setupDataChannel(dc);
+
+ const offer = await this.pc.createOffer();
+ await this.pc.setLocalDescription(offer);
+ this.signaling.sendOffer(this.pc.localDescription.sdp, this.targetDeviceId);
+
+ this._startStats();
+ }
+
+ setupDataChannel(dc) {
+ this.dataChannel = dc;
+ dc.onopen = () => this.onDataChannelState && this.onDataChannelState(true);
+ dc.onclose = () => this.onDataChannelState && this.onDataChannelState(false);
+ dc.onmessage = (e) => console.debug('[DataChannel] 收到消息', e.data);
+ }
+
+ async handleAnswer(sdp) {
+ if (!this.pc) return;
+ try {
+ await this.pc.setRemoteDescription({ type: 'answer', sdp });
+ } catch (e) {
+ this.onError && this.onError('设置远端描述(ANSWER)失败: ' + (e?.message || e));
+ throw e;
+ }
+ // 远端描述已就绪,flush 之前缓存的候选。
+ this._remoteDescSet = true;
+ const pending = this._pendingCandidates;
+ this._pendingCandidates = [];
+ for (const c of pending) {
+ try {
+ await this.pc.addIceCandidate(c);
+ } catch (e) {
+ console.warn('flush addIceCandidate 失败', e);
+ }
+ }
+ }
+
+ async handleIceCandidate(payload) {
+ if (!this.pc) return;
+ const cand = { candidate: payload.candidate, sdpMid: payload.sdpMid, sdpMLineIndex: payload.sdpMLineIndex };
+ if (!this._remoteDescSet) {
+ // 远端描述尚未设置,先缓存,避免整批候选被浏览器丢弃。
+ this._pendingCandidates.push(cand);
+ return;
+ }
+ try {
+ await this.pc.addIceCandidate(cand);
+ } catch (e) {
+ console.warn('addIceCandidate 失败', e);
+ }
+ }
+
+ sendControlMessage(fields) {
+ if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false;
+ const bytes = encodeControlMessage(fields);
+ this.dataChannel.send(bytes);
+ return true;
+ }
+
+ sendTouch(x, y) { return this.sendControlMessage({ action: 1, x, y }); } // TOUCH
+ sendSwipe(x1, y1, x2, y2, duration) { return this.sendControlMessage({ action: 2, x1, y1, x2, y2, duration }); } // SWIPE
+ sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY
+ sendLongPress(x, y) { return this.sendControlMessage({ action: 4, x, y }); } // LONG_PRESS
+ sendMotionEvent(action, x, y) { return this.sendControlMessage({ action: 5, motionAction: action, x, y }); } // MOTION_EVENT
+
+ _startStats() {
+ this._statsTimer = setInterval(async () => {
+ if (!this.pc) return;
+ try {
+ this.onStats && this.onStats(await this.collectStats());
+ } catch { /* ignore */ }
+ }, 1000);
+ }
+
+ async collectStats() {
+ const stats = await this.pc.getStats();
+ let width = '-', height = '-', fps = '-', codec = '-', bytesReceived = 0;
+ const codecs = {};
+ stats.forEach((r) => {
+ if (r.type === 'inbound-rtp' && r.kind === 'video') {
+ width = r.frameWidth ?? '-';
+ height = r.frameHeight ?? '-';
+ fps = r.framesPerSecond ?? '-';
+ if (r.codecId && codecs[r.codecId]) codec = codecs[r.codecId];
+ } else if (r.type === 'codec' && r.mimeType && r.mimeType.startsWith('video/')) {
+ codecs[r.id] = r.mimeType.substring(6);
+ } else if (r.type === 'candidate-pair' && r.nominated) {
+ bytesReceived = r.bytesReceived ?? 0;
+ }
+ });
+ const now = Date.now();
+ let downSpeed = '-';
+ if (this._prevTs) {
+ const dt = (now - this._prevTs) / 1000;
+ if (dt > 0) {
+ const bps = (bytesReceived - this._prevBytes) / dt;
+ downSpeed = bps >= 1048576 ? (bps / 1048576).toFixed(1) + ' MB/s'
+ : bps >= 1024 ? (bps / 1024).toFixed(1) + ' KB/s' : bps.toFixed(0) + ' B/s';
+ }
+ }
+ this._prevBytes = bytesReceived;
+ this._prevTs = now;
+ let duration = '-';
+ if (this._connectedAt) {
+ const secs = Math.floor((now - this._connectedAt) / 1000);
+ duration = String(Math.floor(secs / 60)).padStart(2, '0') + ':' + String(secs % 60).padStart(2, '0');
+ }
+ return { width, height, fps, codec, downSpeed, duration, dcState: this.dataChannel ? this.dataChannel.readyState : 'none' };
+ }
+
+ async close() {
+ if (this._statsTimer) clearInterval(this._statsTimer);
+ if (this.dataChannel) { try { this.dataChannel.close(); } catch { /* ignore */ } }
+ if (this.pc) { try { await this.pc.close(); } catch { /* ignore */ } }
+ this.dataChannel = null;
+ this.pc = null;
+ this._remoteDescSet = false;
+ this._pendingCandidates = [];
+ }
+}
diff --git a/WebRTCControllerWeb/src/store/controllerStore.js b/WebRTCControllerWeb/src/store/controllerStore.js
new file mode 100644
index 0000000..c444558
--- /dev/null
+++ b/WebRTCControllerWeb/src/store/controllerStore.js
@@ -0,0 +1,170 @@
+import { reactive, markRaw } from 'vue';
+import { SignalingClient } from '../services/SignalingClient';
+import { WebRtcController } from '../services/WebRtcController';
+import { loadProto } from '../proto/controlMessage';
+
+// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。
+export const DEFAULT_ICE_SERVERS = [
+ // 公共 TURN(relay 兜底):UDP + TCP 两种传输,TCP 用于 UDP 被防火墙拦截的网络。
+ { urls: 'turn:175.178.213.60:3478', username: 'fanhuitong', credential: 'Fan19961207..' },
+ { urls: 'turn:175.178.213.60:3478?transport=tcp', username: 'fanhuitong', credential: 'Fan19961207..' },
+ // 内网 TURN(与被控端同局域网时可用)。
+ // { urls: 'turn:192.168.100.224:3478', username: 'tt', credential: 'fht' },
+ // { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' },
+ { urls: 'stun:175.178.213.60:3478' },
+ // { urls: 'stun:192.168.5.224:3478' },
+// { urls: 'stun:stun.l.google.com:19302' },
+// { urls: 'stun:stun1.l.google.com:19302' },
+// { urls: 'stun:stun2.l.google.com:19302' },
+];
+
+export const store = reactive({
+ serverUrl: 'ws://175.178.213.60:8088/ws/signal',
+ deviceId: '',
+ targetDeviceId: '',
+ iceServers: DEFAULT_ICE_SERVERS,
+
+ protoReady: false,
+ signalingConnected: false,
+ registered: false,
+ rtcConnected: false,
+ dataChannelOpen: false,
+ statusText: '未连接',
+ error: '',
+
+ controlledDevices: [],
+ stats: null,
+ remoteStream: null,
+});
+
+let signaling = null;
+let webrtc = null;
+
+export async function initProto() {
+ await loadProto();
+ store.protoReady = true;
+}
+
+function parsePayload(payload) {
+ if (!payload) return {};
+ if (typeof payload === 'string') {
+ try { return JSON.parse(payload); } catch { return {}; }
+ }
+ return payload;
+}
+
+export function connectSignaling() {
+ store.error = '';
+ const deviceId = store.deviceId.trim();
+ if (!deviceId) { store.error = '请填写本机设备 ID'; return; }
+
+ signaling = new SignalingClient({
+ serverUrl: store.serverUrl.trim(),
+ deviceId,
+ onConnected: () => {
+ store.signalingConnected = true;
+ store.statusText = '已连接信令服务器';
+ refreshDevices();
+ },
+ onDisconnected: () => {
+ store.signalingConnected = false;
+ store.registered = false;
+ store.statusText = '已断开信令连接';
+ },
+ onError: (e) => { store.error = '信令错误: ' + e; },
+ onMessage: handleSignalMessage,
+ });
+ signaling.connect();
+}
+
+function handleSignalMessage(msg) {
+ switch ((msg.type || '').toUpperCase()) {
+ case 'REGISTER_SUCCESS':
+ store.registered = true;
+ store.statusText = '注册成功 (CONTROLLER)';
+ break;
+ case 'DEVICE_LIST': {
+ const list = msg.controlled || [];
+ store.controlledDevices = Array.isArray(list) ? list : [];
+ if (!store.controlledDevices.includes(store.targetDeviceId)) store.targetDeviceId = '';
+ break;
+ }
+ case 'ANSWER': {
+ store.statusText = '被控端已接受,正在建立连接...';
+ if (webrtc) {
+ webrtc.handleAnswer(parsePayload(msg.payload).sdp).catch((e) => {
+ store.error = '设置远端描述失败: ' + (e?.message || e);
+ });
+ }
+ break;
+ }
+ case 'ICE_CANDIDATE':
+ webrtc && webrtc.handleIceCandidate(parsePayload(msg.payload));
+ break;
+ case 'TARGET_OFFLINE':
+ store.error = msg.payload || '目标被控端不在线,请确认设备已开启并连接服务器';
+ store.statusText = '连接失败';
+ break;
+ case 'CONNECTION_REJECTED':
+ case 'REQUEST_ERROR':
+ case 'REQUEST_TIMEOUT':
+ store.error = msg.payload || '连接请求失败';
+ store.statusText = '连接被拒绝';
+ break;
+ }
+}
+
+export function refreshDevices() {
+ signaling && signaling.requestDeviceList();
+}
+
+export async function connectToDevice(targetId) {
+ if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; }
+ if (!targetId) { store.error = '请选择要控制的被控端设备'; return; }
+
+ store.targetDeviceId = targetId;
+ store.error = '';
+ store.statusText = '正在发起连接...';
+
+ if (webrtc) { await webrtc.close(); webrtc = null; }
+
+ webrtc = new WebRtcController({
+ iceServers: store.iceServers,
+ deviceId: store.deviceId,
+ targetDeviceId: targetId,
+ signaling,
+ onIceState: (s) => {
+ if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; }
+ else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; }
+ },
+ onDataChannelState: (open) => { store.dataChannelOpen = open; },
+ onStream: (stream) => { store.remoteStream = markRaw(stream); },
+ onStats: (stats) => { store.stats = stats; },
+ onError: (msg) => { if (msg) store.error = msg; },
+ });
+ await webrtc.createOffer();
+}
+
+export async function disconnectDevice() {
+ if (webrtc) { await webrtc.close(); webrtc = null; }
+ store.rtcConnected = false;
+ store.dataChannelOpen = false;
+ store.remoteStream = null;
+ store.stats = null;
+ store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
+}
+
+export function disconnectSignaling() {
+ disconnectDevice();
+ if (signaling) { signaling.disconnect(); signaling = null; }
+ store.signalingConnected = false;
+ store.registered = false;
+ store.controlledDevices = [];
+}
+
+// 控制指令转发(供 UI 组件调用)
+export function sendTouch(x, y) { return webrtc && webrtc.sendTouch(x, y); }
+export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.sendSwipe(x1, y1, x2, y2, duration); }
+export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
+export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
+export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }
diff --git a/WebRTCControllerWeb/src/style.css b/WebRTCControllerWeb/src/style.css
new file mode 100644
index 0000000..f5678cf
--- /dev/null
+++ b/WebRTCControllerWeb/src/style.css
@@ -0,0 +1,173 @@
+:root {
+ --bg: #0f1419;
+ --panel: #1a212b;
+ --panel-2: #222c38;
+ --border: #2c3744;
+ --text: #e6edf3;
+ --muted: #8b98a5;
+ --accent: #3b82f6;
+ --accent-2: #22c55e;
+ --danger: #ef4444;
+ --warn: #f59e0b;
+}
+
+* { box-sizing: border-box; }
+
+html, body, #app {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 18px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+}
+.brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; }
+.status { color: var(--muted); font-size: 13px; }
+.status.ok { color: var(--accent-2); }
+.badges { margin-left: auto; display: flex; gap: 8px; }
+.badge {
+ font-size: 12px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--panel-2);
+ color: var(--muted);
+ border: 1px solid var(--border);
+}
+.badge.on { background: rgba(34,197,94,.15); color: var(--accent-2); border-color: rgba(34,197,94,.4); }
+
+.layout { display: flex; flex: 1; min-height: 0; }
+.sidebar {
+ width: 320px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+ background: var(--panel);
+ overflow-y: auto;
+ padding: 16px;
+}
+.stage { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+
+.field { margin-bottom: 14px; }
+.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
+.input, .select {
+ width: 100%;
+ padding: 9px 11px;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 13px;
+ outline: none;
+}
+.input:focus, .select:focus { border-color: var(--accent); }
+
+.btn {
+ width: 100%;
+ padding: 10px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ color: #fff;
+ background: var(--accent);
+}
+.btn:hover { filter: brightness(1.08); }
+.btn:disabled { opacity: .5; cursor: not-allowed; }
+.btn.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
+.btn.danger { background: var(--danger); }
+
+.section-title { font-size: 13px; font-weight: 600; margin: 18px 0 10px; color: var(--text); }
+.hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
+.error { color: var(--danger); font-size: 12px; margin-top: 8px; }
+
+.device-item {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 9px 11px; margin-bottom: 8px;
+ background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
+ font-size: 13px;
+}
+.device-item.active { border-color: var(--accent); }
+
+.screen-wrap {
+ flex: 1;
+ position: relative;
+ background: #000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+}
+.remote-video {
+ max-width: 100%;
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #000;
+}
+.touch-overlay {
+ position: absolute;
+ inset: 0;
+ touch-action: none;
+}
+.touch-overlay.active { cursor: crosshair; }
+.placeholder {
+ position: absolute;
+ text-align: center;
+ color: var(--muted);
+ font-size: 14px;
+ padding: 24px;
+}
+.placeholder .big { font-size: 40px; margin-bottom: 10px; }
+
+.control-bar {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ background: var(--panel);
+ border-top: 1px solid var(--border);
+ flex-wrap: wrap;
+ justify-content: center;
+}
+.key-btn {
+ min-width: 84px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ color: var(--text);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+.key-btn:hover { border-color: var(--accent); }
+.key-btn:active { background: var(--accent); }
+
+.footer { background: var(--panel); border-top: 1px solid var(--border); }
+.stats-bar {
+ display: flex;
+ gap: 18px;
+ padding: 10px 18px;
+ font-size: 12px;
+ color: var(--muted);
+ flex-wrap: wrap;
+}
+.stats-bar b { color: var(--text); font-weight: 600; }
diff --git a/WebRTCControllerWeb/vite.config.js b/WebRTCControllerWeb/vite.config.js
new file mode 100644
index 0000000..b5c1d4e
--- /dev/null
+++ b/WebRTCControllerWeb/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ host: true,
+ port: 5173,
+ },
+});