Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled
@@ -0,0 +1,8 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Build Windows Portable Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
portable:
|
||||||
|
# Configure the Gitea runner with the `windows:host` label.
|
||||||
|
runs-on: windows
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
steps:
|
||||||
|
- name: Checkout source and pinned n2n dependency
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Build and test
|
||||||
|
run: .\scripts\build.ps1 -Clean
|
||||||
|
|
||||||
|
- name: Upload portable package
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: super-n2n-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
release/super-n2n-portable.zip
|
||||||
|
release/super-n2n-portable.zip.sha256
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# JavaScript dependencies and build artifacts
|
||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Rust and Tauri generated output
|
||||||
|
/src-tauri/target/
|
||||||
|
/src-tauri/gen/
|
||||||
|
/src-tauri/.tauri/
|
||||||
|
|
||||||
|
# Locally assembled distributable packages
|
||||||
|
/release/
|
||||||
|
|
||||||
|
# Local environment files (keep examples in source control)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs and package-manager diagnostics
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor and operating-system metadata
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userossc
|
||||||
|
*.sln.docstates
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "n2n"]
|
||||||
|
path = third_party/n2n
|
||||||
|
url = https://github.com/ntop/n2n.git
|
||||||
|
ignore = untracked
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Super N2N
|
||||||
|
|
||||||
|
Super N2N is a Tauri 2 desktop control plane. Its pinned n2n dependency is
|
||||||
|
stored as the `third_party/n2n` Git submodule.
|
||||||
|
|
||||||
|
## Reproducible Windows build
|
||||||
|
|
||||||
|
Clone the project together with its fixed n2n source revision:
|
||||||
|
|
||||||
|
git clone --recurse-submodules https://git.code.cq.cn/chun_qiu/supern2n.git
|
||||||
|
cd supern2n
|
||||||
|
|
||||||
|
The only supported portable-package build entry point is:
|
||||||
|
|
||||||
|
npm run build:portable
|
||||||
|
|
||||||
|
Or call the same script directly when a clean output is required:
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1 -Clean
|
||||||
|
|
||||||
|
The script initializes submodules, compiles n2n, installs frontend dependencies
|
||||||
|
with `npm ci`, runs Rust tests, creates the Tauri portable package, and writes a
|
||||||
|
SHA-256 checksum beside it. Outputs are placed in `release/`.
|
||||||
|
|
||||||
|
The Windows build host needs Node.js, Rust with the MSVC toolchain, Git for
|
||||||
|
Windows (Bash), and MinGW-w64 tools providing `gcc`, `windres`, and
|
||||||
|
`mingw32-make`.
|
||||||
|
|
||||||
|
## Frontend development
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
Start the Vite preview:
|
||||||
|
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
The current preview URL is http://127.0.0.1:1420/.
|
||||||
|
|
||||||
|
## Native desktop development
|
||||||
|
|
||||||
|
Install dependencies, then start the Tauri development window:
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run tauri dev
|
||||||
|
|
||||||
|
The portable build creates `release/super-n2n-portable.zip`. It contains
|
||||||
|
`n2n/edge.exe` and `n2n/supernode.exe`; on Windows Super N2N starts them with
|
||||||
|
the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`.
|
||||||
|
|
||||||
|
## Gitea Actions
|
||||||
|
|
||||||
|
`.gitea/workflows/build-windows.yml` runs on every push and uploads the portable
|
||||||
|
zip plus its checksum as workflow artifacts. Configure a dedicated Gitea runner
|
||||||
|
with the `windows:host` label and the Windows build prerequisites above.
|
||||||
|
|
||||||
|
The native command layer can start and stop edge/supernode processes, query the n2n UDP management API, run ping and TCPing checks, and inspect the local firewall state. Browser preview does not fabricate native process, management, or network-check results.
|
||||||
|
|
||||||
|
## Included UI
|
||||||
|
|
||||||
|
- Overview of managed services, management API client records, P2P/relay counts, and management request latency
|
||||||
|
- Visual Edge and Supernode option editors mapped to n2n command flags
|
||||||
|
- Auto-IP/DHCP client table populated from the n2n management API
|
||||||
|
- Topology view populated from the currently configured services and returned client records
|
||||||
|
- Ping, TCPing, and local firewall checks
|
||||||
|
- Process, management API, and tool event log
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0d1117" />
|
||||||
|
<title>Super N2N Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "super-n2n",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1 --port 1420",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:portable:raw": "tauri build --no-bundle",
|
||||||
|
"tauri:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1",
|
||||||
|
"build:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1",
|
||||||
|
"package:portable": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build.ps1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.8.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Super N2N Portable
|
||||||
|
|
||||||
|
Run super-n2n.exe to open the desktop control plane.
|
||||||
|
|
||||||
|
The directory includes n2n\edge.exe and n2n\supernode.exe. The application starts
|
||||||
|
them as .\n2n\edge.exe and .\n2n\supernode.exe, so no PATH configuration is required.
|
||||||
|
|
||||||
|
Windows WebView2 is required. It is included by default on current Windows 10 and
|
||||||
|
Windows 11 installations.
|
||||||
|
|
||||||
|
The bundled n2n programs are distributed under n2n\LICENSE-n2n.txt.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
n2n_root="${1:?n2n source path is required}"
|
||||||
|
make_bin="${2:?MinGW make path is required}"
|
||||||
|
clean="${3:-0}"
|
||||||
|
|
||||||
|
cd "$n2n_root"
|
||||||
|
if [[ ! -f config.mak || ! -f include/config.h ]]; then
|
||||||
|
./scripts/hack_fakeautoconf.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$clean" == "1" ]]; then
|
||||||
|
"$make_bin" clean
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$make_bin" edge supernode
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Clean,
|
||||||
|
[switch]$SkipTests
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$PSNativeCommandUseErrorActionPreference = $true
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$n2nRoot = Join-Path $projectRoot 'third_party\n2n'
|
||||||
|
$cargoManifest = Join-Path $projectRoot 'src-tauri\Cargo.toml'
|
||||||
|
$packageScript = Join-Path $PSScriptRoot 'package-portable.ps1'
|
||||||
|
$n2nBuildScript = Join-Path $PSScriptRoot 'build-n2n.sh'
|
||||||
|
$archivePath = Join-Path $projectRoot 'release\super-n2n-portable.zip'
|
||||||
|
$checksumPath = $archivePath + '.sha256'
|
||||||
|
|
||||||
|
function Get-RequiredCommand {
|
||||||
|
param(
|
||||||
|
[string[]]$Names,
|
||||||
|
[string]$Purpose
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($name in $Names) {
|
||||||
|
$command = Get-Command $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if ($command) {
|
||||||
|
return $command.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Missing required tool for $Purpose. Expected one of: $($Names -join ', ')."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GitBash {
|
||||||
|
param([string]$GitExecutable)
|
||||||
|
|
||||||
|
$gitRoot = Split-Path -Parent (Split-Path -Parent $GitExecutable)
|
||||||
|
$candidatePaths = @(
|
||||||
|
(Join-Path $gitRoot 'bin\bash.exe')
|
||||||
|
)
|
||||||
|
$candidatePaths += Get-Command bash.exe -CommandType Application -All -ErrorAction SilentlyContinue | ForEach-Object Source
|
||||||
|
|
||||||
|
foreach ($bashPath in $candidatePaths | Select-Object -Unique) {
|
||||||
|
if (-not (Test-Path -LiteralPath $bashPath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$bashDirectory = Split-Path -Parent $bashPath
|
||||||
|
$gitUnixBinCandidates = @(
|
||||||
|
$bashDirectory,
|
||||||
|
(Join-Path (Split-Path -Parent $bashDirectory) 'usr\bin')
|
||||||
|
)
|
||||||
|
foreach ($gitUnixBin in $gitUnixBinCandidates) {
|
||||||
|
if ((Test-Path -LiteralPath (Join-Path $gitUnixBin 'sh.exe')) -and
|
||||||
|
(Test-Path -LiteralPath (Join-Path $gitUnixBin 'cygpath.exe'))) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Bash = $bashPath
|
||||||
|
UnixBin = $gitUnixBin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'Git Bash was not found. Install Git for Windows.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Checked {
|
||||||
|
param(
|
||||||
|
[string]$Executable,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
& $Executable @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Command failed with exit code ${LASTEXITCODE}: $Executable $($Arguments -join ' ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:OS -ne 'Windows_NT') {
|
||||||
|
throw 'This script builds the Windows portable package and must run on Windows.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$git = Get-RequiredCommand -Names @('git.exe', 'git') -Purpose 'source checkout'
|
||||||
|
$npm = Get-RequiredCommand -Names @('npm.cmd', 'npm') -Purpose 'frontend dependency installation'
|
||||||
|
$cargo = Get-RequiredCommand -Names @('cargo.exe', 'cargo') -Purpose 'Tauri compilation'
|
||||||
|
$gitBash = Get-GitBash -GitExecutable $git
|
||||||
|
$make = Get-RequiredCommand -Names @('mingw32-make.exe', 'mingw32-make') -Purpose 'n2n compilation'
|
||||||
|
$null = Get-RequiredCommand -Names @('gcc.exe', 'gcc') -Purpose 'n2n compilation'
|
||||||
|
$null = Get-RequiredCommand -Names @('windres.exe', 'windres') -Purpose 'n2n Windows resource compilation'
|
||||||
|
|
||||||
|
# Make GNU utilities (rm, cp, gzip) available to n2n's Makefile on Windows.
|
||||||
|
$env:Path = "$($gitBash.UnixBin);$env:Path"
|
||||||
|
$env:SHELL = Join-Path $gitBash.UnixBin 'sh.exe'
|
||||||
|
|
||||||
|
Push-Location $projectRoot
|
||||||
|
try {
|
||||||
|
Invoke-Checked $git @('submodule', 'sync')
|
||||||
|
Invoke-Checked $git @('submodule', 'update', '--init')
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $n2nRoot)) {
|
||||||
|
throw "The n2n submodule was not initialized: $n2nRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
$cygpath = Join-Path $gitBash.UnixBin 'cygpath.exe'
|
||||||
|
$n2nRootUnix = (& $cygpath -u $n2nRoot).Trim()
|
||||||
|
$makeUnix = (& $cygpath -u $make).Trim()
|
||||||
|
$n2nBuildScriptUnix = (& $cygpath -u $n2nBuildScript).Trim()
|
||||||
|
Invoke-Checked $gitBash.Bash @($n2nBuildScriptUnix, $n2nRootUnix, $makeUnix, $(if ($Clean) { '1' } else { '0' }))
|
||||||
|
|
||||||
|
foreach ($binary in @('edge.exe', 'supernode.exe')) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $n2nRoot $binary))) {
|
||||||
|
throw "n2n build did not produce $binary."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Checked $npm @('ci')
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Invoke-Checked $cargo @('test', '--manifest-path', $cargoManifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
& $packageScript -Clean:$Clean
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Portable package script failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $archivePath)) {
|
||||||
|
throw "Portable archive was not created: $archivePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = [System.IO.File]::OpenRead($archivePath)
|
||||||
|
try {
|
||||||
|
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$hashBytes = $sha256.ComputeHash($stream)
|
||||||
|
$hash = -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha256.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
"$hash *$(Split-Path -Leaf $archivePath)" | Set-Content -LiteralPath $checksumPath -Encoding ascii
|
||||||
|
Write-Output "Build completed: $archivePath"
|
||||||
|
Write-Output "SHA-256 checksum: $checksumPath"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
param(
|
||||||
|
[switch]$Clean
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$n2nRoot = Join-Path $projectRoot 'third_party\n2n'
|
||||||
|
$releaseRoot = Join-Path $projectRoot 'release'
|
||||||
|
$portableRoot = Join-Path $projectRoot 'release\super-n2n-portable'
|
||||||
|
$stagingRoot = Join-Path $releaseRoot 'super-n2n-portable-staging'
|
||||||
|
$archivePath = Join-Path $releaseRoot 'super-n2n-portable.zip'
|
||||||
|
$releaseRootFull = [System.IO.Path]::GetFullPath($releaseRoot)
|
||||||
|
$portableRootFull = [System.IO.Path]::GetFullPath($portableRoot)
|
||||||
|
$stagingRootFull = [System.IO.Path]::GetFullPath($stagingRoot)
|
||||||
|
$archivePathFull = [System.IO.Path]::GetFullPath($archivePath)
|
||||||
|
|
||||||
|
if ((Split-Path -Parent $portableRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $portableRootFull) -ne 'super-n2n-portable') {
|
||||||
|
throw "Refusing to replace an unexpected portable output directory: $portableRootFull"
|
||||||
|
}
|
||||||
|
if ((Split-Path -Parent $stagingRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $stagingRootFull) -ne 'super-n2n-portable-staging') {
|
||||||
|
throw "Refusing to replace an unexpected staging directory: $stagingRootFull"
|
||||||
|
}
|
||||||
|
if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne 'super-n2n-portable.zip') {
|
||||||
|
throw "Refusing to replace an unexpected portable archive: $archivePathFull"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Clean) {
|
||||||
|
if (Test-Path -LiteralPath $portableRoot) {
|
||||||
|
Remove-Item -LiteralPath $portableRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $archivePath) {
|
||||||
|
Remove-Item -LiteralPath $archivePath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $projectRoot
|
||||||
|
try {
|
||||||
|
& npm.cmd run tauri:portable:raw
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = @{
|
||||||
|
'super-n2n.exe' = Join-Path $projectRoot 'src-tauri\target\release\super-n2n.exe'
|
||||||
|
'n2n\edge.exe' = Join-Path $n2nRoot 'edge.exe'
|
||||||
|
'n2n\supernode.exe' = Join-Path $n2nRoot 'supernode.exe'
|
||||||
|
'n2n\LICENSE-n2n.txt' = Join-Path $n2nRoot 'LICENSE'
|
||||||
|
'README.txt' = Join-Path $projectRoot 'scripts\PORTABLE-README.txt'
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry in $files.GetEnumerator()) {
|
||||||
|
if (-not (Test-Path -LiteralPath $entry.Value)) {
|
||||||
|
throw "Required portable artifact is missing: $($entry.Value)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $stagingRoot) {
|
||||||
|
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
|
||||||
|
foreach ($entry in $files.GetEnumerator()) {
|
||||||
|
$destination = Join-Path $stagingRoot $entry.Key
|
||||||
|
$destinationDirectory = Split-Path -Parent $destination
|
||||||
|
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
|
||||||
|
Copy-Item -LiteralPath $entry.Value -Destination $destination -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $stagingRoot '*') -DestinationPath $archivePath -CompressionLevel Optimal -Force
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null
|
||||||
|
foreach ($legacyName in @('edge.exe', 'supernode.exe', 'LICENSE-n2n.txt')) {
|
||||||
|
$legacyPath = Join-Path $portableRoot $legacyName
|
||||||
|
if (Test-Path -LiteralPath $legacyPath) {
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $legacyPath -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not remove legacy artifact $legacyPath because it is in use."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($entry in $files.GetEnumerator()) {
|
||||||
|
$destination = Join-Path $portableRoot $entry.Key
|
||||||
|
$destinationDirectory = Split-Path -Parent $destination
|
||||||
|
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
|
||||||
|
try {
|
||||||
|
Copy-Item -LiteralPath $entry.Value -Destination $destination -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not update $destination because it is in use. The zip archive is current."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Portable package created: $archivePath"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $stagingRoot) {
|
||||||
|
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Ignore generated files that would otherwise restart `tauri dev`.
|
||||||
|
/target/
|
||||||
|
/gen/
|
||||||
|
/.tauri/
|
||||||
|
|
||||||
|
# Local native build diagnostics.
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "super-n2n"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "N2N desktop control plane"
|
||||||
|
authors = ["Super N2N"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "super_n2n_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis"] }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="88" fill="#101716"/>
|
||||||
|
<path d="M128 178h118l48 78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
|
||||||
|
<path d="M128 334h118l48-78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
|
||||||
|
<circle cx="128" cy="256" r="45" fill="#f2fbf5"/>
|
||||||
|
<circle cx="384" cy="256" r="45" fill="#f2fbf5"/>
|
||||||
|
<circle cx="256" cy="256" r="31" fill="#f7c75d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default permissions for the Super N2N desktop window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 529 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 957 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,505 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
net::{SocketAddr, TcpStream, UdpSocket},
|
||||||
|
path::PathBuf,
|
||||||
|
process::{Child, Command, Stdio},
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
processes: Mutex<HashMap<String, Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StartRequest {
|
||||||
|
service: String,
|
||||||
|
binary_path: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ServiceStatus {
|
||||||
|
service: String,
|
||||||
|
running: bool,
|
||||||
|
pid: Option<u32>,
|
||||||
|
started_at: u64,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ManagementRequest {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
command: String,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ToolResult {
|
||||||
|
ok: bool,
|
||||||
|
summary: String,
|
||||||
|
output: String,
|
||||||
|
latency_ms: Option<u128>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PingRequest {
|
||||||
|
host: String,
|
||||||
|
count: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TcpingRequest {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TapTrafficRequest {
|
||||||
|
tap_device: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TapTraffic {
|
||||||
|
interface_name: String,
|
||||||
|
received_bytes: u64,
|
||||||
|
sent_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_epoch() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_status(service: &str, message: &str) -> ServiceStatus {
|
||||||
|
ServiceStatus {
|
||||||
|
service: service.to_string(),
|
||||||
|
running: false,
|
||||||
|
pid: None,
|
||||||
|
started_at: 0,
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_binary_path(service: &str) -> Result<&'static str, String> {
|
||||||
|
match service {
|
||||||
|
"edge" => Ok("./n2n/edge.exe"),
|
||||||
|
"supernode" => Ok("./n2n/supernode.exe"),
|
||||||
|
_ => Err(format!("unsupported service: {service}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_directory() -> Result<std::path::PathBuf, String> {
|
||||||
|
std::env::current_exe()
|
||||||
|
.map_err(|error| format!("unable to locate application executable: {error}"))?
|
||||||
|
.parent()
|
||||||
|
.map(|path| path.to_path_buf())
|
||||||
|
.ok_or_else(|| "application executable has no parent directory".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downloads_directory() -> PathBuf {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||||
|
return PathBuf::from(profile).join("Downloads");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
return PathBuf::from(home).join("Downloads");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
|
||||||
|
use std::{ptr, slice};
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::NO_ERROR,
|
||||||
|
NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn wide_string(value: &[u16]) -> String {
|
||||||
|
let end = value
|
||||||
|
.iter()
|
||||||
|
.position(|unit| *unit == 0)
|
||||||
|
.unwrap_or(value.len());
|
||||||
|
String::from_utf16_lossy(&value[..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guid_string(guid: &windows_sys::core::GUID) -> String {
|
||||||
|
format!(
|
||||||
|
"{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
|
||||||
|
guid.data1,
|
||||||
|
guid.data2,
|
||||||
|
guid.data3,
|
||||||
|
guid.data4[0],
|
||||||
|
guid.data4[1],
|
||||||
|
guid.data4[2],
|
||||||
|
guid.data4[3],
|
||||||
|
guid.data4[4],
|
||||||
|
guid.data4[5],
|
||||||
|
guid.data4[6],
|
||||||
|
guid.data4[7]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut();
|
||||||
|
let result = unsafe { GetIfTable2(&mut table) };
|
||||||
|
if result != NO_ERROR || table.is_null() {
|
||||||
|
return Err(format!("unable to read network adapter table: {result}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let traffic = unsafe {
|
||||||
|
let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize);
|
||||||
|
rows.iter()
|
||||||
|
.find_map(|row| {
|
||||||
|
let alias = wide_string(&row.Alias);
|
||||||
|
let description = wide_string(&row.Description);
|
||||||
|
let guid = guid_string(&row.InterfaceGuid);
|
||||||
|
(alias.eq_ignore_ascii_case(tap_device)
|
||||||
|
|| description.eq_ignore_ascii_case(tap_device)
|
||||||
|
|| guid.eq_ignore_ascii_case(tap_device))
|
||||||
|
.then_some(TapTraffic {
|
||||||
|
interface_name: alias,
|
||||||
|
received_bytes: row.InOctets,
|
||||||
|
sent_bytes: row.OutOctets,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("TAP adapter not found: {tap_device}"))
|
||||||
|
};
|
||||||
|
unsafe { FreeMibTable(table.cast()) };
|
||||||
|
traffic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||||
|
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||||
|
if let Some(existing) = processes.get_mut(&request.service) {
|
||||||
|
if existing.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||||
|
return Ok(ServiceStatus {
|
||||||
|
service: request.service,
|
||||||
|
running: true,
|
||||||
|
pid: Some(existing.id()),
|
||||||
|
started_at: now_epoch(),
|
||||||
|
message: "already running".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_binary = relative_binary_path(&request.service)?;
|
||||||
|
if request.binary_path != relative_binary {
|
||||||
|
return Err(format!("{} must use {}", request.service, relative_binary));
|
||||||
|
}
|
||||||
|
|
||||||
|
let working_directory = app_directory()?;
|
||||||
|
let binary = working_directory.join(relative_binary);
|
||||||
|
if !binary.is_file() {
|
||||||
|
return Err(format!("required n2n binary is missing: {}", binary.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = Command::new(relative_binary)
|
||||||
|
.current_dir(&working_directory)
|
||||||
|
.args(&request.args)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
|
||||||
|
let pid = child.id();
|
||||||
|
processes.insert(request.service.clone(), child);
|
||||||
|
|
||||||
|
Ok(ServiceStatus {
|
||||||
|
service: request.service,
|
||||||
|
running: true,
|
||||||
|
pid: Some(pid),
|
||||||
|
started_at: now_epoch(),
|
||||||
|
message: format!("started {relative_binary}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||||
|
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||||
|
if let Some(mut child) = processes.remove(&service) {
|
||||||
|
child.kill().map_err(|e| format!("unable to stop {}: {}", service, e))?;
|
||||||
|
let _ = child.wait();
|
||||||
|
return Ok(empty_status(&service, "stopped"));
|
||||||
|
}
|
||||||
|
Ok(empty_status(&service, "not running"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn service_status(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||||
|
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||||
|
if let Some(child) = processes.get_mut(&service) {
|
||||||
|
if child.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||||
|
return Ok(ServiceStatus {
|
||||||
|
service,
|
||||||
|
running: true,
|
||||||
|
pid: Some(child.id()),
|
||||||
|
started_at: now_epoch(),
|
||||||
|
message: "running".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
processes.remove(&service);
|
||||||
|
return Ok(empty_status(&service, "exited"));
|
||||||
|
}
|
||||||
|
Ok(empty_status(&service, "not running"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn management_query(request: ManagementRequest) -> Result<Vec<Value>, String> {
|
||||||
|
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid management address: {}", e))?;
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(650)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let tag = now_epoch() % 1000;
|
||||||
|
let auth = request
|
||||||
|
.password
|
||||||
|
.filter(|password| !password.is_empty())
|
||||||
|
.map(|password| format!(":1:{}", password))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let payload = format!("r {}{} {}\n", tag, auth, request.command);
|
||||||
|
socket
|
||||||
|
.send_to(payload.as_bytes(), address)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
match socket.recv_from(&mut buffer) {
|
||||||
|
Ok((size, _)) => {
|
||||||
|
if let Ok(text) = std::str::from_utf8(&buffer[..size]) {
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
||||||
|
let finished = value.get("_type").and_then(Value::as_str) == Some("end");
|
||||||
|
packets.push(value);
|
||||||
|
if finished {
|
||||||
|
return Ok(packets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock || error.kind() == std::io::ErrorKind::TimedOut => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packets.is_empty() {
|
||||||
|
Err(format!("management API timed out: {address}"))
|
||||||
|
} else {
|
||||||
|
Ok(packets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn run_ping(request: PingRequest) -> Result<ToolResult, String> {
|
||||||
|
let count = request.count.clamp(1, 8).to_string();
|
||||||
|
let started = Instant::now();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = Command::new("ping")
|
||||||
|
.args(["-n", &count, &request.host])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = Command::new("ping")
|
||||||
|
.args(["-c", &count, &request.host])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
Ok(ToolResult {
|
||||||
|
ok: output.status.success(),
|
||||||
|
summary: if output.status.success() { "目标可达".to_string() } else { "目标不可达".to_string() },
|
||||||
|
output: if stdout.is_empty() { stderr } else { stdout },
|
||||||
|
latency_ms: Some(started.elapsed().as_millis()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn run_tcping(request: TcpingRequest) -> Result<ToolResult, String> {
|
||||||
|
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid TCP address: {}", e))?;
|
||||||
|
let started = Instant::now();
|
||||||
|
match TcpStream::connect_timeout(&address, Duration::from_millis(request.timeout_ms.max(100))) {
|
||||||
|
Ok(_) => Ok(ToolResult {
|
||||||
|
ok: true,
|
||||||
|
summary: "TCP 端口可连接".to_string(),
|
||||||
|
output: format!("connected to {} in {} ms", address, started.elapsed().as_millis()),
|
||||||
|
latency_ms: Some(started.elapsed().as_millis()),
|
||||||
|
}),
|
||||||
|
Err(error) => Ok(ToolResult {
|
||||||
|
ok: false,
|
||||||
|
summary: "TCP 端口不可连接".to_string(),
|
||||||
|
output: error.to_string(),
|
||||||
|
latency_ms: Some(started.elapsed().as_millis()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn tap_traffic(request: TapTrafficRequest) -> Result<TapTraffic, String> {
|
||||||
|
let tap_device = request.tap_device.trim();
|
||||||
|
if tap_device.is_empty() {
|
||||||
|
return Err("TAP device name is empty".to_string());
|
||||||
|
}
|
||||||
|
if tap_device.contains(['/', '\\']) || tap_device == "." || tap_device == ".." {
|
||||||
|
return Err("invalid TAP device name".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
return windows_tap_traffic(tap_device);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let path = std::path::Path::new("/sys/class/net").join(tap_device).join("statistics");
|
||||||
|
let received_bytes = fs::read_to_string(path.join("rx_bytes"))
|
||||||
|
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||||
|
.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|error| format!("invalid TAP receive counter: {error}"))?;
|
||||||
|
let sent_bytes = fs::read_to_string(path.join("tx_bytes"))
|
||||||
|
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||||
|
.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|error| format!("invalid TAP transmit counter: {error}"))?;
|
||||||
|
return Ok(TapTraffic {
|
||||||
|
interface_name: tap_device.to_string(),
|
||||||
|
received_bytes,
|
||||||
|
sent_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
|
Err("TAP traffic monitoring is not supported on this operating system".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn firewall_status() -> Result<ToolResult, String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = Command::new("netsh")
|
||||||
|
.args(["advfirewall", "show", "allprofiles", "state"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let output = Command::new("sh")
|
||||||
|
.args(["-c", "ufw status verbose 2>/dev/null || firewall-cmd --state 2>/dev/null || true"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let output = Command::new("/usr/libexec/ApplicationFirewall/socketfilterfw")
|
||||||
|
.args(["--getglobalstate"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||||
|
let output = Command::new("true").output().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let normalized = stdout
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let enabled = normalized.contains("state on")
|
||||||
|
|| normalized.contains("status: active")
|
||||||
|
|| normalized.contains("firewall is enabled")
|
||||||
|
|| normalized.contains("state = 1")
|
||||||
|
|| normalized.split_whitespace().any(|token| token == "running");
|
||||||
|
Ok(ToolResult {
|
||||||
|
ok: output.status.success(),
|
||||||
|
summary: if enabled { "本地防火墙已开启".to_string() } else { "未检测到防火墙开启".to_string() },
|
||||||
|
output: stdout,
|
||||||
|
latency_ms: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn export_log(contents: String) -> Result<String, String> {
|
||||||
|
let directory = downloads_directory();
|
||||||
|
fs::create_dir_all(&directory)
|
||||||
|
.map_err(|error| format!("unable to create downloads directory: {error}"))?;
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let file = directory.join(format!("super-n2n-{millis}.log"));
|
||||||
|
fs::write(&file, contents).map_err(|error| format!("unable to export log: {error}"))?;
|
||||||
|
Ok(file.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{relative_binary_path, StartRequest};
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn windows_tap_traffic_uses_the_adapter_table() {
|
||||||
|
assert!(super::windows_tap_traffic("__super_n2n_missing_tap__").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_request_accepts_an_argument_vector_from_tauri() {
|
||||||
|
let request: StartRequest = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"service": "edge",
|
||||||
|
"binaryPath": "./n2n/edge.exe",
|
||||||
|
"args": ["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("a Tauri start request with an argument array should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(request.binary_path, "./n2n/edge.exe");
|
||||||
|
assert_eq!(
|
||||||
|
request.args,
|
||||||
|
["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn services_have_fixed_relative_binary_paths() {
|
||||||
|
assert_eq!(relative_binary_path("edge").unwrap(), "./n2n/edge.exe");
|
||||||
|
assert_eq!(
|
||||||
|
relative_binary_path("supernode").unwrap(),
|
||||||
|
"./n2n/supernode.exe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(AppState {
|
||||||
|
processes: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_service,
|
||||||
|
stop_service,
|
||||||
|
service_status,
|
||||||
|
management_query,
|
||||||
|
run_ping,
|
||||||
|
run_tcping,
|
||||||
|
tap_traffic,
|
||||||
|
firewall_status,
|
||||||
|
export_log
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running Super N2N");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
super_n2n_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Super N2N",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.supern2n.control",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Super N2N Control",
|
||||||
|
"width": 350,
|
||||||
|
"height": 380,
|
||||||
|
"minWidth": 350,
|
||||||
|
"minHeight": 380,
|
||||||
|
"maxWidth": 350,
|
||||||
|
"maxHeight": 380,
|
||||||
|
"resizable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false,
|
||||||
|
"targets": "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
export const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__)
|
||||||
|
|
||||||
|
export async function nativeCall(command, args, fallback) {
|
||||||
|
if (!isTauri) return fallback()
|
||||||
|
try {
|
||||||
|
return await invoke(command, args)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startService = (request) => nativeCall('start_service', { request }, () => {
|
||||||
|
throw new Error('浏览器预览无法启动原生服务')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const stopService = (service) => nativeCall('stop_service', { service }, () => {
|
||||||
|
throw new Error('浏览器预览没有受管进程')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serviceStatus = (service) => nativeCall('service_status', { service }, () => ({
|
||||||
|
service,
|
||||||
|
running: false,
|
||||||
|
pid: null,
|
||||||
|
started_at: 0,
|
||||||
|
message: '浏览器预览没有受管进程',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const queryManagement = (request) => nativeCall('management_query', { request }, () => {
|
||||||
|
throw new Error('浏览器预览不支持管理 API 查询')
|
||||||
|
})
|
||||||
|
export const runPing = (request, fallback) => nativeCall('run_ping', { request }, fallback)
|
||||||
|
export const runTcping = (request, fallback) => nativeCall('run_tcping', { request }, fallback)
|
||||||
|
export const checkFirewall = (fallback) => nativeCall('firewall_status', {}, fallback)
|
||||||
|
export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => {
|
||||||
|
throw new Error('浏览器预览无法读取 TAP 流量')
|
||||||
|
})
|
||||||
|
export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null)
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import {
|
||||||
|
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
|
||||||
|
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { exportLog, isTauri, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const initialEdge = {
|
||||||
|
serverHost: '',
|
||||||
|
serverPort: '7777',
|
||||||
|
community: '',
|
||||||
|
dhcp: true,
|
||||||
|
ip: '',
|
||||||
|
encryptionKey: '',
|
||||||
|
managementPort: '5644',
|
||||||
|
tapDevice: 'n2n0',
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [home, setHome] = useState('edge')
|
||||||
|
const [edge, setEdge] = useState(initialEdge)
|
||||||
|
const [edgeRunning, setEdgeRunning] = useState(false)
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [logOpen, setLogOpen] = useState(false)
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [traffic, setTraffic] = useState(null)
|
||||||
|
const [message, setMessage] = useState(null)
|
||||||
|
const lastTapSample = useRef(null)
|
||||||
|
|
||||||
|
const appendLog = (text, level = 'info') => {
|
||||||
|
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
setLogs((current) => [{ time, level, text }, ...current].slice(0, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
serviceStatus('edge')
|
||||||
|
.then((status) => {
|
||||||
|
if (!active) return
|
||||||
|
setEdgeRunning(Boolean(status.running))
|
||||||
|
appendLog(status.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', status.running ? 'success' : 'info')
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!edgeRunning) {
|
||||||
|
lastTapSample.current = null
|
||||||
|
setTraffic(null)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
let timer
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const sample = await tapTraffic(edge.tapDevice)
|
||||||
|
const sampledAt = Date.now()
|
||||||
|
const previous = lastTapSample.current
|
||||||
|
const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1
|
||||||
|
const downRate = previous ? Math.max(0, sample.receivedBytes - previous.receivedBytes) / elapsedSeconds : 0
|
||||||
|
const upRate = previous ? Math.max(0, sample.sentBytes - previous.sentBytes) / elapsedSeconds : 0
|
||||||
|
lastTapSample.current = { ...sample, sampledAt }
|
||||||
|
if (active) setTraffic({ ...sample, downRate, upRate, error: null })
|
||||||
|
} catch (error) {
|
||||||
|
if (active) setTraffic((current) => ({ ...current, error: error.message }))
|
||||||
|
} finally {
|
||||||
|
if (active) timer = window.setTimeout(poll, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [edgeRunning, edge.tapDevice])
|
||||||
|
|
||||||
|
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
|
||||||
|
const notify = (text, tone = 'error') => {
|
||||||
|
setMessage({ text, tone })
|
||||||
|
window.setTimeout(() => setMessage(null), 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEdge = async () => {
|
||||||
|
if (edgeRunning) {
|
||||||
|
try {
|
||||||
|
const status = await stopService('edge')
|
||||||
|
setEdgeRunning(Boolean(status.running))
|
||||||
|
appendLog('已断开 N2N 网络')
|
||||||
|
notify('已断开 N2N 网络', 'neutral')
|
||||||
|
} catch (error) {
|
||||||
|
appendLog('停止连接失败:' + error.message, 'error')
|
||||||
|
notify('无法停止连接:' + error.message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!edge.serverHost.trim()) {
|
||||||
|
appendLog('连接校验:未填写服务器 IP 或域名', 'warning')
|
||||||
|
notify('请填写服务器 IP 或域名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!edge.community.trim()) {
|
||||||
|
appendLog('连接校验:未填写群组名称', 'warning')
|
||||||
|
notify('请填写群组名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!edge.dhcp && !edge.ip.trim()) {
|
||||||
|
appendLog('连接校验:静态 IP 未填写', 'warning')
|
||||||
|
notify('关闭 DHCP 后,请填写本机 IP')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await startService({
|
||||||
|
service: 'edge',
|
||||||
|
binaryPath: './n2n/edge.exe',
|
||||||
|
args: buildEdgeArgs(edge),
|
||||||
|
})
|
||||||
|
setEdgeRunning(Boolean(status.running))
|
||||||
|
appendLog('已连接到群组 ' + edge.community, 'success')
|
||||||
|
notify('已连接到 ' + edge.community, 'success')
|
||||||
|
} catch (error) {
|
||||||
|
appendLog('连接失败:' + error.message, 'error')
|
||||||
|
notify('无法建立连接:' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div>
|
||||||
|
<div className="runtime"><span className={edgeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? '已连接' : '未连接'}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="workspace" aria-label="N2N 连接设置">
|
||||||
|
<div className="mode-switch" role="tablist" aria-label="选择主页">
|
||||||
|
<button role="tab" aria-selected={home === 'edge'} className={home === 'edge' ? 'active' : ''} onClick={() => setHome('edge')}>
|
||||||
|
<Router size={18} /> 加入网络
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={home === 'supernode'} className={home === 'supernode' ? 'active' : ''} onClick={() => setHome('supernode')}>
|
||||||
|
<Server size={18} /> 创建服务器
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{home === 'edge'
|
||||||
|
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onExportLog={() => exportLogs(logs, notify)} />
|
||||||
|
: <SupernodeHome />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer>
|
||||||
|
{message && <Notice {...message} />}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) {
|
||||||
|
const toggleAdvanced = () => {
|
||||||
|
setLogOpen(false)
|
||||||
|
setAdvancedOpen((current) => !current)
|
||||||
|
}
|
||||||
|
const toggleLog = () => {
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
setLogOpen((current) => !current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="connection-flow">
|
||||||
|
<div className="intro">
|
||||||
|
<div className="intro-icon"><Wifi size={28} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">EDGE</p>
|
||||||
|
<h1>加入一个网络</h1>
|
||||||
|
<p className="intro-copy">填写服务器和群组信息,然后连接。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="connection-card">
|
||||||
|
<div className="field-stack">
|
||||||
|
<label className="input-group">
|
||||||
|
<span className="field-title"><Globe2 size={18} />服务器</span>
|
||||||
|
<span className="server-inputs">
|
||||||
|
<input value={edge.serverHost} onChange={(event) => updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" />
|
||||||
|
<span className="port-separator">:</span>
|
||||||
|
<input className="port-input" inputMode="numeric" value={edge.serverPort} onChange={(event) => updateEdge('serverPort', event.target.value)} placeholder="7777" disabled={running} aria-label="服务器端口" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="input-group">
|
||||||
|
<span className="field-title"><Network size={18} />群组名称</span>
|
||||||
|
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
|
||||||
|
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
|
||||||
|
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={running ? 'connect-button stop' : 'connect-button'} onClick={onToggle}>
|
||||||
|
{running ? <Square size={19} fill="currentColor" /> : <Play size={20} fill="currentColor" />}
|
||||||
|
{running ? '断开连接' : '连接网络'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{running && <TrafficStrip traffic={traffic} tapDevice={edge.tapDevice} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="secondary-actions">
|
||||||
|
<section className="advanced-section">
|
||||||
|
<button className="advanced-toggle" onClick={toggleAdvanced} aria-expanded={advancedOpen}>
|
||||||
|
<span><Settings2 size={17} />高级设置</span><ChevronDown size={18} className={advancedOpen ? 'rotated' : ''} />
|
||||||
|
</button>
|
||||||
|
{advancedOpen && <div className="advanced-fields">
|
||||||
|
<label><span>加密密钥</span><input type="password" value={edge.encryptionKey} onChange={(event) => updateEdge('encryptionKey', event.target.value)} placeholder="可选" disabled={running} /></label>
|
||||||
|
<label><span>管理端口</span><input inputMode="numeric" value={edge.managementPort} onChange={(event) => updateEdge('managementPort', event.target.value)} disabled={running} /></label>
|
||||||
|
<label><span>TAP 设备名称</span><input value={edge.tapDevice} onChange={(event) => updateEdge('tapDevice', event.target.value)} disabled={running} /></label>
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<LogPanel open={logOpen} onToggle={toggleLog} logs={logs} onExport={onExportLog} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficStrip({ traffic, tapDevice }) {
|
||||||
|
if (!traffic || traffic.error) {
|
||||||
|
return <div className="traffic-strip pending" role="status" title={traffic?.error || ''}>
|
||||||
|
<span className="traffic-device"><Gauge size={17} />TAP {tapDevice}</span>
|
||||||
|
<strong>{traffic?.error ? '无法读取流量' : '正在读取流量…'}</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
return <section className="traffic-strip" aria-label={'TAP ' + traffic.interfaceName + ' 实时流量'}>
|
||||||
|
<div className="traffic-device"><Gauge size={17} /><span>TAP</span><strong>{traffic.interfaceName}</strong></div>
|
||||||
|
<TrafficMetric direction="down" label="下载" rate={traffic.downRate} total={traffic.receivedBytes} />
|
||||||
|
<TrafficMetric direction="up" label="上传" rate={traffic.upRate} total={traffic.sentBytes} />
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficMetric({ direction, label, rate, total }) {
|
||||||
|
const Icon = direction === 'down' ? ArrowDown : ArrowUp
|
||||||
|
return <output className={'traffic-metric ' + direction}>
|
||||||
|
<span><Icon size={15} />{label}</span><strong>{formatRate(rate)}</strong><small>累计 {formatBytes(total)}</small>
|
||||||
|
</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DhcpToggle({ checked, disabled, onChange }) {
|
||||||
|
return <label className="dhcp-toggle">
|
||||||
|
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||||
|
<span className="toggle-track" aria-hidden="true"><i /></span>
|
||||||
|
<span>DHCP</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupernodeHome() {
|
||||||
|
return <div className="coming-soon">
|
||||||
|
<div className="coming-icon"><Server size={29} /></div>
|
||||||
|
<p className="eyebrow">SUPERNODE</p>
|
||||||
|
<h1>创建服务器</h1>
|
||||||
|
<p>服务器配置即将加入此页面。</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogPanel({ open, onToggle, logs, onExport }) {
|
||||||
|
return <section className="log-section">
|
||||||
|
<button className="log-toggle" onClick={onToggle} aria-expanded={open}>
|
||||||
|
<span><ScrollText size={17} />运行日志 <small>{logs.length}</small></span><ChevronDown size={18} className={open ? 'rotated' : ''} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="log-content">
|
||||||
|
<div className="log-toolbar"><span>本次会话</span><button className="export-log" onClick={onExport} disabled={logs.length === 0}><Download size={16} />导出 .log</button></div>
|
||||||
|
{logs.length === 0
|
||||||
|
? <p className="empty-log">暂无运行记录。连接或校验网络后,事件会显示在这里。</p>
|
||||||
|
: <ol className="log-list">{logs.map((log, index) => <li key={log.time + index} className={log.level}><time>{log.time}</time><span>{log.text}</span></li>)}</ol>}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notice({ text, tone }) {
|
||||||
|
const Icon = tone === 'success' ? CheckCircle2 : CircleAlert
|
||||||
|
return <div className={'notice ' + tone} role="status"><Icon size={19} />{text}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportLogs(logs, notify) {
|
||||||
|
const exportedAt = new Date()
|
||||||
|
const timestamp = exportedAt.toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
|
||||||
|
const body = [
|
||||||
|
'Super N2N session log',
|
||||||
|
'Exported: ' + exportedAt.toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
'',
|
||||||
|
...logs.slice().reverse().map((log) => '[' + log.time + '] [' + log.level.toUpperCase() + '] ' + log.text),
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
const savedPath = await exportLog(body)
|
||||||
|
if (savedPath) {
|
||||||
|
notify('运行日志已导出到 ' + savedPath, 'success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([body], { type: 'text/plain;charset=utf-8' }))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = 'super-n2n-' + timestamp + '.log'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
notify('运行日志已导出', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRate(bytesPerSecond) {
|
||||||
|
return formatBytes(bytesPerSecond) + '/s'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
const value = bytes / (1024 ** index)
|
||||||
|
return value.toFixed(value >= 100 || index === 0 ? 0 : 1) + ' ' + units[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption(args, flag, value) {
|
||||||
|
if (String(value ?? '').trim()) args.push(flag, String(value).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeArgs(config) {
|
||||||
|
const args = []
|
||||||
|
addOption(args, '-c', config.community)
|
||||||
|
addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777'))
|
||||||
|
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
|
||||||
|
addOption(args, '-d', config.tapDevice)
|
||||||
|
addOption(args, '-t', config.managementPort)
|
||||||
|
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
|
||||||
|
args.push('-f', '-v', '-v')
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root')
|
||||||
|
const appRoot = globalThis.__superN2nRoot ?? createRoot(rootElement)
|
||||||
|
globalThis.__superN2nRoot = appRoot
|
||||||
|
appRoot.render(<App />)
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
:root {
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #17242c;
|
||||||
|
background: #edf5f5;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
--ink: #17242c;
|
||||||
|
--muted: #5e7079;
|
||||||
|
--faint: #81939b;
|
||||||
|
--canvas: #edf5f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--line: #d6e1e2;
|
||||||
|
--line-strong: #b9cacc;
|
||||||
|
--teal: #087f72;
|
||||||
|
--teal-dark: #056457;
|
||||||
|
--teal-soft: #dff3ef;
|
||||||
|
--blue: #2b6cb0;
|
||||||
|
--red: #bd3d46;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||||
|
body { min-height: 100vh; background: var(--canvas); }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { border: 0; cursor: pointer; }
|
||||||
|
button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
|
||||||
|
button:disabled, input:disabled { cursor: not-allowed; }
|
||||||
|
|
||||||
|
.app-shell { min-height: 100vh; display: grid; grid-template-rows: 56px 1fr 36px; background: var(--canvas); }
|
||||||
|
.app-header, .app-footer { width: min(100% - 40px, 888px); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.app-header { border-bottom: 1px solid var(--line); }
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-size: 16px; font-weight: 700; }
|
||||||
|
.brand-icon { width: 28px; height: 28px; display: grid; place-items: center; color: #fff; background: var(--teal); border-radius: 6px; }
|
||||||
|
.runtime { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; }
|
||||||
|
.status-light { width: 7px; height: 7px; border-radius: 50%; background: #a6b5b9; }
|
||||||
|
.status-light.live { background: #0c9a78; box-shadow: 0 0 0 4px rgba(12, 154, 120, .14); }
|
||||||
|
|
||||||
|
.workspace { width: min(100% - 40px, 640px); align-self: center; justify-self: center; padding: 24px 0 30px; }
|
||||||
|
.mode-switch { width: max-content; display: flex; gap: 3px; padding: 3px; margin: 0 auto 22px; background: #e0ebeb; border: 1px solid #d1dede; border-radius: 8px; }
|
||||||
|
.mode-switch button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 6px; font-size: 13px; font-weight: 600; transition: color 180ms ease, background 180ms ease, box-shadow 180ms ease; }
|
||||||
|
.mode-switch button:hover { color: var(--ink); }
|
||||||
|
.mode-switch button.active { color: var(--ink); background: var(--surface); box-shadow: 0 1px 3px rgba(26, 54, 58, .13); }
|
||||||
|
|
||||||
|
.connection-flow { animation: reveal 220ms ease-out; }
|
||||||
|
.intro { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 18px; text-align: left; }
|
||||||
|
.intro-icon, .coming-icon { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; color: var(--teal); background: var(--teal-soft); border: 1px solid #b7ddd4; border-radius: 10px; }
|
||||||
|
.eyebrow { margin: 0 0 3px; color: var(--teal); font-size: 10px; font-weight: 700; letter-spacing: .08em; }
|
||||||
|
h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spacing: 0; }
|
||||||
|
.intro-copy { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||||
|
|
||||||
|
.connection-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
|
||||||
|
.field-stack { display: grid; padding: 18px 22px 6px; }
|
||||||
|
.input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; }
|
||||||
|
.input-group:last-child { border-bottom: 0; }
|
||||||
|
.field-title { display: inline-flex; align-items: center; gap: 7px; color: var(--ink); font-size: 14px; font-weight: 700; }
|
||||||
|
.field-title svg { color: var(--teal); }
|
||||||
|
.input-group input, .advanced-fields input { width: 100%; height: 40px; padding: 0 11px; color: var(--ink); background: #fbfdfd; border: 1px solid var(--line-strong); border-radius: 5px; font-size: 14px; transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; }
|
||||||
|
.input-group input:hover, .advanced-fields input:hover { border-color: #8ca5a8; }
|
||||||
|
.input-group input:focus, .advanced-fields input:focus { background: #fff; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(43, 108, 176, .13); outline: 0; }
|
||||||
|
.input-group input:disabled { color: #83969b; background: #f0f5f5; border-color: #dce6e7; }
|
||||||
|
.input-group input::placeholder, .advanced-fields input::placeholder { color: #8ca0a5; }
|
||||||
|
.server-inputs { display: grid; grid-template-columns: minmax(0, 1fr) auto 88px; align-items: center; gap: 7px; }
|
||||||
|
.port-separator { color: var(--faint); font: 700 19px/1 ui-monospace, Consolas, monospace; }
|
||||||
|
.ip-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.input-group small { color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||||
|
|
||||||
|
.dhcp-toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 28px; color: var(--muted); font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||||
|
.dhcp-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||||
|
.toggle-track { position: relative; width: 36px; height: 22px; flex: 0 0 auto; background: #a8b7ba; border-radius: 14px; transition: background 180ms ease; }
|
||||||
|
.toggle-track i { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; box-shadow: 0 1px 2px rgba(18, 42, 45, .2); transition: left 180ms ease; }
|
||||||
|
.dhcp-toggle input:checked + .toggle-track { background: var(--teal); }
|
||||||
|
.dhcp-toggle input:checked + .toggle-track i { left: 17px; }
|
||||||
|
.dhcp-toggle input:focus-visible + .toggle-track { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
|
||||||
|
.dhcp-toggle input:disabled + .toggle-track { opacity: .55; }
|
||||||
|
.dhcp-toggle:has(input:disabled) { cursor: not-allowed; }
|
||||||
|
|
||||||
|
.connect-button { width: calc(100% - 44px); min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 8px; margin: 13px 22px 18px; color: #fff; background: var(--teal); border: 1px solid var(--teal); border-radius: 5px; font-size: 15px; font-weight: 700; transition: background 180ms ease, border-color 180ms ease, transform 180ms ease; }
|
||||||
|
.connect-button:hover { background: var(--teal-dark); border-color: var(--teal-dark); }
|
||||||
|
.connect-button:active { transform: translateY(1px); }
|
||||||
|
.connect-button.stop { color: #fff; background: var(--red); border-color: var(--red); }
|
||||||
|
.connect-button.stop:hover { background: #a7313a; border-color: #a7313a; }
|
||||||
|
.connection-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--line); background: #f8fbfb; }
|
||||||
|
.connection-summary div { min-width: 0; display: grid; gap: 4px; padding: 12px 14px; border-right: 1px solid var(--line); }
|
||||||
|
.connection-summary div:last-child { border-right: 0; }
|
||||||
|
.connection-summary span { color: var(--faint); font-size: 11px; }
|
||||||
|
.connection-summary strong { overflow: hidden; color: var(--ink); font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.traffic-strip { min-height: 66px; display: grid; grid-template-columns: minmax(135px, 1.1fr) 1fr 1fr; align-items: stretch; border-top: 1px solid var(--line); background: #f7fbfa; }
|
||||||
|
.traffic-device { min-width: 0; display: flex; align-items: center; gap: 7px; padding: 12px 14px; color: var(--teal); border-right: 1px solid var(--line); font-size: 12px; }
|
||||||
|
.traffic-device span { color: var(--faint); font-weight: 700; }
|
||||||
|
.traffic-device strong { overflow: hidden; color: var(--ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.traffic-metric { min-width: 0; display: grid; grid-template-columns: auto 1fr; align-content: center; gap: 3px 8px; padding: 9px 13px; border-right: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
||||||
|
.traffic-metric:last-child { border-right: 0; }
|
||||||
|
.traffic-metric span { display: inline-flex; align-items: center; gap: 4px; color: var(--faint); font-size: 11px; }
|
||||||
|
.traffic-metric.down span svg { color: var(--blue); }
|
||||||
|
.traffic-metric.up span svg { color: #b36b18; }
|
||||||
|
.traffic-metric strong { justify-self: end; color: var(--ink); font: 700 14px/1.2 ui-monospace, "Cascadia Code", Consolas, monospace; white-space: nowrap; }
|
||||||
|
.traffic-metric small { grid-column: 1 / -1; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||||
|
.traffic-strip.pending { min-height: 52px; display: flex; align-items: center; justify-content: space-between; padding-right: 14px; color: var(--muted); }
|
||||||
|
.traffic-strip.pending .traffic-device { padding-top: 8px; padding-bottom: 8px; border-right: 0; }
|
||||||
|
.traffic-strip.pending > strong { font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
|
.advanced-section, .log-section { margin-top: 8px; background: rgba(255, 255, 255, .58); border: 1px solid var(--line); border-radius: 7px; }
|
||||||
|
.advanced-toggle { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 7px; font-size: 13px; font-weight: 600; }
|
||||||
|
.advanced-toggle:hover, .log-toggle:hover { color: var(--ink); background: rgba(255, 255, 255, .68); }
|
||||||
|
.advanced-toggle span, .log-toggle span { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.advanced-toggle svg, .log-toggle > svg { transition: transform 180ms ease; }
|
||||||
|
.advanced-toggle svg.rotated, .log-toggle > svg.rotated { transform: rotate(180deg); }
|
||||||
|
.advanced-fields { display: grid; grid-template-columns: 1.4fr .8fr 1fr; gap: 10px; padding: 0 14px 14px; }
|
||||||
|
.advanced-fields label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||||
|
.advanced-fields input { height: 36px; padding: 0 9px; font-size: 13px; }
|
||||||
|
|
||||||
|
.log-toggle { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; color: var(--muted); background: transparent; border-radius: 7px; font-size: 13px; font-weight: 600; }
|
||||||
|
.log-toggle small { min-width: 22px; height: 20px; display: inline-grid; place-items: center; padding: 0 6px; color: #41616a; background: #dfeaea; border-radius: 10px; font-size: 11px; }
|
||||||
|
.log-content { padding: 0 14px 14px; }
|
||||||
|
.log-toolbar { min-height: 40px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); }
|
||||||
|
.log-toolbar > span { color: var(--faint); font-size: 12px; }
|
||||||
|
.export-log { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 9px; color: var(--teal-dark); background: #edf8f5; border: 1px solid #badbd3; border-radius: 5px; font-size: 12px; font-weight: 700; transition: background 180ms ease, border-color 180ms ease; }
|
||||||
|
.export-log:hover { background: var(--teal-soft); border-color: #8ec7b9; }
|
||||||
|
.export-log:disabled { color: #93a2a5; background: #f2f5f5; border-color: var(--line); opacity: .75; }
|
||||||
|
.empty-log { margin: 0; padding: 24px 16px; color: var(--muted); text-align: center; background: #f8fbfb; border: 1px solid #e1e9ea; border-radius: 6px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.log-list { max-height: 190px; margin: 0; padding: 0; overflow-y: auto; list-style: none; background: #172328; border: 1px solid #26383f; border-radius: 5px; }
|
||||||
|
.log-list li { min-height: 34px; display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: center; gap: 10px; padding: 6px 10px; color: #d4dfe1; border-bottom: 1px solid #29383e; font: 11px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
|
||||||
|
.log-list li:last-child { border-bottom: 0; }
|
||||||
|
.log-list time { color: #80979e; }
|
||||||
|
.log-list li.success span { color: #79d8bd; }
|
||||||
|
.log-list li.warning span { color: #edc67f; }
|
||||||
|
.log-list li.error span { color: #f0a0a6; }
|
||||||
|
|
||||||
|
.coming-soon { min-height: 280px; display: grid; align-content: center; justify-items: center; padding: 32px; text-align: center; background: rgba(255, 255, 255, .68); border: 1px dashed #b7cbcb; border-radius: 7px; animation: reveal 220ms ease-out; }
|
||||||
|
.coming-soon .coming-icon { margin-bottom: 14px; color: var(--blue); background: #e5f0fc; border-color: #c6dbf4; }
|
||||||
|
.coming-soon h1 { margin-bottom: 10px; }
|
||||||
|
.coming-soon p:last-child { margin: 0; color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.app-footer { color: var(--faint); border-top: 1px solid var(--line); font-size: 12px; }
|
||||||
|
.notice { position: fixed; right: 24px; bottom: 24px; max-width: min(420px, calc(100vw - 48px)); display: flex; align-items: center; gap: 10px; padding: 14px 16px; color: #803035; background: #fff7f7; border: 1px solid #e6b9bb; border-radius: 7px; box-shadow: 0 8px 22px rgba(44, 44, 44, .13); font-size: 14px; animation: reveal 200ms ease-out; }
|
||||||
|
.notice.success { color: #0d6a5e; background: #f0fbf8; border-color: #a8d9cd; }
|
||||||
|
.notice.neutral { color: #586a70; background: #f7fafa; border-color: #d2e0e1; }
|
||||||
|
@keyframes reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.app-shell { grid-template-rows: 54px 1fr 34px; }
|
||||||
|
.app-header, .app-footer, .workspace { width: min(100% - 24px, 640px); }
|
||||||
|
.workspace { padding: 18px 0 24px; align-self: start; }
|
||||||
|
.mode-switch { width: 100%; margin-bottom: 18px; }
|
||||||
|
.mode-switch button { flex: 1; padding: 0 10px; font-size: 13px; }
|
||||||
|
.intro { align-items: center; justify-content: flex-start; gap: 10px; }
|
||||||
|
.intro-icon { width: 40px; height: 40px; border-radius: 9px; }
|
||||||
|
.intro-icon svg { width: 21px; height: 21px; }
|
||||||
|
h1 { font-size: 22px; }
|
||||||
|
.intro-copy { font-size: 12px; }
|
||||||
|
.field-stack { padding: 10px 16px 2px; }
|
||||||
|
.input-group { padding: 12px 0; }
|
||||||
|
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 74px; gap: 5px; }
|
||||||
|
.input-group input { height: 38px; font-size: 14px; }
|
||||||
|
.connect-button { width: calc(100% - 32px); margin: 10px 16px 14px; }
|
||||||
|
.connection-summary { grid-template-columns: 1fr; }
|
||||||
|
.connection-summary div { grid-template-columns: 80px 1fr; align-items: baseline; padding: 9px 14px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.connection-summary div:last-child { border-bottom: 0; }
|
||||||
|
.traffic-strip { grid-template-columns: 1fr 1fr; }
|
||||||
|
.traffic-device { grid-column: 1 / -1; padding: 9px 12px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.traffic-metric { padding: 8px 11px; }
|
||||||
|
.traffic-strip.pending { display: flex; }
|
||||||
|
.traffic-strip.pending .traffic-device { border-bottom: 0; }
|
||||||
|
.advanced-fields { grid-template-columns: 1fr; }
|
||||||
|
.log-list li { grid-template-columns: 66px minmax(0, 1fr); gap: 8px; padding: 8px 10px; font-size: 11px; }
|
||||||
|
.app-footer { font-size: 11px; }
|
||||||
|
.notice { right: 16px; bottom: 16px; max-width: calc(100vw - 32px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px), (max-height: 450px) {
|
||||||
|
html, body, #root { width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
|
body { min-height: 0; }
|
||||||
|
.app-shell { width: 100%; height: 100%; min-height: 0; grid-template-rows: 42px minmax(0, 1fr); overflow: hidden; }
|
||||||
|
.app-header { width: 100%; min-width: 0; height: 42px; padding: 0 10px; }
|
||||||
|
.app-footer { display: none; }
|
||||||
|
.brand { gap: 6px; font-size: 14px; }
|
||||||
|
.brand-icon { width: 24px; height: 24px; border-radius: 5px; }
|
||||||
|
.brand-icon svg { width: 16px; height: 16px; }
|
||||||
|
.runtime { gap: 5px; font-size: 11px; }
|
||||||
|
.status-light { width: 6px; height: 6px; }
|
||||||
|
|
||||||
|
.workspace { width: 100%; height: 100%; min-height: 0; padding: 7px 10px 8px; overflow: hidden; align-self: stretch; }
|
||||||
|
.mode-switch { width: 100%; height: 34px; min-height: 34px; margin: 0 0 7px; padding: 2px; border-radius: 6px; }
|
||||||
|
.mode-switch button { min-height: 28px; gap: 5px; padding: 0 5px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.mode-switch button svg { width: 15px; height: 15px; }
|
||||||
|
.connection-flow { height: calc(100% - 41px); display: grid; grid-template-rows: minmax(0, 1fr) 34px; gap: 6px; }
|
||||||
|
.intro { display: none; }
|
||||||
|
.connection-card { min-height: 0; display: flex; flex-direction: column; border-radius: 6px; box-shadow: none; }
|
||||||
|
.field-stack { min-height: 0; flex: 1 1 auto; padding: 3px 8px; }
|
||||||
|
.input-group { grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 6px; padding: 4px 0; }
|
||||||
|
.input-group:not(:has(.ip-heading)) > .field-title { grid-column: 1; grid-row: 1; }
|
||||||
|
.input-group:not(:has(.ip-heading)) > input, .input-group:not(:has(.ip-heading)) > .server-inputs { grid-column: 2; grid-row: 1; }
|
||||||
|
.field-title { gap: 0; font-size: 12px; }
|
||||||
|
.field-title svg { display: none; }
|
||||||
|
.input-group input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 58px; gap: 4px; }
|
||||||
|
.port-separator { font-size: 15px; }
|
||||||
|
.input-group small { display: none; }
|
||||||
|
.ip-heading { display: contents; }
|
||||||
|
.input-group:has(.ip-heading) { position: relative; }
|
||||||
|
.input-group:has(.ip-heading) .field-title { grid-column: 1; grid-row: 1; }
|
||||||
|
.input-group:has(.ip-heading) > input { grid-column: 2; grid-row: 1; padding-right: 65px; }
|
||||||
|
.input-group:has(.ip-heading) .dhcp-toggle { position: absolute; top: 7px; right: 5px; gap: 4px; min-height: 18px; font-size: 10px; }
|
||||||
|
.toggle-track { width: 28px; height: 17px; }
|
||||||
|
.toggle-track i { top: 2px; left: 2px; width: 13px; height: 13px; }
|
||||||
|
.dhcp-toggle input:checked + .toggle-track i { left: 13px; }
|
||||||
|
.connect-button { width: calc(100% - 16px); min-height: 32px; flex: 0 0 32px; margin: 3px 8px 5px; gap: 6px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.connect-button svg { width: 15px; height: 15px; }
|
||||||
|
.connection-summary { display: none; }
|
||||||
|
|
||||||
|
.traffic-strip { min-height: 42px; grid-template-columns: 72px 1fr 1fr; }
|
||||||
|
.traffic-device { gap: 4px; padding: 5px 6px; font-size: 10px; }
|
||||||
|
.traffic-device svg { width: 13px; height: 13px; }
|
||||||
|
.traffic-device span { display: none; }
|
||||||
|
.traffic-device strong { font-size: 11px; }
|
||||||
|
.traffic-metric { grid-template-columns: auto 1fr; gap: 1px 4px; padding: 4px 5px; }
|
||||||
|
.traffic-metric span { gap: 2px; font-size: 9px; }
|
||||||
|
.traffic-metric span svg { width: 11px; height: 11px; }
|
||||||
|
.traffic-metric strong { font-size: 11px; }
|
||||||
|
.traffic-metric small { display: none; }
|
||||||
|
.traffic-strip.pending { min-height: 42px; padding-right: 7px; }
|
||||||
|
.traffic-strip.pending .traffic-device { padding: 5px 6px; }
|
||||||
|
.traffic-strip.pending > strong { font-size: 10px; }
|
||||||
|
|
||||||
|
.secondary-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 6px; min-height: 34px; }
|
||||||
|
.advanced-section, .log-section { min-width: 0; height: 34px; margin: 0; border-radius: 6px; }
|
||||||
|
.advanced-toggle, .log-toggle { min-height: 32px; height: 32px; padding: 0 8px; border-radius: 5px; font-size: 11px; }
|
||||||
|
.advanced-toggle span, .log-toggle span { min-width: 0; gap: 5px; white-space: nowrap; }
|
||||||
|
.advanced-toggle span svg, .log-toggle span svg { width: 14px; height: 14px; flex: 0 0 auto; }
|
||||||
|
.advanced-toggle > svg, .log-toggle > svg { width: 14px; height: 14px; flex: 0 0 auto; }
|
||||||
|
.log-toggle small { min-width: 17px; height: 16px; padding: 0 4px; font-size: 9px; }
|
||||||
|
|
||||||
|
.advanced-section:has(.advanced-fields), .log-section:has(.log-content) { position: fixed; z-index: 20; inset: 42px 0 0; width: 100%; height: auto; padding: 8px 10px; overflow: hidden; background: var(--canvas); border: 0; border-radius: 0; }
|
||||||
|
.advanced-section:has(.advanced-fields) .advanced-toggle, .log-section:has(.log-content) .log-toggle { min-height: 34px; height: 34px; padding: 0 9px; background: rgba(255, 255, 255, .72); border: 1px solid var(--line); border-radius: 6px; }
|
||||||
|
.advanced-fields { grid-template-columns: 1fr; gap: 6px; padding: 8px 4px 0; }
|
||||||
|
.advanced-fields label { gap: 4px; font-size: 11px; }
|
||||||
|
.advanced-fields input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
|
||||||
|
|
||||||
|
.log-content { padding: 8px 0 0; }
|
||||||
|
.log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; }
|
||||||
|
.log-toolbar > span { font-size: 11px; }
|
||||||
|
.export-log { min-height: 28px; gap: 4px; padding: 0 7px; font-size: 11px; }
|
||||||
|
.export-log svg { width: 13px; height: 13px; }
|
||||||
|
.empty-log { padding: 16px 10px; border-radius: 5px; font-size: 11px; line-height: 1.4; }
|
||||||
|
.log-list { max-height: none; overflow: hidden; border-radius: 4px; }
|
||||||
|
.log-list li { min-height: 26px; grid-template-columns: 56px minmax(0, 1fr); gap: 5px; padding: 4px 6px; font-size: 10px; }
|
||||||
|
.log-list li:nth-child(n + 5) { display: none; }
|
||||||
|
.log-list li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.coming-soon { min-height: 0; height: calc(100% - 41px); padding: 16px; border-radius: 6px; }
|
||||||
|
.coming-soon .coming-icon { width: 34px; height: 34px; margin-bottom: 8px; border-radius: 7px; }
|
||||||
|
.coming-soon .coming-icon svg { width: 20px; height: 20px; }
|
||||||
|
.coming-soon h1 { margin-bottom: 6px; font-size: 18px; }
|
||||||
|
.coming-soon p:last-child { font-size: 12px; }
|
||||||
|
.notice { right: 8px; bottom: 8px; max-width: calc(100vw - 16px); padding: 9px 10px; border-radius: 5px; font-size: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
})
|
||||||