This commit is contained in:
eric
2026-03-24 12:55:52 -05:00
parent 9e812215d2
commit 6ceadb7e0d
33 changed files with 1941 additions and 39 deletions

View File

@@ -8,6 +8,14 @@ export default defineConfig({
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
output: {
format: 'cjs',
entryFileNames: 'index.js',
},
},
},
},
renderer: {
resolve: {

View File

@@ -6,20 +6,24 @@
"main": "./out/main/index.js",
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"prepare-easytier": "node scripts/download-easytier.mjs",
"dev": "node scripts/download-easytier.mjs && electron-vite dev",
"build": "node scripts/download-easytier.mjs && electron-vite build",
"preview": "electron-vite preview",
"dist": "electron-vite build && electron-builder --win --publish never"
"dist": "pnpm run build && electron-builder --win --publish never"
},
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"adm-zip": "^0.5.16",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
@@ -45,6 +49,11 @@
"package.json"
],
"extraResources": [
{
"from": "resources/easytier",
"to": "easytier",
"filter": ["**/*"]
},
{
"from": "..",
"to": "backend",

162
electron/pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.3.1
version: 18.3.1
@@ -18,6 +21,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.19.15
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^18.3.12
version: 18.3.28
@@ -27,6 +33,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@5.4.21(@types/node@22.19.15))
adm-zip:
specifier: ^0.5.16
version: 0.5.16
electron:
specifier: ^33.2.0
version: 33.4.11
@@ -535,6 +544,9 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -565,6 +577,10 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -739,6 +755,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
@@ -773,6 +793,9 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -857,6 +880,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -890,6 +917,9 @@ packages:
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@@ -1027,6 +1057,10 @@ packages:
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -1296,6 +1330,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -1506,17 +1544,33 @@ packages:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@@ -1543,6 +1597,10 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1573,6 +1631,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@@ -1608,6 +1671,9 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resedit@1.7.2:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
@@ -1868,6 +1934,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1876,6 +1945,10 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1891,6 +1964,9 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -1901,10 +1977,18 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -2389,6 +2473,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.15
'@types/react-dom@18.3.7(@types/react@18.3.28)':
dependencies:
'@types/react': 18.3.28
@@ -2426,6 +2514,8 @@ snapshots:
abbrev@1.1.1: {}
adm-zip@0.5.16: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -2688,6 +2778,8 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
camelcase@5.3.1: {}
caniuse-lite@1.0.30001781: {}
chalk@4.1.2:
@@ -2715,6 +2807,12 @@ snapshots:
string-width: 4.2.3
optional: true
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -2790,6 +2888,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -2823,6 +2923,8 @@ snapshots:
detect-node@2.1.0:
optional: true
dijkstrajs@1.0.3: {}
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.5
@@ -3028,6 +3130,11 @@ snapshots:
dependencies:
minimatch: 5.1.9
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -3335,6 +3442,10 @@ snapshots:
dependencies:
readable-stream: 2.3.8
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash.defaults@4.2.0: {}
lodash.difference@4.5.0: {}
@@ -3550,16 +3661,28 @@ snapshots:
p-cancelable@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@@ -3581,6 +3704,8 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
pngjs@5.0.0: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@@ -3605,6 +3730,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
quick-lru@5.1.1: {}
react-dom@18.3.1(react@18.3.1):
@@ -3647,6 +3778,8 @@ snapshots:
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resedit@1.7.2:
dependencies:
pe-library: 0.4.1
@@ -3916,6 +4049,8 @@ snapshots:
dependencies:
defaults: 1.0.4
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -3924,6 +4059,12 @@ snapshots:
dependencies:
string-width: 4.2.3
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -3940,14 +4081,35 @@ snapshots:
xmlbuilder@15.1.1: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1

View File

@@ -0,0 +1 @@
2026-03-24T17:49:18.788Z

View File

@@ -0,0 +1,3 @@
EasyTier 以 LGPL-3.0 授权发布。
源码: https://github.com/EasyTier/EasyTier
本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,103 @@
/**
* 下载官方 EasyTier Windows x64 发行包并解压到 resources/easytier/
* 须复制 exe 同目录下全部 DLL 等依赖,否则 easytier-core 会以 0xC00001353221225781退出。
* 设置 SKIP_EASYTIER=1 可跳过CI 无网环境)
*/
import fs from 'fs'
import path from 'path'
import https from 'https'
import { fileURLToPath } from 'url'
import AdmZip from 'adm-zip'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const resDir = path.join(root, 'resources', 'easytier')
const DEFAULT_URL =
process.env.EASYTIER_DOWNLOAD_URL ||
'https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-windows-x86_64-v2.4.5.zip'
const exeName = 'easytier-core.exe'
const bundleMarker = '.easytier_bundle_ok'
if (process.env.SKIP_EASYTIER === '1') {
console.log('[easytier] SKIP_EASYTIER=1跳过下载')
process.exit(0)
}
function download(url) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
download(res.headers.location).then(resolve).catch(reject)
return
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} ${url}`))
return
}
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks)))
res.on('error', reject)
})
.on('error', reject)
})
}
function findExe(dir, name) {
if (!fs.existsSync(dir)) return null
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, f.name)
if (f.isDirectory()) {
const r = findExe(p, name)
if (r) return r
} else if (f.name === name) {
return p
}
}
return null
}
fs.mkdirSync(resDir, { recursive: true })
const outExe = path.join(resDir, exeName)
const markerPath = path.join(resDir, bundleMarker)
if (fs.existsSync(outExe) && fs.existsSync(markerPath)) {
console.log('[easytier] 已存在完整包,跳过:', outExe)
process.exit(0)
}
if (fs.existsSync(outExe) && !fs.existsSync(markerPath)) {
console.log('[easytier] 检测到旧版仅复制了 exe将重新下载并解压全部依赖文件…')
}
console.log('[easytier] 下载:', DEFAULT_URL)
const buf = await download(DEFAULT_URL)
const zip = new AdmZip(buf)
const tmp = path.join(resDir, '_extract_tmp')
if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true })
fs.mkdirSync(tmp, { recursive: true })
zip.extractAllTo(tmp, true)
const found = findExe(tmp, exeName)
if (!found) {
console.error('[easytier] zip 内未找到', exeName)
process.exit(1)
}
const srcDir = path.dirname(found)
for (const name of fs.readdirSync(srcDir)) {
const from = path.join(srcDir, name)
const to = path.join(resDir, name)
if (!fs.statSync(from).isFile()) continue
fs.copyFileSync(from, to)
}
fs.rmSync(tmp, { recursive: true })
fs.writeFileSync(
path.join(resDir, 'NOTICE-EasyTier.txt'),
[
'EasyTier 以 LGPL-3.0 授权发布。',
'源码: https://github.com/EasyTier/EasyTier',
'本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。',
'',
].join('\n'),
'utf-8',
)
fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf-8')
console.log('[easytier] 完成(已复制 zip 内同目录全部文件):', outExe)

View File

@@ -0,0 +1,158 @@
/**
* 内置 EasyTierWindows随应用分发 easytier-core.exe自动生成组网密钥无需用户单独安装。
* 二进制来源https://github.com/EasyTier/EasyTier LGPL-3.0
*/
import { app } from 'electron'
import { spawn, type ChildProcess } from 'child_process'
import crypto from 'crypto'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export type EasyTierNetworkConfig = {
networkName: string
networkSecret: string
peerUrls: string[]
}
let easytierChild: ChildProcess | null = null
let easytierLastError = ''
/** Windows 进程退出码(含 NTSTATUS可读说明 */
function formatWindowsExitHint(code: number): string {
const u = code >>> 0
if (u === 0xc0000135) {
return ' — 缺少 DLL常见官方 zip 内依赖未与 exe 同目录;开发环境请删除 electron/resources/easytier 后重新执行 pnpm run prepare-easytier。也可能是未安装 VC++ 运行库。'
}
if (u === 0xc000007b) {
return ' — 无效映像32/64 位与系统不符或文件损坏)。'
}
return ''
}
export function getBundledEasyTierExePath(): string | null {
if (process.platform !== 'win32') return null
const exe = 'easytier-core.exe'
if (app.isPackaged) {
const p = path.join(process.resourcesPath, 'easytier', exe)
return fs.existsSync(p) ? p : null
}
const dev = path.resolve(__dirname, '../../resources/easytier', exe)
return fs.existsSync(dev) ? dev : null
}
export function readEasyTierNetworkName(backendRoot: string): string {
const p = path.join(backendRoot, 'config', 'easytier_network.json')
if (!fs.existsSync(p)) return ''
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
return String(raw.networkName ?? '')
} catch {
return ''
}
}
export function loadOrCreateNetworkConfig(backendRoot: string): EasyTierNetworkConfig {
const p = path.join(backendRoot, 'config', 'easytier_network.json')
fs.mkdirSync(path.dirname(p), { recursive: true })
if (fs.existsSync(p)) {
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
return {
networkName: String(raw.networkName ?? ''),
networkSecret: String(raw.networkSecret ?? ''),
peerUrls: Array.isArray(raw.peerUrls) ? raw.peerUrls.map(String) : [],
}
} catch {
/* fallthrough */
}
}
const cfg: EasyTierNetworkConfig = {
networkName: `lr_${crypto.randomBytes(6).toString('hex')}`,
networkSecret: crypto.randomBytes(24).toString('hex'),
peerUrls: [],
}
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf-8')
return cfg
}
export function getEasyTierRuntimeState(): {
bundled: boolean
running: boolean
lastError: string
} {
const bundled = !!getBundledEasyTierExePath()
const running = !!easytierChild && !easytierChild.killed
return { bundled, running, lastError: easytierLastError }
}
export function startBundledEasyTier(backendRoot: string): void {
easytierLastError = ''
const exe = getBundledEasyTierExePath()
if (!exe) {
easytierLastError = '未找到内置 easytier-core.exe开发环境可执行 pnpm run prepare-easytier'
return
}
if (easytierChild && !easytierChild.killed) {
return
}
const cfg = loadOrCreateNetworkConfig(backendRoot)
const args = ['-d', '--network-name', cfg.networkName, '--network-secret', cfg.networkSecret]
for (const u of cfg.peerUrls) {
const t = u.trim()
if (t) args.push('-p', t)
}
let child: ChildProcess
try {
child = spawn(exe, args, {
cwd: path.dirname(exe),
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
})
} catch (e) {
easytierLastError = e instanceof Error ? e.message : String(e)
easytierChild = null
return
}
easytierChild = child
const appendErr = (chunk: Buffer): void => {
const s = chunk.toString()
if (s.length < 2000) easytierLastError = (easytierLastError + s).slice(-2000)
}
child.stderr?.on('data', appendErr)
child.stdout?.on('data', appendErr)
child.on('error', (err) => {
easytierLastError = err.message
})
child.on('close', (code) => {
if (code !== 0 && code !== null) {
const hint = formatWindowsExitHint(code)
easytierLastError = `easytier-core 已退出(代码 ${code}${hint}${easytierLastError ? `\n${easytierLastError}` : ''}`
}
easytierChild = null
})
}
export function stopBundledEasyTier(): void {
if (!easytierChild || easytierChild.killed) {
easytierChild = null
return
}
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/PID', String(easytierChild.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore',
})
} else {
easytierChild.kill('SIGTERM')
}
} catch {
/* ignore */
}
easytierChild = null
}

View File

@@ -1,19 +1,135 @@
import { app, BrowserWindow, dialog } from 'electron'
import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { execFile, spawn, ChildProcessWithoutNullStreams } from 'child_process'
import { promisify } from 'util'
import http from 'http'
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { networkInterfaces } from 'os'
import { fileURLToPath } from 'url'
import {
getEasyTierRuntimeState,
loadOrCreateNetworkConfig,
readEasyTierNetworkName,
startBundledEasyTier,
stopBundledEasyTier,
} from './easytier'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const execFileAsync = promisify(execFile)
const PORT = Number(process.env.WEB2_PORT || 8001)
const HOST = '127.0.0.1'
/** uvicorn 监听地址0.0.0.0 时建议配置 config/web2_api_token.txt 供远程/EasyTier 访问 */
let web2Host = '127.0.0.1'
let mainWindow: BrowserWindow | null = null
let pythonChild: ChildProcessWithoutNullStreams | null = null
let stderrBuf = ''
let _backendRoot: string | null = null
let _pythonExe: string | null = null
/**
* 供扫码绑定 baseUrl优先虚拟网卡EasyTier 等),其次 10.x再补充 WiFi/以太网等局域网 IPv4
* 避免仅有虚拟网时列表为空、用户无处选 IP。
*/
function collectEasyTierCandidateIPs(): { address: string; name: string }[] {
const nets = networkInterfaces()
const seen = new Set<string>()
const out: { address: string; name: string }[] = []
const nameMatch = /easytier|wintun|tun|tap|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx/i
for (const [name, addrs] of Object.entries(nets)) {
if (!addrs) continue
for (const a of addrs) {
if (a.family !== 'IPv4' || a.internal) continue
if (nameMatch.test(name) && !seen.has(a.address)) {
seen.add(a.address)
out.push({ address: a.address, name })
}
}
}
if (out.length === 0) {
for (const [name, addrs] of Object.entries(nets)) {
if (!addrs) continue
for (const a of addrs) {
if (a.family !== 'IPv4' || a.internal) continue
if (a.address.startsWith('10.') && !seen.has(a.address)) {
seen.add(a.address)
out.push({ address: a.address, name: `${name}10.x可能含 EasyTier` })
}
}
}
}
for (const [name, addrs] of Object.entries(nets)) {
if (!addrs) continue
for (const a of addrs) {
if (a.family !== 'IPv4' || a.internal) continue
if (a.address === '127.0.0.1') continue
if (seen.has(a.address)) continue
seen.add(a.address)
out.push({ address: a.address, name: `${name}(局域网)` })
}
}
return out
}
async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
const br = _backendRoot ?? getBackendRoot()
const py = _pythonExe ?? resolvePythonExe(br)
web2Host = resolveWeb2Host()
await killPythonProcessForRestart()
startPythonBackend(br, py)
try {
await waitForHealth(90000)
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
return { ok: true }
}
/**
* 切换监听地址 / 轮换 API 密钥时必须等旧 uvicorn 完全退出并释放端口。
* Windows 上若仅 spawn taskkill 不等待,旧进程仍占用 8001新进程启动失败但 /health 仍由旧进程响应,
* waitForHealth 误判成功,随后旧进程被杀死 → 前端 ERR_CONNECTION_REFUSED。
*/
async function killPythonProcessForRestart(): Promise<void> {
if (!pythonChild || pythonChild.killed) {
pythonChild = null
return
}
const child = pythonChild
const pid = child.pid
pythonChild = null
if (process.platform === 'win32') {
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
windowsHide: true,
timeout: 25000,
maxBuffer: 1024 * 1024,
})
} catch {
/* 进程已退出等 */
}
await new Promise((r) => setTimeout(r, 500))
} else {
try {
child.kill('SIGTERM')
} catch {
/* ignore */
}
await new Promise<void>((resolve) => {
const t = setTimeout(() => resolve(), 10000)
child.once('close', () => {
clearTimeout(t)
resolve()
})
})
await new Promise((r) => setTimeout(r, 200))
}
}
function getBackendRoot(): string {
if (app.isPackaged) {
@@ -23,6 +139,38 @@ function getBackendRoot(): string {
return path.resolve(__dirname, '../../..')
}
function resolveWeb2Host(): string {
const e = process.env.WEB2_HOST?.trim()
if (e) return e
const br = getBackendRoot()
const p = path.join(br, 'config', 'web2_bind_host.txt')
try {
if (fs.existsSync(p)) {
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
if (line) return line
}
} catch {
/* ignore */
}
return '127.0.0.1'
}
function resolveWeb2ApiTokenForChild(): string | undefined {
const e = process.env.WEB2_API_TOKEN?.trim()
if (e) return e
const br = getBackendRoot()
const p = path.join(br, 'config', 'web2_api_token.txt')
try {
if (fs.existsSync(p)) {
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
if (line) return line
}
} catch {
/* ignore */
}
return undefined
}
function resolvePythonExe(backendRoot: string): string {
if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) {
return process.env.PYTHON_EXE
@@ -68,6 +216,56 @@ function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
return env
}
/** 开发模式:等 Vite 监听后再 loadURL避免先打开窗口却加载失败导致黑屏 */
function waitForDevServer(devUrl: string, timeoutMs = 60000): Promise<void> {
return new Promise((resolve, reject) => {
let u: URL
try {
u = new URL(devUrl)
} catch {
reject(new Error(`无效的 ELECTRON_RENDERER_URL: ${devUrl}`))
return
}
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
const start = Date.now()
const tryOnce = (): void => {
if (Date.now() - start > timeoutMs) {
reject(
new Error(
`开发服务器在 ${timeoutMs / 1000}s 内未响应(${devUrl})。请先保持 pnpm dev / electron-vite dev 运行,或检查端口是否被占用。`,
),
)
return
}
const req = http.get(
{
hostname: u.hostname,
port,
path: '/',
timeout: 2500,
},
(res) => {
res.resume()
resolve()
},
)
req.on('error', () => {
req.destroy()
setTimeout(tryOnce, 350)
})
req.on('timeout', () => {
req.destroy()
setTimeout(tryOnce, 350)
})
}
tryOnce()
})
}
function healthCheckHost(): string {
return web2Host === '0.0.0.0' ? '127.0.0.1' : web2Host
}
function waitForHealth(timeoutMs = 120000): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now()
@@ -77,7 +275,7 @@ function waitForHealth(timeoutMs = 120000): Promise<void> {
reject(new Error(`本地服务在 ${timeoutMs / 1000} 秒内未就绪。\n\n${stderrTail()}`))
return
}
const req = http.get(`http://${HOST}:${PORT}/health`, (res) => {
const req = http.get(`http://${healthCheckHost()}:${PORT}/health`, (res) => {
res.resume()
if (res.statusCode === 200) {
clearInterval(iv)
@@ -93,13 +291,20 @@ function waitForHealth(timeoutMs = 120000): Promise<void> {
function startPythonBackend(backendRoot: string, pythonExe: string): void {
stderrBuf = ''
const apiTok = resolveWeb2ApiTokenForChild()
if (web2Host === '0.0.0.0' && !apiTok) {
console.warn(
'[直播录制助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txtAPI 无鉴权,请勿暴露到公网。',
)
}
const env = {
...buildChildEnv(backendRoot),
PYTHONUNBUFFERED: '1',
WEB2_SUPERVISOR: 'builtin',
PYTHONIOENCODING: 'utf-8',
...(apiTok ? { WEB2_API_TOKEN: apiTok } : {}),
}
const args = ['-m', 'uvicorn', 'web2:app', '--host', HOST, '--port', String(PORT)]
const args = ['-m', 'uvicorn', 'web2:app', '--host', web2Host, '--port', String(PORT)]
pythonChild = spawn(pythonExe, args, {
cwd: backendRoot,
env,
@@ -141,13 +346,15 @@ function killPythonTree(): void {
function resolvePreload(): string {
const base = path.join(__dirname, '../preload')
const mjs = path.join(base, 'index.mjs')
const js = path.join(base, 'index.js')
const mjs = path.join(base, 'index.mjs')
// 构建产物为 CJS 的 index.js若目录里残留旧版 index.mjs须优先使用 index.js
if (fs.existsSync(js)) return js
if (fs.existsSync(mjs)) return mjs
return js
}
function createWindow(): void {
async function createWindow(): Promise<void> {
mainWindow = new BrowserWindow({
width: 1020,
height: 720,
@@ -167,7 +374,43 @@ function createWindow(): void {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
mainWindow.loadURL(devUrl)
try {
await waitForDevServer(devUrl)
} catch (e) {
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
mainWindow.destroy()
mainWindow = null
return
}
mainWindow.webContents.once('did-finish-load', () => {
if (process.env.NODE_ENV === 'development' || devUrl.includes('localhost')) {
mainWindow?.webContents.openDevTools({ mode: 'detach' })
}
})
try {
for (let i = 0; i < 8; i++) {
try {
await mainWindow.loadURL(devUrl)
break
} catch {
if (i === 7) {
dialog.showErrorBox(
'直播录制助手',
`无法加载开发页面:${devUrl}\n请确认 Vite 已启动且端口未被占用。`,
)
mainWindow.destroy()
mainWindow = null
return
}
await new Promise((r) => setTimeout(r, 400))
}
}
} catch (e) {
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
mainWindow.destroy()
mainWindow = null
return
}
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
}
@@ -189,8 +432,95 @@ if (!gotLock) {
})
app.whenReady().then(async () => {
ipcMain.removeHandler('open-external')
ipcMain.handle('open-external', async (_evt, url: unknown) => {
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
throw new Error('无效的链接地址')
}
await shell.openExternal(url)
})
ipcMain.removeHandler('web2:get-api-token')
ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null)
ipcMain.removeHandler('remote:get-status')
ipcMain.handle('remote:get-status', () => {
const bindHost = resolveWeb2Host()
const et = getEasyTierRuntimeState()
const br = _backendRoot ?? getBackendRoot()
const easytierNetworkName = readEasyTierNetworkName(br)
return {
bindHost,
remoteEnabled: bindHost === '0.0.0.0',
port: PORT,
hasToken: !!resolveWeb2ApiTokenForChild(),
candidates: collectEasyTierCandidateIPs(),
envHostOverride: !!process.env.WEB2_HOST?.trim(),
easytierBundled: et.bundled,
easytierRunning: et.running,
easytierLastError: et.lastError,
easytierNetworkName,
}
})
ipcMain.removeHandler('remote:get-bind-payload')
ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => {
if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null
const br = _backendRoot ?? getBackendRoot()
const cfg = loadOrCreateNetworkConfig(br)
const token = resolveWeb2ApiTokenForChild() ?? ''
const ip = selectedIp.trim()
return {
v: 1 as const,
baseUrl: `http://${ip}:${PORT}`,
port: PORT,
token,
easytier: {
networkName: cfg.networkName,
networkSecret: cfg.networkSecret,
peerUrls: cfg.peerUrls,
},
}
})
ipcMain.removeHandler('remote:set-enabled')
ipcMain.handle('remote:set-enabled', async (_evt, enabled: unknown) => {
if (process.env.WEB2_HOST?.trim()) {
return {
ok: false,
error: '已设置环境变量 WEB2_HOST请改用环境变量或删除后再用界面开关。',
}
}
if (typeof enabled !== 'boolean') {
return { ok: false, error: '参数无效' }
}
const br = _backendRoot ?? getBackendRoot()
const cfgDir = path.join(br, 'config')
fs.mkdirSync(cfgDir, { recursive: true })
fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8')
const r = await restartWeb2Backend()
if (r.ok && enabled) startBundledEasyTier(br)
return r
})
ipcMain.removeHandler('remote:generate-token')
ipcMain.handle('remote:generate-token', async () => {
const br = _backendRoot ?? getBackendRoot()
const cfgDir = path.join(br, 'config')
fs.mkdirSync(cfgDir, { recursive: true })
const tok = crypto.randomBytes(24).toString('hex')
fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8')
const r = await restartWeb2Backend()
if (r.ok) startBundledEasyTier(br)
return r
})
web2Host = resolveWeb2Host()
const backendRoot = getBackendRoot()
const pythonExe = resolvePythonExe(backendRoot)
_backendRoot = backendRoot
_pythonExe = pythonExe
if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) {
dialog.showErrorBox(
@@ -215,19 +545,23 @@ if (!gotLock) {
return
}
createWindow()
startBundledEasyTier(backendRoot)
await createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
})
})
app.on('window-all-closed', () => {
stopBundledEasyTier()
killPythonTree()
app.quit()
})
app.on('before-quit', () => {
stopBundledEasyTier()
killPythonTree()
})
}

View File

@@ -1,5 +1,18 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('recorderConsole', {
platform: process.platform,
/** 沙箱下 preload 无可靠 shell经主进程打开系统浏览器 */
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
})
contextBridge.exposeInMainWorld('web2Remote', {
getApiToken: (): Promise<string | null> => ipcRenderer.invoke('web2:get-api-token'),
})
contextBridge.exposeInMainWorld('remoteBinding', {
getStatus: () => ipcRenderer.invoke('remote:get-status'),
setRemoteEnabled: (enabled: boolean) => ipcRenderer.invoke('remote:set-enabled', enabled),
generateToken: () => ipcRenderer.invoke('remote:generate-token'),
getBindPayload: (selectedIp: string) => ipcRenderer.invoke('remote:get-bind-payload', selectedIp),
})

View File

@@ -1179,6 +1179,153 @@ body.ready {
}
/* —— 录制代理 + 系统指标 —— */
.app-panel--network {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card--remote {
border-color: rgba(100, 149, 237, 0.28);
background: linear-gradient(165deg, rgba(100, 149, 237, 0.07) 0%, var(--bg-card) 100%);
}
.remote-link {
color: var(--accent, #6d9eff);
text-decoration: underline;
text-underline-offset: 2px;
}
.remote-code {
font-family: ui-monospace, monospace;
font-size: 12px;
color: #b8c0cc;
}
.remote-et-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
}
.remote-et-pill {
font-size: 12px;
padding: 0.2rem 0.55rem;
border-radius: 999px;
}
.remote-et-pill.on {
background: rgba(61, 214, 140, 0.15);
color: #9ee6c0;
}
.remote-et-pill.off {
background: rgba(139, 146, 158, 0.15);
color: #8b929e;
}
.remote-et-pill.warn {
background: rgba(245, 166, 35, 0.15);
color: #e8c07d;
}
.remote-et-meta {
font-size: 12px;
color: var(--text-muted, #8b929e);
}
.remote-warn {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
background: rgba(245, 166, 35, 0.12);
border: 1px solid rgba(245, 166, 35, 0.35);
color: #e8c07d;
font-size: 13px;
}
.remote-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.remote-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.remote-toggle-input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--accent, #6d9eff);
}
.remote-token-badge {
font-size: 13px;
padding: 0.2rem 0.6rem;
border-radius: 999px;
}
.remote-token-badge.on {
background: rgba(61, 214, 140, 0.15);
color: #9ee6c0;
}
.remote-token-badge.off {
background: rgba(245, 101, 101, 0.12);
color: #f0a0a0;
}
.remote-qr-block {
margin-top: 0.5rem;
padding: 1rem;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.remote-qr-hint {
margin: 0;
font-size: 13px;
color: var(--text-muted, #8b929e);
}
.remote-qr-img {
border-radius: 8px;
background: #fff;
padding: 8px;
}
.remote-qr-json {
margin: 0;
padding: 0.75rem;
font-size: 12px;
line-height: 1.45;
max-width: 100%;
overflow: auto;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
color: #c8cdd5;
}
.remote-error {
margin: 0.5rem 0 0;
color: #f56565;
font-size: 13px;
}
.card--proxy {
border-color: rgba(61, 214, 140, 0.22);
background: linear-gradient(165deg, rgba(61, 214, 140, 0.06) 0%, var(--bg-card) 100%);
@@ -1228,6 +1375,39 @@ body.ready {
background: linear-gradient(165deg, rgba(124, 92, 255, 0.07) 0%, var(--bg-card) 100%);
}
/* YouTube OAuth 行 */
.yt-oauth-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: rgba(91, 157, 255, 0.06);
border: 1px solid rgba(91, 157, 255, 0.15);
}
.yt-oauth-badge {
font-size: 12px;
font-weight: 650;
padding: 4px 10px;
border-radius: var(--radius-pill);
margin-right: 4px;
}
.yt-oauth-badge--on {
color: var(--success);
background: var(--success-soft);
border: 1px solid rgba(61, 214, 140, 0.35);
}
.yt-oauth-badge--off {
color: var(--text-muted);
background: var(--bg-card-muted);
border: 1px solid var(--border-card);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
import NetworkDashboard from './NetworkDashboard'
import RemoteBinding from './RemoteBinding'
import ProxySettings from './ProxySettings'
import SystemMetrics from './SystemMetrics'
import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants'
import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH } from './constants'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
type Entry = { script: string; label: string; pm2: string }
@@ -61,6 +62,9 @@ export default function App() {
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
const [ytOauth, setYtOauth] = useState<{ connected: boolean; has_refresh: boolean } | null>(null)
const [fetchKeyLoading, setFetchKeyLoading] = useState(false)
const [nav, setNav] = useState<NavId>('record')
const logRef = useRef<HTMLPreElement>(null)
@@ -86,7 +90,7 @@ export default function App() {
const isObs = ent ? isObsScript(script) : false
const loadProcessMonitor = useCallback(async () => {
const res = await fetch(apiUrl('/process_monitor'))
const res = await apiFetch(apiUrl('/process_monitor'))
if (!res.ok) throw new Error(String(res.status))
const data = await res.json()
const raw: Entry[] = (data.entries || []).map(
@@ -105,7 +109,7 @@ export default function App() {
async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => {
setSt('正在读取…')
try {
const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`)
const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`)
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
const data = await res.json()
setContent(data.content || '')
@@ -117,6 +121,61 @@ export default function App() {
[currentProcess],
)
const loadYtOauthStatus = useCallback(async () => {
try {
const res = await apiFetch(apiUrl('/youtube/oauth/status'))
if (!res.ok) throw new Error(String(res.status))
const data = (await res.json()) as {
connected?: boolean
has_refresh?: boolean
}
setYtOauth({
connected: !!data.connected,
has_refresh: !!data.has_refresh,
})
} catch {
setYtOauth(null)
}
}, [])
const startYoutubeOAuth = useCallback(async () => {
setYoutubeStatus('正在获取授权地址…')
try {
const res = await apiFetch(apiUrl('/youtube/oauth/start'))
const data = (await res.json()) as { url?: string; error?: string }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
const url = data.url
if (!url) throw new Error('未返回授权地址')
const open = window.recorderConsole?.openExternal
if (open) {
await open(url)
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
setYoutubeStatus('请在浏览器中完成授权')
} catch (err) {
setYoutubeStatus(`授权失败:${err instanceof Error ? err.message : String(err)}`)
}
}, [])
const fetchYoutubeStreamKey = useCallback(async () => {
setFetchKeyLoading(true)
try {
const res = await apiFetch(
`${apiUrl('/youtube/stream/fetch_key')}?process=${encodeURIComponent(currentProcess)}`,
{ method: 'POST' },
)
const data = (await res.json()) as { error?: string; message?: string }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
if (data.message) setYoutubeStatus(data.message)
await loadConfig('/get_config', setYoutubeText, setYoutubeStatus)
} catch (err) {
setYoutubeStatus(`拉取失败:${err instanceof Error ? err.message : String(err)}`)
} finally {
setFetchKeyLoading(false)
}
}, [currentProcess, loadConfig])
const saveConfig = useCallback(
async (
endpoint: string,
@@ -126,7 +185,7 @@ export default function App() {
) => {
setSt('正在保存…')
try {
const res = await fetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, {
const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
@@ -148,7 +207,7 @@ export default function App() {
const isStatus = action === 'status'
if (!isStatus) setLoadingAction(action)
try {
const res = await fetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`)
const res = await apiFetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(currentProcess)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
const data = await res.json()
@@ -257,6 +316,13 @@ export default function App() {
if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus)
}, [ent, currentProcess, isYoutube, isTiktok, loadConfig])
useEffect(() => {
if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return
void loadYtOauthStatus()
const id = window.setInterval(() => void loadYtOauthStatus(), 5000)
return () => clearInterval(id)
}, [isYoutube, loadYtOauthStatus])
const onProcessChange = (v: string) => {
setCurrentProcess(v)
}
@@ -340,7 +406,8 @@ export default function App() {
<div className="app-workspace__scroll">
{nav === 'network' && (
<div className="app-panel">
<div className="app-panel app-panel--network">
<RemoteBinding />
<NetworkDashboard />
</div>
)}
@@ -410,9 +477,39 @@ export default function App() {
{isYoutube && (
<section className="card">
<h2 className="card__title">YouTube </h2>
<p className="card__desc"></p>
<p className="card__desc">
{UI_SHOW_YOUTUBE_GOOGLE_OAUTH ? (
<>
Google key=使 Google Cloud
OAuth URIhttp://127.0.0.1:8001/youtube/oauth/callback
</>
) : (
<> key=</>
)}
</p>
{UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
<div className="yt-oauth-bar">
<span
className={`yt-oauth-badge ${ytOauth?.connected ? 'yt-oauth-badge--on' : 'yt-oauth-badge--off'}`}
>
{ytOauth?.connected ? '已连接 Google' : '未连接 Google'}
</span>
<button type="button" className="btn btn-secondary" onClick={() => void startYoutubeOAuth()}>
Google
</button>
<button
type="button"
className={`btn btn-primary ${fetchKeyLoading ? 'loading' : ''}`}
disabled={!ytOauth?.connected || fetchKeyLoading}
title={!ytOauth?.connected ? '请先完成 Google 授权' : ''}
onClick={() => void fetchYoutubeStreamKey()}
>
{fetchKeyLoading ? '拉取中…' : '拉取串流密钥并写入配置'}
</button>
</div>
)}
<div className="card__field">
<span className="card__field-label"></span>
<span className="card__field-label">youtube.ini</span>
<textarea
id="youtubeConfig"
className="input-area"

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type SiteInfo = {
host: string
@@ -75,7 +75,7 @@ export default function NetworkDashboard() {
setLoading(true)
setErr(null)
try {
const res = await fetch(apiUrl('/network_status'))
const res = await apiFetch(apiUrl('/network_status'))
const json = (await res.json()) as NetworkPayload & { error?: string }
if (!res.ok) {
setErr(json.error || `请求失败 ${res.status}`)

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type ProxyCfg = { enabled: boolean; http: string; socks: string }
@@ -13,7 +13,7 @@ export default function ProxySettings() {
setLoading(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'))
const res = await apiFetch(apiUrl('/proxy_config'))
const data = (await res.json()) as ProxyCfg
if (!res.ok) throw new Error('读取失败')
setCfg({
@@ -36,7 +36,7 @@ export default function ProxySettings() {
setSaving(true)
setMsg('')
try {
const res = await fetch(apiUrl('/proxy_config'), {
const res = await apiFetch(apiUrl('/proxy_config'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),

View File

@@ -0,0 +1,345 @@
import { useCallback, useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { initApiFromMain } from './api'
type RemoteStatus = {
bindHost: string
remoteEnabled: boolean
port: number
hasToken: boolean
candidates: { address: string; name: string }[]
envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
}
type RemoteBindingApi = {
getStatus: () => Promise<RemoteStatus>
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
}
/** React Native 扫码JSON 含 baseUrl、token、easytier组网名/密钥/可选 peer */
export default function RemoteBinding() {
const rb = (typeof window !== 'undefined'
? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
: undefined) as RemoteBindingApi | undefined
const [status, setStatus] = useState<RemoteStatus | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [selectedIp, setSelectedIp] = useState('')
const [manualHost, setManualHost] = useState('')
const [qrImg, setQrImg] = useState<string | null>(null)
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null)
const pickBindIp = useCallback((): string => {
if (!status?.candidates?.length) return manualHost.trim()
if (selectedIp.trim()) return selectedIp.trim()
return manualHost.trim()
}, [status?.candidates?.length, selectedIp, manualHost])
const load = useCallback(async () => {
if (!rb?.getStatus) {
setLoading(false)
setMsg('仅 Electron 桌面端支持此面板;网页调试请直接配置 API。')
return
}
setLoading(true)
try {
const s = await rb.getStatus()
setStatus(s)
setMsg('')
} catch (e) {
setStatus(null)
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [rb])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!status?.candidates?.length) return
setSelectedIp((prev) => {
if (prev && status.candidates.some((c) => c.address === prev)) return prev
return status.candidates[0].address
})
}, [status])
useEffect(() => {
if (!status?.remoteEnabled || !rb?.getBindPayload) {
setBindPayload(null)
return
}
const ip = pickBindIp()
if (!ip) {
setBindPayload(null)
return
}
let cancelled = false
void rb.getBindPayload(ip).then((p) => {
if (!cancelled) setBindPayload(p)
})
return () => {
cancelled = true
}
}, [status?.remoteEnabled, rb, status?.hasToken, pickBindIp])
useEffect(() => {
if (!bindPayload) {
setQrImg(null)
return
}
const json = JSON.stringify(bindPayload)
void QRCode.toDataURL(json, { margin: 2, width: 224 }).then(setQrImg).catch(() => setQrImg(null))
}, [bindPayload])
const onToggle = async (enabled: boolean) => {
if (!rb?.setRemoteEnabled) return
setBusy(true)
setMsg('')
try {
const r = await rb.setRemoteEnabled(enabled)
if (!r.ok) {
setMsg(r.error)
return
}
await initApiFromMain()
await load()
} finally {
setBusy(false)
}
}
const onGenerateToken = async () => {
if (!rb?.generateToken) return
setBusy(true)
setMsg('')
try {
const r = await rb.generateToken()
if (!r.ok) {
setMsg(r.error)
return
}
await initApiFromMain()
await load()
} finally {
setBusy(false)
}
}
const onGenerateQr = async () => {
if (!rb?.getBindPayload) return
if (!status?.remoteEnabled) {
setMsg('请先勾选「允许局域网 / 虚拟网访问」并等待后端重启完成。')
return
}
const ip = pickBindIp()
if (!ip) {
const hasList = !!status.candidates?.length
setMsg(
hasList
? '请从上方下拉选择本机 IP或在「手动 IP」中填写手机能访问的地址。'
: '未检测到本机局域网 IP请在下方「手动 IP」中输入例如 192.168.x.x 或与手机同网段地址、EasyTier 虚拟 IP或点「刷新 IP 列表」后重试。',
)
return
}
setBusy(true)
setMsg('')
try {
const p = await rb.getBindPayload(ip)
setBindPayload(p)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
if (loading) {
return (
<section className="card card--remote" aria-busy>
<h2 className="card__title"></h2>
<p className="card__desc"></p>
</section>
)
}
if (!rb?.getStatus) {
return (
<section className="card card--remote">
<h2 className="card__title"></h2>
<p className="card__desc">{msg || '不可用'}</p>
</section>
)
}
if (!status) {
return (
<section className="card card--remote">
<h2 className="card__title"></h2>
<p className="remote-error" role="alert">
{msg || '无法加载状态'}
</p>
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
</button>
</section>
)
}
return (
<section className="card card--remote" aria-labelledby="remote-binding-title">
<h2 className="card__title" id="remote-binding-title">
</h2>
<p className="card__desc">
Windows {' '}
<a href="https://github.com/EasyTier/EasyTier" target="_blank" rel="noreferrer" className="remote-link">
EasyTier
</a>{' '}
App 使 EasyTier JSON {' '}
<code className="remote-code">easytier</code> 0.0.0.0 API
</p>
<div className="remote-et-status" role="status">
<span className={`remote-et-pill ${status.easytierBundled ? 'on' : 'off'}`}>
{status.easytierBundled ? '已内置 easytier-core' : '未找到内置程序(开发环境请执行 pnpm run prepare-easytier'}
</span>
{status.easytierBundled && (
<span className={`remote-et-pill ${status.easytierRunning ? 'on' : 'warn'}`}>
{status.easytierRunning ? 'EasyTier 进程运行中' : '进程未运行或已退出'}
</span>
)}
{!!status.easytierNetworkName && (
<span className="remote-et-meta">{status.easytierNetworkName}</span>
)}
</div>
{status.easytierBundled && status.easytierLastError && (
<p className="remote-warn" role="status">
EasyTier{status.easytierLastError}
</p>
)}
{status?.envHostOverride && (
<p className="remote-warn" role="status">
WEB2_HOST
</p>
)}
<div className="remote-row">
<label className="remote-toggle-label">
<input
type="checkbox"
className="remote-toggle-input"
checked={!!status?.remoteEnabled}
disabled={busy || !!status?.envHostOverride}
onChange={(e) => void onToggle(e.target.checked)}
/>
<span> / 访 0.0.0.0</span>
</label>
</div>
<div className="remote-row">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void onGenerateToken()}>
API
</button>
<span className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}>
{status?.hasToken ? '已配置密钥' : '未配置密钥(远程时强烈建议)'}
</span>
</div>
<div className="remote-row">
<label className="field-label" htmlFor="remoteEtIp">
IP /
</label>
<select
id="remoteEtIp"
className="select-process"
value={selectedIp}
onChange={(e) => setSelectedIp(e.target.value)}
disabled={!status?.candidates?.length}
>
{(status?.candidates?.length
? status.candidates
: [{ address: '', name: '(暂无候选,请用下方手动 IP 或点刷新)' }]
).map((c) => (
<option key={c.address || 'none'} value={c.address} disabled={!c.address}>
{c.address ? `${c.address}${c.name}` : c.name}
</option>
))}
</select>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void load()}>
IP
</button>
</div>
<div className="remote-row">
<label className="field-label" htmlFor="remoteManualIp">
IP
</label>
<input
id="remoteManualIp"
type="text"
className="select-process"
placeholder="例如 192.168.1.10、10.126.126.1"
value={manualHost}
onChange={(e) => setManualHost(e.target.value)}
disabled={busy}
autoComplete="off"
/>
</div>
<div className="remote-row">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void onGenerateQr()}>
</button>
<span className="remote-qr-hint"> React Native 访 IP</span>
</div>
{!status.remoteEnabled && (
<p className="remote-qr-hint">访 API </p>
)}
{status.remoteEnabled && bindPayload && qrImg && (
<div className="remote-qr-block">
<p className="remote-qr-hint">
React Native JSON <code className="remote-code">baseUrl</code><code className="remote-code">token</code>
<code className="remote-code">easytier</code>使
</p>
<img src={qrImg} alt="绑定信息二维码" className="remote-qr-img" width={224} height={224} />
<pre className="remote-qr-json" tabIndex={0}>
{JSON.stringify(bindPayload, null, 2)}
</pre>
<button
type="button"
className="btn btn-secondary"
onClick={() => void navigator.clipboard.writeText(JSON.stringify(bindPayload))}
>
JSON
</button>
</div>
)}
{status.remoteEnabled && !bindPayload && (
<p className="remote-qr-hint">
IP IP IP
</p>
)}
{msg && (
<p className="remote-error" role="alert">
{msg}
</p>
)}
</section>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { apiUrl } from './api'
import { apiFetch, apiUrl } from './api'
type Metrics = {
ts?: number
@@ -32,7 +32,7 @@ export default function SystemMetrics() {
const load = useCallback(async () => {
try {
const res = await fetch(apiUrl('/system_metrics'))
const res = await apiFetch(apiUrl('/system_metrics'))
const json = (await res.json()) as Metrics
if (!res.ok) {
setErr(json.error || `HTTP ${res.status}`)

View File

@@ -1,6 +1,44 @@
const base = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001'
/** 由 Electron 主进程从 config/web2_api_token.txt 注入;无则走 VITE / localStorage */
let bearerFromMain: string | null = null
export function apiUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
return `${base.replace(/\/$/, '')}${p}`
}
function getBearerToken(): string | null {
if (bearerFromMain) return bearerFromMain
const v = import.meta.env.VITE_WEB2_API_TOKEN
if (v && String(v).trim()) return String(v).trim()
try {
return localStorage.getItem('web2_api_token')
} catch {
return null
}
}
export async function initApiFromMain(): Promise<void> {
bearerFromMain = null
try {
const w = (window as Window & { web2Remote?: { getApiToken?: () => Promise<string | null> } }).web2Remote
const t = await w?.getApiToken?.()
if (t && String(t).trim()) bearerFromMain = String(t).trim()
} catch {
/* 非 Electron 或 preload 未注入 */
}
if (!bearerFromMain) {
const v = import.meta.env.VITE_WEB2_API_TOKEN
if (v && String(v).trim()) bearerFromMain = String(v).trim()
}
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers)
const token = getBearerToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
}

View File

@@ -1,2 +1,5 @@
/** 为 true 时:下拉只展示 youtube*.py */
export const UI_DROPDOWN_YOUTUBE_ONLY = true
/** 为 true 时YouTube 卡片显示「Google 授权 / 拉取串流密钥」false 仅保留手动编辑配置(后端路由仍保留) */
export const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { initApiFromMain } from './api'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
void initApiFromMain().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
})

View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_WEB2_API_TOKEN?: string
}
interface ImportMeta {
@@ -11,5 +12,26 @@ interface ImportMeta {
interface Window {
recorderConsole?: {
platform: string
openExternal?: (url: string) => Promise<void>
}
web2Remote?: {
getApiToken?: () => Promise<string | null>
}
remoteBinding?: {
getStatus: () => Promise<{
bindHost: string
remoteEnabled: boolean
port: number
hasToken: boolean
candidates: { address: string; name: string }[]
envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
}>
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
}
}