chore: remote forwarding integration

This commit is contained in:
2026-07-17 18:16:11 +08:00
parent d12c74649a
commit ec9f854136
14 changed files with 1660 additions and 77 deletions
+658 -2
View File
@@ -10,12 +10,20 @@
"hasInstallScript": true,
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@lydell/node-pty": "^1.2.0-beta.12",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"better-sqlite3": "^11.5.0",
"electron-store": "^8.2.0",
"element-plus": "^2.8.4",
"highlight.js": "^11.10.0",
"markdown-it": "^14.3.0",
"node-hid": "^3.3.0",
"pinia": "^2.2.4",
"serialport": "^13.0.0",
"sudo-prompt": "^9.2.1",
"usb": "^3.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -1174,6 +1182,98 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lydell/node-pty": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty/-/node-pty-1.2.0-beta.12.tgz",
"integrity": "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==",
"license": "MIT",
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
}
},
"node_modules/@lydell/node-pty-darwin-arm64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.12.tgz",
"integrity": "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-darwin-x64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.12.tgz",
"integrity": "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-linux-arm64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.12.tgz",
"integrity": "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-linux-x64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.12.tgz",
"integrity": "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-win32-arm64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.12.tgz",
"integrity": "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@lydell/node-pty-win32-x64": {
"version": "1.2.0-beta.12",
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.12.tgz",
"integrity": "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@malept/cross-spawn-promise": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
@@ -1252,6 +1352,166 @@
"node": ">= 10.0.0"
}
},
"node_modules/@node-usb/usb-darwin-arm64": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-darwin-arm64/-/usb-darwin-arm64-3.0.1.tgz",
"integrity": "sha512-GB+MGKaXvEtaDOWng7nCPxWvnXWhZtbFL0FW9ncDmGYtFl3OW+xfLZGhd5Y9VKG6GXXIYS3x1FsBI+zkcxVMhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-darwin-x64": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-darwin-x64/-/usb-darwin-x64-3.0.1.tgz",
"integrity": "sha512-oTY8vLoigjYFwNft1nZNNje0zAXjSQy9u3swe+pcCfZp6qQbEoSLegLEBr94arEgQloQabaq4VA2JZVA8cUQqQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-linux-arm-gnueabihf": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm-gnueabihf/-/usb-linux-arm-gnueabihf-3.0.1.tgz",
"integrity": "sha512-GN5dTxc/FhjmC9NP7Xx1esGAhpIhnNvAf02kk/0i7Cz0w1VaJ/I0XbIVAYIJ5zhDg/oSYkhdILHbOS9+mlWCRQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-linux-arm64-gnu": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm64-gnu/-/usb-linux-arm64-gnu-3.0.1.tgz",
"integrity": "sha512-7cL85qb/yrBXvssZwL3k62duBxT/LaCY/Vcfv/Du9MLFdHhqU0zdYX5fLch/zaW5euYXQE0gmCoTfimfAjlxGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-linux-arm64-musl": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm64-musl/-/usb-linux-arm64-musl-3.0.1.tgz",
"integrity": "sha512-EweA4HeTwzLiRHsU2DRFflJ3vx0shB5Z9Lt5/P/bkoUtSNt8uGjFCmsCjqoO068ul9oejsBMJg22rNLD3+iFqQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-linux-x64-gnu": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-x64-gnu/-/usb-linux-x64-gnu-3.0.1.tgz",
"integrity": "sha512-cEE7GyMhtfq5OM/MXZn3RmYBqPvId0CMuoBx7biazPvlnf0iEWYjpma0WvVMWad/f1QIcC45WgrazHaVv6gAPw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-linux-x64-musl": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-x64-musl/-/usb-linux-x64-musl-3.0.1.tgz",
"integrity": "sha512-zGWlk1mzElTPO4wxlRcd1C7xd7xDxzgpOXbQfE+NbClOUJ71NkbnahWN2ef2LcJvFESYg/CV9Dv/ULKSpJWEEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-win32-arm64-msvc": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-arm64-msvc/-/usb-win32-arm64-msvc-3.0.1.tgz",
"integrity": "sha512-ayXNeb+KvdkZLXzTWyttNONUPG8/x9PwuP+GvUeiYCQuyLMqmbGeMsO7IiuBWTnxLFtCdep+UBpcL8XMcYdTiA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-win32-ia32-msvc": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-ia32-msvc/-/usb-win32-ia32-msvc-3.0.1.tgz",
"integrity": "sha512-U5281i6yY7gtoTwLom8nLF2o+ILI/g4OFpbAJBr4GbVPOTu+CSNF2Ea+dLEAG30148mp8v79NEsIiAbWXS/1aw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@node-usb/usb-win32-x64-msvc": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-x64-msvc/-/usb-win32-x64-msvc-3.0.1.tgz",
"integrity": "sha512-T1jzCBwgnA6E5otxJS/j44b//6fGzHSDmLQtNN1fX6JvdQIisgtbf2+iKD/YI7E247j6Yi9Ixt+MKjHt64hugA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1624,6 +1884,254 @@
"win32"
]
},
"node_modules/@serialport/binding-mock": {
"version": "10.2.2",
"resolved": "https://registry.npmmirror.com/@serialport/binding-mock/-/binding-mock-10.2.2.tgz",
"integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==",
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "^1.2.1",
"debug": "^4.3.3"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@serialport/bindings-cpp": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz",
"integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "1.2.2",
"@serialport/parser-readline": "12.0.0",
"debug": "4.4.0",
"node-addon-api": "8.3.0",
"node-gyp-build": "4.8.4"
},
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": {
"version": "12.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz",
"integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": {
"version": "12.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-readline/-/parser-readline-12.0.0.tgz",
"integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==",
"license": "MIT",
"dependencies": {
"@serialport/parser-delimiter": "12.0.0"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/bindings-cpp/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@serialport/bindings-cpp/node_modules/node-addon-api": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.3.0.tgz",
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@serialport/bindings-interface": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz",
"integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==",
"license": "MIT",
"engines": {
"node": "^12.22 || ^14.13 || >=16"
}
},
"node_modules/@serialport/parser-byte-length": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz",
"integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-cctalk": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz",
"integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-delimiter": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz",
"integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-inter-byte-timeout": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz",
"integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-packet-length": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz",
"integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==",
"license": "MIT",
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@serialport/parser-readline": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-readline/-/parser-readline-13.0.0.tgz",
"integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==",
"license": "MIT",
"dependencies": {
"@serialport/parser-delimiter": "13.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-ready": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-ready/-/parser-ready-13.0.0.tgz",
"integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-regex": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-regex/-/parser-regex-13.0.0.tgz",
"integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-slip-encoder": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz",
"integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/parser-spacepacket": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz",
"integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/stream": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/@serialport/stream/-/stream-13.0.0.tgz",
"integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==",
"license": "MIT",
"dependencies": {
"@serialport/bindings-interface": "1.2.2",
"debug": "4.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/@serialport/stream/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -1814,6 +2322,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/w3c-web-usb": {
"version": "1.0.14",
"resolved": "https://registry.npmmirror.com/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@@ -2140,6 +2654,27 @@
"node": ">=14.6"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -3277,7 +3812,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5338,7 +5872,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": {
@@ -5404,6 +5937,40 @@
"license": "MIT",
"optional": true
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-hid": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/node-hid/-/node-hid-3.3.0.tgz",
"integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==",
"hasInstallScript": true,
"license": "(MIT OR X11)",
"dependencies": {
"node-addon-api": "^3.2.1",
"pkg-prebuilds": "^1.0.0"
},
"bin": {
"hid-showdevices": "src/show-devices.js"
},
"engines": {
"node": ">=10.16"
}
},
"node_modules/node-hid/node_modules/node-addon-api": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-3.2.1.tgz",
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.51",
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.51.tgz",
@@ -5666,6 +6233,19 @@
}
}
},
"node_modules/pkg-prebuilds": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/pkg-prebuilds/-/pkg-prebuilds-1.1.0.tgz",
"integrity": "sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==",
"license": "MIT",
"bin": {
"pkg-prebuilds-copy": "bin/copy.mjs",
"pkg-prebuilds-verify": "bin/verify.mjs"
},
"engines": {
"node": ">= 14.15.0"
}
},
"node_modules/pkg-up": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/pkg-up/-/pkg-up-3.1.0.tgz",
@@ -6088,6 +6668,51 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serialport": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/serialport/-/serialport-13.0.0.tgz",
"integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==",
"license": "MIT",
"dependencies": {
"@serialport/binding-mock": "10.2.2",
"@serialport/bindings-cpp": "13.0.0",
"@serialport/parser-byte-length": "13.0.0",
"@serialport/parser-cctalk": "13.0.0",
"@serialport/parser-delimiter": "13.0.0",
"@serialport/parser-inter-byte-timeout": "13.0.0",
"@serialport/parser-packet-length": "13.0.0",
"@serialport/parser-readline": "13.0.0",
"@serialport/parser-ready": "13.0.0",
"@serialport/parser-regex": "13.0.0",
"@serialport/parser-slip-encoder": "13.0.0",
"@serialport/parser-spacepacket": "13.0.0",
"@serialport/stream": "13.0.0",
"debug": "4.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://opencollective.com/serialport/donate"
}
},
"node_modules/serialport/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6347,6 +6972,13 @@
"node": ">=0.10.0"
}
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmmirror.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -6613,6 +7245,30 @@
"punycode": "^2.1.0"
}
},
"node_modules/usb": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/usb/-/usb-3.0.1.tgz",
"integrity": "sha512-IQIi5EraqmKssMNcXe7afKhriHFAE5WdfykhvgkOschJyHBkJXtk/PDx/DzVNyfI7qdr+BUbmLziXA+I3z02lw==",
"license": "MIT",
"dependencies": {
"@types/w3c-web-usb": "^1.0.14"
},
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
},
"optionalDependencies": {
"@node-usb/usb-darwin-arm64": "3.0.1",
"@node-usb/usb-darwin-x64": "3.0.1",
"@node-usb/usb-linux-arm-gnueabihf": "3.0.1",
"@node-usb/usb-linux-arm64-gnu": "3.0.1",
"@node-usb/usb-linux-arm64-musl": "3.0.1",
"@node-usb/usb-linux-x64-gnu": "3.0.1",
"@node-usb/usb-linux-x64-musl": "3.0.1",
"@node-usb/usb-win32-arm64-msvc": "3.0.1",
"@node-usb/usb-win32-ia32-msvc": "3.0.1",
"@node-usb/usb-win32-x64-msvc": "3.0.1"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
+25 -5
View File
@@ -9,26 +9,31 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
"package:dir": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --dir",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@lydell/node-pty": "^1.2.0-beta.12",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"better-sqlite3": "^11.5.0",
"electron-store": "^8.2.0",
"element-plus": "^2.8.4",
"highlight.js": "^11.10.0",
"markdown-it": "^14.3.0",
"node-hid": "^3.3.0",
"pinia": "^2.2.4",
"serialport": "^13.0.0",
"sudo-prompt": "^9.2.1",
"usb": "^3.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -59,6 +64,14 @@
"out/**/*",
"package.json"
],
"asarUnpack": [
"**/node_modules/@lydell/node-pty/**/*",
"**/node_modules/@lydell/node-pty-*/*",
"**/node_modules/@node-usb/**/*",
"**/node_modules/usb/prebuilds/**/*",
"**/node_modules/@serialport/**/prebuilds/**/*",
"**/node_modules/node-hid/prebuilds/**/*"
],
"extraResources": [
{
"from": "resources/icon.png",
@@ -68,7 +81,12 @@
"win": {
"icon": "resources/icon.png",
"target": [
{ "target": "nsis", "arch": ["x64"] }
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-Setup.${ext}"
},
@@ -88,7 +106,9 @@
},
"linux": {
"icon": "resources/icon.png",
"target": ["AppImage"]
"target": [
"AppImage"
]
}
}
}
+5 -1
View File
@@ -11,6 +11,8 @@ export interface ChatServerEvents {
recall: (from: DeviceInfo, messageId: string) => void
typing: (from: DeviceInfo) => void
read: (from: DeviceInfo, upToMessageId: string) => void
// 任意 ws 帧 (含 terminal/forward/usb 等), 让上层路由分发
frame: (from: DeviceInfo, frame: WsFrame) => void
}
export class ChatServer extends EventEmitter {
@@ -52,6 +54,8 @@ export class ChatServer extends EventEmitter {
} catch {
return
}
// 通用: 任何帧都抛 'frame', 上层 (ipc.bindNetworkContext) 自行路由
if (peer) this.emit('frame', peer, frame)
switch (frame.type) {
case 'hello':
if (frame.from && frame.from.deviceId) {
@@ -79,12 +83,12 @@ export class ChatServer extends EventEmitter {
if (peer) this.emit('recall', peer, frame.messageId)
break
case 'read':
// 已读回执: 接收方已看到 upTo (含), 把 ≤ upTo 的发送方消息视为已读
if (peer) this.emit('read', peer, frame.upTo)
break
case 'typing':
if (peer) this.emit('typing', peer)
break
// terminal / forward / usb 由 'frame' 事件统一交给上层处理
}
})
+86
View File
@@ -42,6 +42,23 @@ CREATE TABLE IF NOT EXISTS unread (
device_id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);
-- 远程操作审计 (terminal / forward / usb)
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
action TEXT NOT NULL,
source_device TEXT,
target_device TEXT,
session_id TEXT,
payload_json TEXT,
result TEXT NOT NULL,
bytes_in INTEGER NOT NULL DEFAULT 0,
bytes_out INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
`)
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
@@ -240,3 +257,72 @@ export function totalUnread(): number {
const r = db.prepare(`SELECT COALESCE(SUM(count), 0) AS s FROM unread`).get() as { s: number }
return r.s
}
// 审计日志 (远程操作: terminal / forward / usb)
export interface AuditRow {
id: number
ts: number
action: string
source_device: string | null
target_device: string | null
session_id: string | null
payload_json: string | null
result: string
bytes_in: number
bytes_out: number
}
const insertAuditStmt = db.prepare(`
INSERT INTO audit_log
(ts, action, source_device, target_device, session_id, payload_json, result, bytes_in, bytes_out)
VALUES
(@ts, @action, @source_device, @target_device, @session_id, @payload_json, @result, @bytes_in, @bytes_out)
`)
export function recordAudit(args: {
action: string
source?: string | null
target?: string | null
sessionId?: string | null
payload?: any
result: 'ok' | 'denied' | 'error' | 'closed'
bytesIn?: number
bytesOut?: number
}): void {
insertAuditStmt.run({
ts: Date.now(),
action: args.action,
source_device: args.source ?? null,
target_device: args.target ?? null,
session_id: args.sessionId ?? null,
payload_json: args.payload !== undefined ? JSON.stringify(args.payload) : null,
result: args.result,
bytes_in: args.bytesIn ?? 0,
bytes_out: args.bytesOut ?? 0,
})
}
export function listAudit(opts: { limit?: number; since?: number; sessionId?: string } = {}): AuditRow[] {
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 2000)
if (opts.sessionId) {
return db.prepare(`SELECT * FROM audit_log WHERE session_id = ? ORDER BY ts DESC LIMIT ?`).all(opts.sessionId, limit) as AuditRow[]
}
if (opts.since) {
return db.prepare(`SELECT * FROM audit_log WHERE ts >= ? ORDER BY ts DESC LIMIT ?`).all(opts.since, limit) as AuditRow[]
}
return db.prepare(`SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?`).all(limit) as AuditRow[]
}
export function pruneAudit(olderThanMs: number): number {
const r = db.prepare(`DELETE FROM audit_log WHERE ts < ?`).run(Date.now() - olderThanMs)
return r.changes
}
// 远程操作白名单 (按 deviceId -> { terminal, forward, usb })
// 写入 settings.json (electron-store) 而不是 DB, 便于跨设备查看与回滚
export interface RemoteAllowRow {
device_id: string
terminal: number // 0/1
forward: number
usb: number
}
+22 -3
View File
@@ -14,8 +14,11 @@ import { Discovery } from './discovery'
import { ChatServer } from './chat-server'
import { ChatClient } from './chat-client'
import { FileServer } from './file-server'
import { TerminalManager } from './remote/terminal'
import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { registerIpc, bindNetworkContext } from './ipc'
import { listDevices, totalUnread, getUnread } from './db'
import { listDevices, totalUnread, getUnread, pruneAudit } from './db'
import { setBadgeCount } from './notify'
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
import type { DeviceInfo } from './protocol'
@@ -58,6 +61,9 @@ async function main() {
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
const chatClient = new ChatClient(self)
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir())
const terminal = new TerminalManager(chatClient, self.deviceId)
const forward = new ForwardManager(chatClient)
const usb = new UsbBridgeManager(chatClient)
const chatPort = await chatServer.start()
const filePort = await fileServer.start()
@@ -65,10 +71,18 @@ async function main() {
self.filePort = filePort
// 后台清理 24h+ 残留 .tmp (不阻塞启动)
fileServer.cleanupStaleTmp().catch(() => {})
stopFns.push(() => chatServer.stop(), () => chatClient.stop(), () => discovery.stop(), () => fileServer.stop())
stopFns.push(
() => chatServer.stop(),
() => chatClient.stop(),
() => discovery.stop(),
() => fileServer.stop(),
() => terminal.stop(),
() => forward.stop(),
() => usb.stop(),
)
await discovery.start()
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer })
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb })
// 已入库的设备: 启动后主动尝试连接
for (const d of listDevices()) {
@@ -91,6 +105,11 @@ async function main() {
createTray()
registerIpc()
// 启动时按 auditRetentionDays 清理一次旧审计
const s = getSettings()
const retentionMs = Math.max(1, s.auditRetentionDays) * 24 * 3600 * 1000
pruneAudit(retentionMs)
setTimeout(pushInitialState, 400)
}
+236 -2
View File
@@ -10,7 +10,8 @@ import {
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates,
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation,
recordAudit, listAudit, pruneAudit
} from './db'
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
import { ChatServer } from './chat-server'
@@ -19,7 +20,11 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef } from './protocol'
import { TerminalManager } from './remote/terminal'
import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
interface NetCtx {
self: DeviceInfo
@@ -27,6 +32,9 @@ interface NetCtx {
chatServer: ChatServer
chatClient: ChatClient
fileServer: FileServer
terminal: TerminalManager
forward: ForwardManager
usb: UsbBridgeManager
}
let ctx: NetCtx | null = null
@@ -159,6 +167,40 @@ export function bindNetworkContext(c: NetCtx) {
else nextStatus = 'delivered'
updateMessageStatus(messageId, nextStatus)
broadcastToRenderer('message:statusChanged', { messageId, status: nextStatus })
return
}
if (frame.type === 'terminal') {
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn('[terminal] handle error:', e?.message))
return
}
if (frame.type === 'forward') {
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
return
}
if (frame.type === 'usb') {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
})
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
// 这里分发 remote 帧给本地 manager (peer -> 我们) — 控制端发的请求落在这里
c.chatServer.on('frame', (peer, frame) => {
if (frame.type === 'terminal') {
console.log(`[ipc] incoming terminal frame from ${peer.name}: ${frame.payload.type}`)
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn('[terminal] handle error:', e?.message))
return
}
if (frame.type === 'forward') {
console.log(`[ipc] incoming forward frame from ${peer.name}: ${(frame.payload as any).type}`)
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
return
}
if (frame.type === 'usb') {
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
return
}
})
@@ -234,6 +276,57 @@ export function bindNetworkContext(c: NetCtx) {
broadcastToRenderer('message:statusChanged', { messageId: r.message_id, status: 'read' })
}
})
// ===== Remote: terminal / forward / usb 事件桥接 =====
c.terminal.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
// 控制端从被控端收到 output -> 给 UI
broadcastToRenderer('remote:terminal:output', { sessionId, peerId, data: bytes.toString('base64') })
})
c.terminal.on('sessionOpened', (sessionId: string, peerId: string, info: { shell: string; rows: number; cols: number }) => {
broadcastToRenderer('remote:terminal:opened', { sessionId, peerId, ...info })
})
c.terminal.on('sessionClosed', (sessionId: string, peerId: string, reason?: string) => {
broadcastToRenderer('remote:terminal:closed', { sessionId, peerId, reason })
})
c.terminal.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'terminal' })
})
c.forward.on('sessionOpened', (info) => {
broadcastToRenderer('remote:forward:opened', info)
})
c.forward.on('sessionClosed', (info) => {
broadcastToRenderer('remote:forward:closed', info)
})
c.forward.on('sessionError', (info) => {
broadcastToRenderer('remote:forward:error', info)
})
c.forward.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'forward' })
})
c.usb.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
broadcastToRenderer('remote:usb:output', { sessionId, peerId, data: bytes.toString('base64') })
})
c.usb.on('sessionOpened', (info) => {
broadcastToRenderer('remote:usb:opened', info)
})
c.usb.on('sessionClosed', (info) => {
broadcastToRenderer('remote:usb:closed', info)
})
c.usb.on('sessionError', (info) => {
broadcastToRenderer('remote:usb:error', info)
})
c.usb.on('approvalRequested', (req) => {
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'usb' })
})
// 对端 ws 断开 -> 清理该对端的所有 session
c.chatClient.on('close', (peer) => {
c.terminal.onPeerDisconnected(peer.deviceId)
c.forward.onPeerDisconnected(peer.deviceId)
c.usb.onPeerDisconnected(peer.deviceId)
})
}
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
@@ -911,6 +1004,147 @@ export function registerIpc() {
return false
}
})
// ===== 远程: terminal =====
ipcMain.handle('terminal:open', async (_e, args: { peerId: string; rows?: number; cols?: number; readOnly?: boolean }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.terminal.openOnPeer(args.peerId, {
rows: args.rows ?? 24,
cols: args.cols ?? 80,
readOnly: args.readOnly,
})
})
ipcMain.handle('terminal:input', (_e, args: { sessionId: string; dataBase64: string }) => {
if (!ctx) return false
if (!args?.sessionId || !args?.dataBase64) return false
const buf = Buffer.from(args.dataBase64, 'base64')
return ctx.terminal.sendInput(args.sessionId, buf)
})
ipcMain.handle('terminal:resize', (_e, args: { sessionId: string; rows: number; cols: number }) => {
if (!ctx) return false
return ctx.terminal.resize(args.sessionId, args.rows, args.cols)
})
ipcMain.handle('terminal:close', (_e, args: { sessionId: string; reason?: string }) => {
if (!ctx) return false
ctx.terminal.close(args.sessionId, args.reason)
return true
})
ipcMain.handle('terminal:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.terminal.listClientSessions(),
server: ctx.terminal.listServerSessions(),
}
})
// ===== 远程: forward =====
ipcMain.handle('forward:open', async (_e, args: { peerId: string; direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.forward.openOnPeer(args.peerId, args)
})
ipcMain.handle('forward:close', (_e, args: { sessionId: string }) => {
if (!ctx) return false
// 控制端: 关 clientSession
ctx.forward.cleanupClientSession(args.sessionId, 'user')
return true
})
ipcMain.handle('forward:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.forward.listClientSessions(),
server: ctx.forward.listServerSessions(),
}
})
// ===== 远程: usb (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) =====
ipcMain.handle('usb:list', async (_e, args: { peerId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.listOnPeer(args.peerId)
})
// 列本机 (串口 + libusb + linux usbip) — self-in 模式用
ipcMain.handle('usb:listLocal', async () => {
if (!ctx) return { ok: false, reason: 'no ctx' }
try {
const devices = await ctx.usb.listLocal()
return { ok: true, devices }
} catch (e: any) {
return { ok: false, reason: String(e?.message || e), devices: [] }
}
})
// attach: 由 renderer 把 UsbAttachConfig 传过来
ipcMain.handle('usb:attach', async (_e, args: { peerId: string; direction?: UsbDirection; busId: string; config: UsbAttachConfig }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config })
})
// 串口: 发送字节 (client 端)
ipcMain.handle('usb:serialSend', (_e, args: { sessionId: string; dataBase64: string }) => {
if (!ctx) return false
const buf = Buffer.from(args.dataBase64, 'base64')
return ctx.usb.sendSerialBytes(args.sessionId, buf)
})
// USB 控制传输 (control OUT / control IN)
ipcMain.handle('usb:ctrlOut', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; dataBase64?: string }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbCtrlTransfer(args.sessionId, args.setup, args.dataBase64)
})
ipcMain.handle('usb:ctrlIn', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; length: number }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbCtrlIn(args.sessionId, args.setup, args.length)
})
// USB 批量传输 (bulk OUT / bulk IN)
ipcMain.handle('usb:bulkOut', async (_e, args: { sessionId: string; endpoint: number; dataBase64: string }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbBulkOut(args.sessionId, args.endpoint, args.dataBase64)
})
ipcMain.handle('usb:bulkIn', async (_e, args: { sessionId: string; endpoint: number; length: number; timeoutMs?: number }) => {
if (!ctx) return { ok: false, status: -1 }
return ctx.usb.usbBulkIn(args.sessionId, args.endpoint, args.length, args.timeoutMs)
})
ipcMain.handle('usb:detach', async (_e, args: { sessionId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
return ctx.usb.detach(args.sessionId, 'user')
})
ipcMain.handle('usb:listSessions', () => {
if (!ctx) return { client: [], server: [] }
return {
client: ctx.usb.listClientSessions(),
server: ctx.usb.listServerSessions(),
}
})
// ===== 授权 / 审计 =====
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
return replyApproval(args.requestId, args.ok, args.remember)
})
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
return listAudit(args || {})
})
ipcMain.handle('audit:prune', (_e, olderThanMs?: number) => {
const days = getSettings().auditRetentionDays
const ms = olderThanMs ?? Math.max(1, days) * 24 * 3600 * 1000
return pruneAudit(ms)
})
}
function rowToEnvelope(row: any): MessageEnvelope {
+129
View File
@@ -36,6 +36,135 @@ export type WsFrame =
| { type: 'recall'; messageId: string; ts: number }
| { type: 'typing'; from: string; ts: number }
| { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读
| { type: 'terminal'; payload: TerminalFrame } // 远程终端 (双向多路复用)
| { type: 'forward'; payload: ForwardFrame } // 端口转发
| { type: 'usb'; payload: UsbFrame } // USB 设备透传
// ============ 远程终端 ============
export type TerminalFrame =
| { type: 'open'; sessionId: string; rows: number; cols: number; shell?: string; readOnly?: boolean }
| { type: 'input'; sessionId: string; data: string /* base64 of bytes */ }
| { type: 'output'; sessionId: string; data: string /* base64 of bytes */ }
| { type: 'resize'; sessionId: string; rows: number; cols: number }
| { type: 'close'; sessionId: string; reason?: string }
| { type: 'ack'; sessionId: string; ok: boolean; reason?: string }
// ============ 端口转发 ============
/**
* 转发方向:
* 'self-out': 控制端作为代理入口 — 控制端开 127.0.0.1 TCP server, 被控端连自己的目标
* 用途: 把对方的 127.0.0.1:8080 "挪到" 自己的 localhost 访问
* 'self-in': 控制端作为被代理目标 — 控制端连自己的目标, 被控端开 127.0.0.1 TCP server
* 用途: 把自己的 127.0.0.1:8080 暴露到对方的 localhost 让对方访问
*/
export type ForwardDirection = 'self-out' | 'self-in'
export type ForwardFrame =
| { type: 'open'; sessionId: string; direction: ForwardDirection; listenPort: number; targetHost: string; targetPort: number; ttlSec: number }
| { type: 'data'; sessionId: string; dir: 'c2s' | 's2c'; data: string /* base64 */; fin?: boolean }
| { type: 'close'; sessionId: string; reason?: string; bytesIn?: number; bytesOut?: number }
| { type: 'ack'; sessionId: string; ok: boolean; reason?: string }
// ============ USB / 串口 共享 ============
//
// 老实说: 纯 Node.js 在 Windows 上做不到 "USB 设备完全透明地变成本机 USB" — 那是商业软件
// (VirtualHere / USB Network Gate / FlexiHub) 或 Linux usbip + WSL 才有的能力.
//
// 我们提供三种能力, 按场景取用:
// 1. 'serial' — 真正的双向字节流转发, 适合 USB-串口适配器 (CH340/CP210x/FTDI) 和原生串口.
// 在 OS 看来就是普通的串口, 桥接完整 (无协议).
// 2. 'usb' — libusb 字节桥: server 端 node-usb open + claim interface, client 端发
// controlTransfer / bulkTransfer 请求并收结果. 适合访问自定义 USB 设备
// (单片机, 编程器, 调试器). 不是透明 USB — 是远程调设备.
// 3. 'usbip' — Linux only, 调系统 usbip CLI 做真透明 USB 透传. 用户机器必须装了
// usbip + 加载 vhci_hcd 模块. macOS/Windows 默认不可用, UI 会隐藏.
export interface UsbDeviceInfo {
busId: string // 总线 id, 用于唯一识别 + attach
vid: number
pid: number
deviceClass: number
deviceSubclass: number
product?: string
manufacturer?: string
serialNumber?: string
port?: string // node-hid 的 path (HID 模式)
/** 'serial' = 字节流转发, 'usb' = libusb 字节桥 (control/bulk), 'usbip' = Linux 透明透传 */
kind: 'serial' | 'usb' | 'usbip'
serialPath?: string // 串口路径, e.g. COM3 / /dev/ttyUSB0
baudRate?: number // 串口默认波特率
}
/**
* USB 方向: 同 Forward
* 'self-out': 控制端使用对端的设备 (对端打开真实设备)
* 'self-in': 对端使用控制端的设备 (控制端打开真实设备)
*/
export type UsbDirection = 'self-out' | 'self-in'
// attach 时附带的配置 (按 kind 区分)
export type UsbAttachConfig =
| { kind: 'serial'; baudRate?: number; dataBits?: 5|6|7|8; stopBits?: 1|2; parity?: 'none'|'even'|'odd'|'mark'|'space'; createVirtual?: boolean; virtualName?: string }
| { kind: 'usb'; configurationValue?: number; interfaceNumber?: number; detachKernelDriver?: boolean }
| { kind: 'usbip' }
// attached 帧带回的设备元信息 (主要用于 usb 模式, 让 UI 知道有哪些 endpoint)
export interface UsbEndpointInfo {
endpointNumber: number
direction: 'in' | 'out'
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
packetSize: number
}
export interface UsbAttachedInfo {
kind: 'serial' | 'usb' | 'usbip'
// usb 模式: 设备的 endpoint 列表
endpoints?: UsbEndpointInfo[]
// 串口模式: 远端真实串口路径
serialPath?: string
// 串口模式: 本机虚拟串口路径 (createVirtual=true 时填)
userVirtualPath?: string
// 设备基础信息
vid?: number
pid?: number
product?: string
manufacturer?: string
serialNumber?: string
}
// USB 控制传输 setup 包 (libusb 风格)
export interface UsbControlSetup {
requestType: number // bmRequestType: 方向 | 类型 | 接收方
request: number // bRequest
value: number // wValue
index: number // wIndex
}
export type UsbFrame =
| { type: 'list'; reqId: string }
| { type: 'devices'; reqId: string; devices: UsbDeviceInfo[] }
| { type: 'attach'; sessionId: string; direction: UsbDirection; busId: string; config: UsbAttachConfig }
| { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: UsbAttachedInfo }
| { type: 'detach'; sessionId: string; reason?: string }
| { type: 'detached'; sessionId: string; ok: boolean; reason?: string }
// 字节流 (serial 模式: 双向, 半关时 fin=true; usb 模式: 不用, 用 control/bulk)
| { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string /* base64 */; fin?: boolean }
// USB 控制传输 (request-response, 用 reqId 配对)
| { type: 'ctrlOut'; sessionId: string; reqId: string; setup: UsbControlSetup; data?: string }
| { type: 'ctrlIn'; sessionId: string; reqId: string; setup: UsbControlSetup; length: number }
| { type: 'ctrlResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
// USB 批量/中断传输 (request-response, 用 reqId 配对)
| { type: 'bulkOut'; sessionId: string; reqId: string; endpoint: number; data: string }
| { type: 'bulkIn'; sessionId: string; reqId: string; endpoint: number; length: number; timeoutMs?: number }
| { type: 'bulkResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
| { type: 'ack'; reqId?: string; sessionId?: string; ok: boolean; reason?: string }
| { type: 'error'; sessionId: string; reason: string }
// ============ 消息 ============
+18
View File
@@ -14,6 +14,18 @@ export interface Settings {
preferredInterface: string
/** 备选: 用户也可指定精确地址 (避免接口名漂移) */
preferredAddress: string
/** 远程功能总开关 (false = 完全禁用 terminal/forward/usb, 即使 allowPeers 里有白名单) */
remoteEnabled: boolean
/** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, usb } */
remoteAllowPeers: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
/** 端口转发默认 TTL (秒), 上限 24h */
forwardDefaultTtlSec: number
/** 端口转发默认字节速率上限 (bytes/s), 0 = 不限 */
forwardMaxBytesPerSec: number
/** 远程终端 read-only 默认 (禁用 input) */
terminalReadOnlyByDefault: boolean
/** 审计日志保留天数 */
auditRetentionDays: number
}
export const store = new Store<Settings>({
@@ -28,6 +40,12 @@ export const store = new Store<Settings>({
theme: 'light',
preferredInterface: '',
preferredAddress: '',
remoteEnabled: true,
remoteAllowPeers: {},
forwardDefaultTtlSec: 3600,
forwardMaxBytesPerSec: 10 * 1024 * 1024,
terminalReadOnlyByDefault: false,
auditRetentionDays: 30,
},
})
+46
View File
@@ -65,4 +65,50 @@ contextBridge.exposeInMainWorld('api', {
// 离线消息: 启动后让主进程把 pending 重新尝试一遍
flushPending: () => ipcRenderer.invoke('message:flushPending'),
// 远程: 终端
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
ipcRenderer.invoke('terminal:open', { peerId, ...(opts || {}) }),
terminalInput: (sessionId: string, dataBase64: string) =>
ipcRenderer.invoke('terminal:input', { sessionId, dataBase64 }),
terminalResize: (sessionId: string, rows: number, cols: number) =>
ipcRenderer.invoke('terminal:resize', { sessionId, rows, cols }),
terminalClose: (sessionId: string, reason?: string) =>
ipcRenderer.invoke('terminal:close', { sessionId, reason }),
terminalListSessions: () => ipcRenderer.invoke('terminal:listSessions'),
// 远程: 端口转发
forwardOpen: (peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
ipcRenderer.invoke('forward:open', { peerId, ...args }),
forwardClose: (sessionId: string) => ipcRenderer.invoke('forward:close', { sessionId }),
forwardListSessions: () => ipcRenderer.invoke('forward:listSessions'),
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
usbList: (peerId: string) => ipcRenderer.invoke('usb:list', { peerId }),
usbListLocal: () => ipcRenderer.invoke('usb:listLocal'),
usbAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) =>
ipcRenderer.invoke('usb:attach', { peerId, busId, ...(opts || {}) }),
// 串口: 发送字节
usbSerialSend: (sessionId: string, dataBase64: string) =>
ipcRenderer.invoke('usb:serialSend', { sessionId, dataBase64 }),
// USB: 控制传输
usbCtrlOut: (sessionId: string, setup: any, dataBase64?: string) =>
ipcRenderer.invoke('usb:ctrlOut', { sessionId, setup, dataBase64 }),
usbCtrlIn: (sessionId: string, setup: any, length: number) =>
ipcRenderer.invoke('usb:ctrlIn', { sessionId, setup, length }),
// USB: 批量传输
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
ipcRenderer.invoke('usb:bulkOut', { sessionId, endpoint, dataBase64 }),
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
ipcRenderer.invoke('usb:bulkIn', { sessionId, endpoint, length, timeoutMs }),
usbDetach: (sessionId: string) => ipcRenderer.invoke('usb:detach', { sessionId }),
usbListSessions: () => ipcRenderer.invoke('usb:listSessions'),
// 授权 / 审计
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) =>
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember }),
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) =>
ipcRenderer.invoke('audit:list', args || {}),
auditPrune: (olderThanMs?: number) => ipcRenderer.invoke('audit:prune', olderThanMs),
})
+55 -27
View File
@@ -1,18 +1,24 @@
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { onMounted, ref, computed, watch, onUnmounted } from 'vue'
import { useDeviceStore } from '@/stores/device'
import { useMessageStore } from '@/stores/message'
import { useSessionStore } from '@/stores/session'
import { useTerminalStore, useForwardStore, useUsbStore, useApprovalStore } from '@/stores/remote'
import Sidebar from '@/components/Sidebar.vue'
import ChatView from '@/components/ChatView.vue'
import SettingsView from '@/components/SettingsView.vue'
import ContextMenu from '@/components/ContextMenu.vue'
import ApprovalDialog from '@/components/remote/ApprovalDialog.vue'
import { formatTime } from '@/utils/format'
import type { DeviceView, MessageView, Settings } from '@/api'
const device = useDeviceStore()
const message = useMessageStore()
const session = useSessionStore()
const terminal = useTerminalStore()
const forward = useForwardStore()
const usb = useUsbStore()
const approval = useApprovalStore()
const showSettings = ref(false)
const imageViewer = ref<string | null>(null)
@@ -30,6 +36,8 @@ function showToast(text: string) {
setTimeout(() => el.remove(), 2200)
}
const unsubs: Array<() => void> = []
onMounted(async () => {
await device.loadSelf()
if (device.self) message.setSelf(device.self.deviceId)
@@ -37,34 +45,34 @@ onMounted(async () => {
// 启动后尝试一次 flush pending
setTimeout(() => { window.api.flushPending() }, 1500)
window.api.on('boot:ready', () => { device.refresh() })
window.api.on('device:found', (d: DeviceView) => {
unsubs.push(window.api.on('boot:ready', () => { device.refresh() }))
unsubs.push(window.api.on('device:found', (d: DeviceView) => {
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
const merged: DeviceView = { ...(device.devices[idx] || ({} as DeviceView)), ...d, online: true, lastSeen: Date.now() }
if (idx >= 0) device.devices[idx] = merged
else device.devices.push(merged)
})
window.api.on('device:updated', (d: DeviceView) => {
}))
unsubs.push(window.api.on('device:updated', (d: DeviceView) => {
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
if (idx >= 0) device.devices[idx] = { ...device.devices[idx], ...d, online: true, lastSeen: Date.now() }
})
window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
}))
unsubs.push(window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = false
})
window.api.on('device:online', ({ deviceId }: { deviceId: string }) => {
}))
unsubs.push(window.api.on('device:online', ({ deviceId }: { deviceId: string }) => {
const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = true
})
window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
}))
unsubs.push(window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = false
})
}))
// 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线"
window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
unsubs.push(window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
device.setWsOpen(deviceId, wsOpen)
})
window.api.on('message:received', (env: MessageView) => {
}))
unsubs.push(window.api.on('message:received', (env: MessageView) => {
const fromId = env.fromDeviceId
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
// 第二次带 savedPath, 气泡自动有"打开/位置"
@@ -107,8 +115,8 @@ onMounted(async () => {
else if (env.type === 'file') body = `[文件] ${(env.body as any).name || ''}`
if (body) showToast(`${name}: ${body}`)
})
})
window.api.on('message:local', (env: MessageView) => {
}))
unsubs.push(window.api.on('message:local', (env: MessageView) => {
// env 里的 status 是后端给的, 通常是 'pending' (离线) 或 'sent' (已发送)
const fid = (env.body as any)?.fileId
const savedPath = (env.body as any)?.savedPath
@@ -119,15 +127,15 @@ onMounted(async () => {
}
// 用 upsert: 同一个 messageId 可能来多次 (第一次 status='sending'/savedPath=null, 第二次已 fill)
message.upsert({ ...env, status: env.status || 'pending' })
})
window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
}))
unsubs.push(window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
message.remove(messageId)
})
window.api.on('message:progress', (e: any) => {
}))
unsubs.push(window.api.on('message:progress', (e: any) => {
// { toDeviceId, fileId, sent, total }
message.setProgress(e.fileId, e.sent, e.total)
})
window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
}))
unsubs.push(window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
// 终态才清 progress: 'sent' 在新流程里出现得太早 (WS metadata 已发, 但上传还在后台跑)
// 让 setProgress 在 100% 时自动清, 避免误清
if (status === 'delivered' || status === 'failed') {
@@ -137,11 +145,30 @@ onMounted(async () => {
}
}
message.updateStatus(messageId, status)
})
window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
}))
unsubs.push(window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
session.setActive(fromDeviceId)
})
window.api.on('settings:changed', (s: Settings) => { device.settings = s })
}))
unsubs.push(window.api.on('settings:changed', (s: Settings) => { device.settings = s }))
// 远程事件订阅
unsubs.push(window.api.on('remote:terminal:output', (p) => terminal.onOutput(p)))
unsubs.push(window.api.on('remote:terminal:opened', (p) => terminal.onOpened(p)))
unsubs.push(window.api.on('remote:terminal:closed', (p) => terminal.onClosed(p)))
unsubs.push(window.api.on('remote:forward:opened', (p) => forward.onOpened(p)))
unsubs.push(window.api.on('remote:forward:closed', (p) => forward.onClosed(p)))
unsubs.push(window.api.on('remote:usb:opened', () => { /* no-op; refresh in component */ }))
unsubs.push(window.api.on('remote:usb:closed', (p) => usb.onClosed(p)))
unsubs.push(window.api.on('remote:usb:output', (p) => usb.onOutput(p)))
unsubs.push(window.api.on('remote:usb:error', (p) => { /* surface via console for now */ console.warn('[usb]', p) }))
unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req)))
// 启动时拉取当前 approval 队列 (防止刚启动就出现未读请求)
approval.refresh().catch(() => {})
})
onUnmounted(() => {
for (const u of unsubs) try { u() } catch {}
})
watch(() => session.activePeerId, async (id) => {
@@ -190,5 +217,6 @@ document.addEventListener('click', (e) => {
<img :src="imageViewer" />
</div>
<ContextMenu />
<ApprovalDialog />
</div>
</template>
+180 -3
View File
@@ -1,5 +1,7 @@
// 与主进程 preload 暴露的 window.api 类型一一对应
export type LnmApi = {
export type LnmApi = LnmApiBase & LnmApiExtra
export type LnmApiBase = {
platform: string
versions: Record<string, string | undefined>
@@ -41,7 +43,6 @@ export type LnmApi = {
on: (channel: string, cb: (payload: any) => void) => () => void
// 触发主进程对所有 pending 消息做一次 flush 尝试
flushPending: () => Promise<{ ok: boolean; flushed: number }>
}
@@ -88,6 +89,12 @@ export interface Settings {
theme: 'light' | 'dark'
preferredInterface?: string
preferredAddress?: string
remoteEnabled?: boolean
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
forwardDefaultTtlSec?: number
forwardMaxBytesPerSec?: number
terminalReadOnlyByDefault?: boolean
auditRetentionDays?: number
}
export interface NetInterface {
@@ -109,10 +116,180 @@ export interface ProgressEvent {
}
// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致)
// 仅携带足够渲染作者 + 内容预览的字段 — 原消息被撤回/删除也不影响这条引用
export interface ReplyRef {
messageId: string
authorName: string
type: MessageType
preview: string
}
// 远程: 终端
export interface TerminalSessionInfo {
sessionId: string
peerId: string
shell: string
rows: number
cols: number
readOnly: boolean
createdAt: number
}
// 远程: 端口转发
export type ForwardDirection = 'self-out' | 'self-in'
export interface ForwardSessionInfo {
sessionId: string
peerId: string
direction: ForwardDirection
listenPort?: number
targetHost: string
targetPort: number
bytesIn: number
bytesOut: number
expiresAt: number
side: 'client' | 'server'
}
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
export type UsbDirection = 'self-out' | 'self-in'
export type UsbKind = 'serial' | 'usb' | 'usbip'
export interface UsbDeviceInfo {
busId: string
vid: number
pid: number
deviceClass: number
deviceSubclass: number
product?: string
manufacturer?: string
serialNumber?: string
kind: UsbKind
serialPath?: string
baudRate?: number
}
export interface UsbEndpointInfo {
endpointNumber: number
direction: 'in' | 'out'
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
packetSize: number
}
export interface UsbAttachedInfo {
kind: UsbKind
endpoints?: UsbEndpointInfo[]
serialPath?: string
userVirtualPath?: string // 本机虚拟串口路径 (createVirtual=true 时填)
vid?: number
pid?: number
product?: string
manufacturer?: string
serialNumber?: string
}
export type UsbAttachConfig =
// serial: 字节流转发
| {
kind: 'serial'
baudRate?: number
dataBits?: 5 | 6 | 7 | 8
stopBits?: 1 | 2
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space'
/** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 用户可在 PuTTY/Arduino IDE 打开该路径 */
createVirtual?: boolean
/** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */
virtualName?: string
}
// usb: libusb 字节桥
| {
kind: 'usb'
configurationValue?: number
interfaceNumber?: number
detachKernelDriver?: boolean
}
// usbip: Linux only, 真透明透传 (需内核模块)
| {
kind: 'usbip'
}
export interface UsbSessionInfo {
sessionId: string
peerId: string
direction: UsbDirection
busId: string
kind: UsbKind
info?: UsbAttachedInfo
bytesIn: number
bytesOut: number
createdAt: number
side: 'client' | 'server'
}
export interface UsbTransferResult {
ok: boolean
data?: number[] // bytes (UI 解码为 Uint8Array, 因为 renderer 端没 Buffer)
status?: number
}
// 远程: 授权请求
export interface ApprovalRequestView {
requestId: string
peerId: string
peerName: string
kind: 'terminal' | 'forward' | 'usb'
detail: string
ts: number
}
// 远程: 审计
export interface AuditEntry {
id: number
ts: number
action: string
source_device: string | null
target_device: string | null
session_id: string | null
payload_json: string | null
result: string
bytes_in: number
bytes_out: number
}
// 扩展 API
export type LnmApiExtra = {
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
terminalInput: (sessionId: string, dataBase64: string) => Promise<boolean>
terminalResize: (sessionId: string, rows: number, cols: number) => Promise<boolean>
terminalClose: (sessionId: string, reason?: string) => Promise<boolean>
terminalListSessions: () => Promise<{ client: TerminalSessionInfo[]; server: TerminalSessionInfo[] }>
forwardOpen: (peerId: string, args: { direction?: ForwardDirection; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
forwardClose: (sessionId: string) => Promise<boolean>
forwardListSessions: () => Promise<{ client: ForwardSessionInfo[]; server: ForwardSessionInfo[] }>
usbList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
usbListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
usbAttach: (peerId: string, busId: string, opts: { direction?: UsbDirection; config: UsbAttachConfig }) =>
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
// 串口: 发送字节
usbSerialSend: (sessionId: string, dataBase64: string) => Promise<boolean>
// USB: 控制传输
usbCtrlOut: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) =>
Promise<{ ok: boolean; status?: number }>
usbCtrlIn: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) =>
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
// USB: 批量传输
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
Promise<{ ok: boolean; status?: number }>
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }>
remoteApprovalList: () => Promise<ApprovalRequestView[]>
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
auditPrune: (olderThanMs?: number) => Promise<number>
}
+63 -1
View File
@@ -5,10 +5,13 @@ import { useMessageStore } from '@/stores/message'
import { useSessionStore } from '@/stores/session'
import MessageItem from './MessageItem.vue'
import MessageInput from './MessageInput.vue'
import TerminalPanel from './remote/TerminalPanel.vue'
import ForwardPanel from './remote/ForwardPanel.vue'
import UsbPanel from './remote/UsbPanel.vue'
import type { DeviceView } from '@/api'
import { initialsOf, colorFor, formatTime } from '@/utils/format'
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView | null }>()
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
@@ -17,6 +20,11 @@ const device = useDeviceStore()
const message = useMessageStore()
const session = useSessionStore()
type Tab = 'chat' | 'terminal' | 'forward' | 'usb'
const activeTab = ref<Tab>('chat')
// 切到别的设备时回到 chat tab
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
const self = computed(() => device.self)
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
@@ -145,8 +153,24 @@ async function onGlobalDrop(e: DragEvent) {
</template>
</div>
</div>
<nav class="header-tabs">
<button :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">
<el-icon><ChatLineRound /></el-icon><span>聊天</span>
</button>
<button :class="{ active: activeTab === 'terminal' }" @click="activeTab = 'terminal'" :disabled="!peer.online">
<el-icon><Position /></el-icon><span>终端</span>
</button>
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online">
<el-icon><Connection /></el-icon><span>转发</span>
</button>
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online">
<el-icon><Cellphone /></el-icon><span>USB</span>
</button>
</nav>
</header>
<!-- chat tab -->
<template v-if="activeTab === 'chat'">
<el-scrollbar ref="bodyEl" class="chat-body">
<div class="chat-body-inner">
<MessageItem
@@ -179,6 +203,14 @@ async function onGlobalDrop(e: DragEvent) {
<el-icon :size="48" color="#fff"><UploadFilled /></el-icon>
<div class="global-drop-hint">松开发送到 {{ peer.name }}</div>
</div>
</template>
<!-- terminal tab -->
<TerminalPanel v-else-if="activeTab === 'terminal'" :peer="peer" :active="activeTab === 'terminal'" />
<!-- forward tab -->
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
<!-- usb tab -->
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
</main>
<main class="chat-main chat-main-empty" v-else>
<el-empty :image-size="120" description="选择一个设备开始聊天">
@@ -246,6 +278,36 @@ async function onGlobalDrop(e: DragEvent) {
margin-left: 2px;
}
.header-tabs {
margin-left: auto;
display: flex;
gap: 2px;
background: var(--el-fill-color-light);
border-radius: 8px;
padding: 3px;
}
.header-tabs button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--el-text-color-regular);
transition: background 0.15s, color 0.15s;
}
.header-tabs button:hover:not(:disabled) { color: var(--el-text-color-primary); }
.header-tabs button.active {
background: var(--el-bg-color);
color: var(--el-color-primary);
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.header-tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
.chat-body { flex: 1; min-height: 0; }
.chat-body-inner { padding: 16px 20px 0; }
.typing-indicator {
+106 -2
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { useDeviceStore } from '@/stores/device'
import { useAuditStore } from '@/stores/remote'
import { ElMessage } from 'element-plus'
import type { NetInterface } from '@/api'
import type { NetInterface, AuditEntry } from '@/api'
const emit = defineEmits<{ (e: 'close'): void }>()
const device = useDeviceStore()
const audit = useAuditStore()
interface FormState {
deviceName: string
@@ -14,6 +16,11 @@ interface FormState {
sound: boolean
autoStart: boolean
theme: 'light' | 'dark'
remoteEnabled: boolean
forwardDefaultTtlSec: number
forwardMaxBytesPerSec: number
terminalReadOnlyByDefault: boolean
auditRetentionDays: number
}
const form = ref<FormState>({
@@ -23,6 +30,11 @@ const form = ref<FormState>({
sound: true,
autoStart: false,
theme: 'light',
remoteEnabled: true,
forwardDefaultTtlSec: 3600,
forwardMaxBytesPerSec: 10 * 1024 * 1024,
terminalReadOnlyByDefault: false,
auditRetentionDays: 30,
})
const loading = ref(true)
@@ -40,6 +52,7 @@ watch(() => device.settings, (s) => {
onMounted(async () => {
await loadIfaces()
await audit.refresh(100)
loading.value = false
})
@@ -52,6 +65,11 @@ function hydrate(s: any) {
sound: s.sound ?? true,
autoStart: s.autoStart ?? false,
theme: s.theme ?? 'light',
remoteEnabled: s.remoteEnabled ?? true,
forwardDefaultTtlSec: s.forwardDefaultTtlSec ?? 3600,
forwardMaxBytesPerSec: s.forwardMaxBytesPerSec ?? 10 * 1024 * 1024,
terminalReadOnlyByDefault: s.terminalReadOnlyByDefault ?? false,
auditRetentionDays: s.auditRetentionDays ?? 30,
}
hydrating.value = false
}
@@ -84,6 +102,11 @@ async function save() {
sound: form.value.sound,
theme: form.value.theme,
autoStart: form.value.autoStart,
remoteEnabled: form.value.remoteEnabled,
forwardDefaultTtlSec: form.value.forwardDefaultTtlSec,
forwardMaxBytesPerSec: form.value.forwardMaxBytesPerSec,
terminalReadOnlyByDefault: form.value.terminalReadOnlyByDefault,
auditRetentionDays: form.value.auditRetentionDays,
})
device.settings = r
dirty.value = false
@@ -95,7 +118,30 @@ async function save() {
}
}
const formLabelWidth = '90px'
async function pruneAudit() {
const n = await audit.prune()
ElMessage.success(`已清理 ${n} 条审计`)
await audit.refresh(100)
}
function fmtTime(ts: number) {
return new Date(ts).toLocaleString()
}
function actionLabel(a: string) {
return ({
'terminal.open': '终端 打开',
'terminal.exit': '终端 退出',
'terminal.close': '终端 关闭',
'forward.open': '转发 开启',
'forward.close': '转发 关闭',
'usb.list': 'USB 列表',
'usb.attach': 'USB 附加',
'usb.detach': 'USB 分离',
} as Record<string, string>)[a] || a
}
const formLabelWidth = '110px'
const externalIfaces = computed(() => ifaces.value.filter(i => !i.internal))
const currentAddress = computed(() => device.self?.address || '-')
@@ -147,6 +193,64 @@ const currentAddress = computed(() => device.self?.address || '-')
</el-form-item>
</el-form>
<el-divider content-position="left">远程 (终端 / 转发 / USB)</el-divider>
<el-form label-position="left" :label-width="formLabelWidth">
<el-form-item label="启用远程">
<el-switch v-model="form.remoteEnabled" />
<span class="form-hint">关闭后所有远程请求一律拒绝 (对方仍可尝试连接)</span>
</el-form-item>
<el-form-item label="默认 TTL">
<el-input-number v-model="form.forwardDefaultTtlSec" :min="30" :max="86400" />
<span class="form-hint">, 默认 1h, 最大 24h</span>
</el-form-item>
<el-form-item label="速率上限">
<el-input-number v-model="form.forwardMaxBytesPerSec" :min="0" :step="1024 * 1024" />
<span class="form-hint">bytes/s, 0 = 不限</span>
</el-form-item>
<el-form-item label="终端默认只读">
<el-switch v-model="form.terminalReadOnlyByDefault" />
<span class="form-hint">默认对方只能看不能输入</span>
</el-form-item>
<el-form-item label="审计保留">
<el-input-number v-model="form.auditRetentionDays" :min="1" :max="365" />
<span class="form-hint">, 启动时自动清理</span>
</el-form-item>
</el-form>
<el-divider content-position="left">最近审计</el-divider>
<div class="audit-section">
<el-button link size="small" @click="audit.refresh(100)">刷新</el-button>
<el-button link size="small" type="danger" @click="pruneAudit">清理过期</el-button>
<el-table v-if="audit.entries.length" :data="audit.entries" size="small" max-height="260" stripe>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ fmtTime(row.ts) }}</template>
</el-table-column>
<el-table-column label="动作" width="120">
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column label="来源" width="180">
<template #default="{ row }">
<code>{{ (row.source_device || '').slice(0, 8) }}</code>
</template>
</el-table-column>
<el-table-column label="目标" width="180">
<template #default="{ row }">
<code>{{ (row.target_device || '').slice(0, 8) }}</code>
</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.result === 'ok' ? 'success' : row.result === 'denied' ? 'danger' : 'info'" size="small">
{{ row.result }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无审计记录" :image-size="60" />
</div>
<el-divider content-position="left">网络</el-divider>
<div class="net-section">
+1 -1
View File
@@ -12,7 +12,7 @@ import type { DeviceView } from '@/api'
const device = useDeviceStore()
const session = useSessionStore()
const message = useMessageStore()
defineEmits<{ (e: 'open-settings'): void }>()
const emit = defineEmits<{ (e: 'open-settings'): void }>()
const self = computed(() => device.self)