Compare commits
21 Commits
v0.1.0-rc9
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c4eb7f4a | |||
| 9ea777d7b7 | |||
| 0a85979142 | |||
| ea4202d4a3 | |||
| c48bd96369 | |||
| 1c1ab566d9 | |||
| 4b985bb7f0 | |||
| acd5291604 | |||
| 36e788402a | |||
| 52ce1e0aa7 | |||
| c83f942716 | |||
| b6619ef53b | |||
| 13c32c90d5 | |||
| 15495d41ea | |||
| 9d174d8db1 | |||
| 19f851afb0 | |||
| 9bdbcd4d88 | |||
| 113616b039 | |||
| 5da30ad058 | |||
| 7306f6be6d | |||
| 15e4156802 |
+50
-10
@@ -17,25 +17,41 @@ concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We deliberately avoid actions/cache@v4 and actions/upload-artifact@v4 in this
|
||||
# workflow. They are Node.js-based and the runner image (golang:*) has no Node.
|
||||
# Installing Node would add ~10s per job for every run; for now we accept
|
||||
# that go module downloads happen on each invocation (~20-30s once warm).
|
||||
# Real release artifacts are produced by release.yml via curl + Forgejo API.
|
||||
# Node.js is installed at the start of every job so actions/cache@v4
|
||||
# (which uses Node) can run. Installing Node costs ~10s; the resulting
|
||||
# Go module + apt cache restore typically saves 30-60s on warm runs.
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go
|
||||
steps:
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
|
||||
# we have to read it from secrets and surface it explicitly.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test with race
|
||||
@@ -50,15 +66,33 @@ jobs:
|
||||
runs-on: go
|
||||
needs: test
|
||||
steps:
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
|
||||
# we have to read it from secrets and surface it explicitly.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
- name: Cross-compile drover.exe (windows/amd64)
|
||||
env:
|
||||
GOOS: windows
|
||||
@@ -68,7 +102,13 @@ jobs:
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
BUILD_DATE="$(date -u +%Y-%m-%d)"
|
||||
mkdir -p bin
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
# -H=windowsgui = subsystem WINDOWS, double-click no longer
|
||||
# flashes a console window. main.go calls AttachConsole on
|
||||
# startup so CLI invocations from cmd/PowerShell still print
|
||||
# to the parent terminal.
|
||||
# -tags desktop,production is REQUIRED by Wails (see release.yml).
|
||||
go build -trimpath -tags "desktop,production" \
|
||||
-ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=dev-${SHORT_SHA} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
@@ -16,6 +16,20 @@ jobs:
|
||||
release:
|
||||
runs-on: go
|
||||
steps:
|
||||
# Debian-based Docker images ship /etc/apt/apt.conf.d/docker-clean
|
||||
# which deletes /var/cache/apt/archives after every apt-get install.
|
||||
# That defeats actions/cache for the apt cache; disable it before
|
||||
# any apt operations.
|
||||
- name: Disable apt auto-clean (preserve cache for actions/cache)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||
|
||||
- name: Install Node (required by actions/cache)
|
||||
run: |
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends nodejs >/dev/null
|
||||
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,6 +38,24 @@ jobs:
|
||||
git -C /tmp/src checkout "$GITHUB_SHA"
|
||||
cp -a /tmp/src/. .
|
||||
|
||||
# Cache Go modules — saves ~10-20s on warm runs.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: go-${{ runner.os }}-
|
||||
|
||||
# NOTE: actions/cache for the apt archive (~300 MB) is disabled. The
|
||||
# save step works (~28s in v0.1.4) but restore times out on the
|
||||
# next run — Gitea's cache server can't push 300 MB back fast
|
||||
# enough. The Wine + Inno Setup install stays at ~1m20s. The
|
||||
# right fix is a pre-baked Docker image (golang:1.25 + wine +
|
||||
# innoextract + xauth + wine32:i386) pushed to git.okcu.io as
|
||||
# the job's container.image. Tracked as future work.
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -42,7 +74,15 @@ jobs:
|
||||
BUILD_DATE="$(date -u +%Y-%m-%d)"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
# -H=windowsgui makes drover.exe a GUI subsystem binary so the
|
||||
# double-click experience doesn't flash a console window. main.go
|
||||
# calls AttachConsole on startup so CLI runs still print to the
|
||||
# parent terminal when launched from cmd/PowerShell.
|
||||
# -tags desktop,production is REQUIRED by Wails — otherwise the
|
||||
# binary aborts at startup with a "Wails applications will not
|
||||
# build without the correct build tags" MessageBox.
|
||||
go build -trimpath -tags "desktop,production" \
|
||||
-ldflags="-s -w -H=windowsgui \
|
||||
-X main.Version=${{ steps.version.outputs.version }} \
|
||||
-X main.Commit=${SHORT_SHA} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
|
||||
+4
-3
@@ -8,10 +8,11 @@
|
||||
*.out
|
||||
|
||||
# Wails
|
||||
/internal/frontend/node_modules/
|
||||
/internal/frontend/dist/
|
||||
/internal/frontend/wailsjs/
|
||||
/internal/gui/frontend/node_modules/
|
||||
/internal/gui/frontend/dist/
|
||||
.wails/
|
||||
# wailsjs/ is generated, but we keep it tracked because we hand-write
|
||||
# bindings/App.js for the gui-package layout (see comment in that file)
|
||||
|
||||
# IDE
|
||||
/.idea/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func autoUpdateOnStartup() {}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/updater"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// autoUpdateOnStartup silently checks for and applies updates whenever
|
||||
// drover.exe starts as a GUI app (no CLI subcommand). Chrome-style: no
|
||||
// prompt, no progress bar, no questions — if an update is available we
|
||||
// download, verify, apply, and re-launch. The user just sees the new
|
||||
// version's window appear instead of the old one.
|
||||
//
|
||||
// Network failures, server outages, slow downloads, and "no updates
|
||||
// available" are silent fall-throughs — startup must never block on them.
|
||||
//
|
||||
// Two split contexts:
|
||||
// - check (8s) — quick HEAD-equivalent against the releases API
|
||||
// - apply (60s) — actual download + sha256 + atomic replace
|
||||
func autoUpdateOnStartup() {
|
||||
src := updater.NewForgejoSource("git.okcu.io", "root", "drover-go", "windows-amd64.exe")
|
||||
|
||||
checkCtx, cancelCheck := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
rel, hasUpdate, err := updater.CheckForUpdate(checkCtx, src, Version)
|
||||
cancelCheck()
|
||||
if err != nil || !hasUpdate {
|
||||
return
|
||||
}
|
||||
|
||||
applyCtx, cancelApply := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelApply()
|
||||
if err := updater.ApplyUpdate(applyCtx, rel, nil); err != nil {
|
||||
// Apply failed — surface this one (sha mismatch, write error,
|
||||
// disk full are not silent-fail cases). The user can keep using
|
||||
// the current version after dismissing the dialog.
|
||||
errorDialog(fmt.Sprintf("Update to %s failed: %v\n\nContinuing on current version.", rel.TagName, err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := relaunchSelf(); err != nil {
|
||||
errorDialog(fmt.Sprintf("Update applied but re-launch failed: %v\n\nPlease restart drover.exe manually.", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Successfully spawned the new version — exit cleanly so it takes over.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func errorDialog(msg string) {
|
||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||
messageBox := user32.NewProc("MessageBoxW")
|
||||
|
||||
bodyW, _ := windows.UTF16PtrFromString(msg)
|
||||
titleW, _ := windows.UTF16PtrFromString("Drover-Go — Error")
|
||||
|
||||
// MB_OK | MB_ICONERROR | MB_TOPMOST
|
||||
const flags = 0x00000000 | 0x00000010 | 0x00040000
|
||||
|
||||
messageBox.Call(0, uintptr(unsafe.Pointer(bodyW)), uintptr(unsafe.Pointer(titleW)), flags)
|
||||
}
|
||||
|
||||
// relaunchSelf starts a fresh copy of the (now-updated) executable in the
|
||||
// background and returns. The caller is expected to os.Exit(0) immediately
|
||||
// after — the OS handles the brief overlap fine, and the new process inherits
|
||||
// nothing from us beyond the working directory and arguments.
|
||||
func relaunchSelf() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate self: %w", err)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func attachToParentConsole() {}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// attachToParentConsole reconnects stdin/stdout/stderr to the console of the
|
||||
// process that launched us, if any. It is a no-op when the binary was started
|
||||
// from Explorer (double-click) — there is no parent console to attach to.
|
||||
//
|
||||
// Why we need this: the binary is built with -H=windowsgui, which means
|
||||
// Windows treats it as a GUI application and does NOT allocate a console
|
||||
// when it starts. That gives a clean double-click experience (no flashing
|
||||
// console window). But it also disconnects fmt.Println output when the
|
||||
// user runs us from cmd.exe / PowerShell. Calling AttachConsole here
|
||||
// re-attaches us to the parent's console so CLI output works as expected.
|
||||
func attachToParentConsole() {
|
||||
const ATTACH_PARENT_PROCESS = ^uint32(0) // (DWORD)-1
|
||||
|
||||
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
|
||||
attachConsole := kernel32.NewProc("AttachConsole")
|
||||
|
||||
r, _, _ := attachConsole.Call(uintptr(ATTACH_PARENT_PROCESS))
|
||||
if r == 0 {
|
||||
// AttachConsole failed — most likely there is no parent console
|
||||
// (we were started from Explorer). That's fine: GUI mode is the
|
||||
// default UX and stdout/stderr just go nowhere.
|
||||
return
|
||||
}
|
||||
|
||||
// Re-bind os.Stdout / os.Stderr to the freshly-attached console.
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdout = os.NewFile(uintptr(h), "stdout")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
|
||||
os.Stderr = os.NewFile(uintptr(h), "stderr")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdin = os.NewFile(uintptr(h), "stdin")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func showTestWindow() {
|
||||
fmt.Printf("Drover-Go v%s — test window unavailable on non-Windows builds\n", Version)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// showTestWindow displays a native Win32 MessageBox with build info.
|
||||
// The intent is to give end-users a visual smoke-test on first run:
|
||||
// double-click drover.exe (or run `drover gui`) and see that:
|
||||
// 1. the binary actually launches on Windows,
|
||||
// 2. the embedded version metadata is correct,
|
||||
// 3. the process can talk to user32.dll (i.e. the runtime is healthy).
|
||||
//
|
||||
// This is *not* the production GUI — that comes later via Wails. Here we
|
||||
// purposely use only stdlib + golang.org/x/sys/windows so this works
|
||||
// before any Wails/CGO machinery is wired up.
|
||||
func showTestWindow() {
|
||||
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||
messageBox := user32.NewProc("MessageBoxW")
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"Drover-Go v%s\n\n"+
|
||||
"Commit: %s\n"+
|
||||
"Build: %s\n"+
|
||||
"Go: %s\n"+
|
||||
"Arch: %s/%s\n\n"+
|
||||
"OK — the binary launched and the Windows API is reachable.",
|
||||
Version, Commit, BuildDate, runtime.Version(), runtime.GOOS, runtime.GOARCH,
|
||||
)
|
||||
title := fmt.Sprintf("Drover-Go v%s — test window", Version)
|
||||
|
||||
bodyW, _ := windows.UTF16PtrFromString(body)
|
||||
titleW, _ := windows.UTF16PtrFromString(title)
|
||||
|
||||
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND
|
||||
const flags = 0x00000000 | 0x00000040 | 0x00010000
|
||||
|
||||
messageBox.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(bodyW)),
|
||||
uintptr(unsafe.Pointer(titleW)),
|
||||
flags,
|
||||
)
|
||||
}
|
||||
+17
-3
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/gui"
|
||||
"git.okcu.io/root/drover-go/internal/updater"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,12 @@ var (
|
||||
var configPath string
|
||||
|
||||
func main() {
|
||||
// On Windows the binary is linked with -H=windowsgui so a double-click
|
||||
// doesn't flash a console window. When the user runs us from cmd or
|
||||
// PowerShell we still want stdout/stderr to land in their terminal —
|
||||
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
|
||||
attachToParentConsole()
|
||||
|
||||
// Inject our build version so the updater package can stamp it on the
|
||||
// User-Agent header it sends to git.okcu.io.
|
||||
updater.SetVersion(Version)
|
||||
@@ -39,6 +46,14 @@ func newRootCmd() *cobra.Command {
|
||||
Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, BuildDate),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
// No subcommand and no flags = end-user double-clicked the exe.
|
||||
// First do a quick silent update check (no-op if offline or
|
||||
// already current); if an update is available we apply it and
|
||||
// re-launch ourselves. Then we open the Wails-backed GUI.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
autoUpdateOnStartup()
|
||||
return gui.Run(Version)
|
||||
},
|
||||
}
|
||||
|
||||
// Custom version template: "drover-go vX.Y.Z (commit abc1234, built 2026-05-01)".
|
||||
@@ -57,10 +72,9 @@ func newRootCmd() *cobra.Command {
|
||||
func newGUICmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "gui",
|
||||
Short: "Show a test window (smoke check that the binary launches on this machine)",
|
||||
Short: "Open the Drover-Go window (same as launching the exe with no args)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
showTestWindow()
|
||||
return nil
|
||||
return gui.Run(Version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go · Fluent</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
*{ box-sizing: border-box; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-fluent-live.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="fluent" title="01 · Fluent / Win11 native"
|
||||
subtitle="Системные шрифты Segoe UI Variable, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
|
||||
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
function tryMount() {
|
||||
if (window.FluentWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else { setTimeout(tryMount, 50); }
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,249 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — Desktop GUI explorations</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<template id="__bundler_thumbnail" data-bg-color="#f0eee9">
|
||||
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="800" fill="#f0eee9"/>
|
||||
<g transform="translate(600 400)">
|
||||
<circle r="160" fill="none" stroke="#2a7d76" stroke-width="22"/>
|
||||
<path d="M -90 -50 L 0 40 L 90 -50" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 0 40 L 0 130" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="600" y="660" text-anchor="middle" font-family="ui-monospace, monospace" font-size="36" font-weight="700" fill="#2a251f" letter-spacing="6">DROVER-GO</text>
|
||||
</svg>
|
||||
</template>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-classic.jsx"></script>
|
||||
<script type="text/babel" src="drover-minimal.jsx"></script>
|
||||
<script type="text/babel" src="drover-glass.jsx"></script>
|
||||
<script type="text/babel" src="drover-brutalist.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
// For each variant we want to show several states side-by-side. To get
|
||||
// distinct states without manual interaction in the artboards, we use a
|
||||
// small "preset" wrapper that performs scripted setup against the variant's
|
||||
// useDrover hook by lifting it up: we pass an `initial` form and an
|
||||
// imperative control prop that the variant doesn't need — instead we just
|
||||
// re-mount with different presets to land at idle / checking / passed /
|
||||
// active / failed / failed+active states.
|
||||
//
|
||||
// We achieve this with a small Driver component that wraps the variant and
|
||||
// drives it through actions on mount.
|
||||
|
||||
function Driver({ Component, mode, preset }) {
|
||||
// preset: 'idle' | 'checking' | 'passed' | 'active' | 'failed' | 'active-warn' | 'auth-passed'
|
||||
const ref = React.useRef(null);
|
||||
const [boot, setBoot] = React.useState(false);
|
||||
React.useEffect(() => { setBoot(true); }, []);
|
||||
return (
|
||||
<PresetHost Component={Component} mode={mode} preset={preset}/>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-implements the variant render but injects a controller that drives
|
||||
// the inner useDrover state via portal. Simpler approach: forward an
|
||||
// `onReady` ref to the inner D state — but the variants don't expose D.
|
||||
// Easiest working approach: wrap each variant and intercept by re-mounting
|
||||
// through React + run actions via a *parallel* useDrover instance is not
|
||||
// possible. So: we expose a tiny "preset" API directly inside each variant
|
||||
// via the `initial` argument that mutates on first paint.
|
||||
|
||||
// The variants all accept an `initial` form. We extend the contract: the
|
||||
// variants now also accept `__preset` and `__scenario` to seed the state
|
||||
// machine on mount. To do that without rewriting all four files, we use a
|
||||
// shim: PresetVariant wraps a variant and uses an effect on the first
|
||||
// render of the inner DOM to dispatch synthetic clicks/programmatic state
|
||||
// via a "Programmatic" pattern: each variant exposes its useDrover-result
|
||||
// through a global hook map. Cleaner: just patch useDrover to broadcast.
|
||||
|
||||
// ── implementation: monkey-patch useDrover to record latest D per slot ──
|
||||
if (!window.__drvSlots) {
|
||||
const orig = window.useDrover;
|
||||
window.__drvSlots = new Map();
|
||||
window.useDrover = function(initial) {
|
||||
const D = orig(initial);
|
||||
const slot = (initial && initial.__slot) || null;
|
||||
React.useEffect(() => {
|
||||
if (slot) window.__drvSlots.set(slot, D);
|
||||
});
|
||||
return D;
|
||||
};
|
||||
}
|
||||
|
||||
function PresetHost({ Component, mode, preset }) {
|
||||
const slot = React.useId();
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const wait = () => new Promise(r => requestAnimationFrame(r));
|
||||
(async () => {
|
||||
await wait(); await wait();
|
||||
const D = window.__drvSlots.get(slot);
|
||||
if (!D || cancelled) return;
|
||||
if (preset === 'idle') return;
|
||||
if (preset === 'checking') {
|
||||
// Set scenario, kick check, but don't await — we want it mid-flight
|
||||
D.runCheck();
|
||||
return;
|
||||
}
|
||||
if (preset === 'passed') {
|
||||
D.setLogsOpen?.(false);
|
||||
D.runCheck();
|
||||
// wait for completion
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'auth-passed') {
|
||||
D.update({ auth: true, login: 'drover', password: 'secret123' });
|
||||
await wait();
|
||||
const cur1 = window.__drvSlots.get(slot);
|
||||
cur1.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'failed') {
|
||||
// We need to switch scenario before runCheck.
|
||||
D.setScenario?.('udpFail');
|
||||
await wait();
|
||||
const cur1 = window.__drvSlots.get(slot);
|
||||
cur1.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (preset === 'active') {
|
||||
D.runCheck();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
// let stats accumulate for a bit
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
if (preset === 'active-warn') {
|
||||
D.setScenario?.('udpFail');
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck?.();
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const cur = window.__drvSlots.get(slot);
|
||||
if (cur?.phase === 'checked') break;
|
||||
}
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; window.__drvSlots.delete(slot); };
|
||||
}, [preset, slot]);
|
||||
return <Component mode={mode} initial={{ __slot: slot }} />;
|
||||
}
|
||||
|
||||
// ─── Section header card with reasoning ────────────────────────────────
|
||||
function ReasoningCard() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 520, padding: '16px 18px',
|
||||
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4, letterSpacing: 0.2 }}>Drover-Go · GUI explorations</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Один экран · 480×640 · фикс. размер для Win11. Четыре стилистики, dark + light, по нескольку состояний:
|
||||
idle → checking → all-passed → active. Отдельные борды показывают сценарий с провалом UDP
|
||||
и активный режим с предупреждением. Все артборды — живые: кликабельны, имеют focus / hover / disabled.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
const variants = [
|
||||
{ id: 'classic', Component: window.ClassicWindow, title: 'Classic devtool',
|
||||
subtitle: 'Hairlines, dense mono metrics. Сдержанный teal-акцент. Под аудиторию sysadmin/devs.' },
|
||||
{ id: 'minimal', Component: window.MinimalWindow, title: 'Minimal · Fluent',
|
||||
subtitle: 'Под Windows 11. Карточки с микро-границей, мягкий slate-blue, system feel.' },
|
||||
{ id: 'glass', Component: window.GlassWindow, title: 'Glassmorphism',
|
||||
subtitle: 'Frosted blur поверх градиента, glow на active. Для тех, кому хочется vibe.' },
|
||||
{ id: 'brutal', Component: window.BrutWindow, title: 'Brutalist',
|
||||
subtitle: 'Жёсткие границы, без скруглений, моно. Acid-lime акцент, hard-shadow на кнопках.' },
|
||||
];
|
||||
const presets = [
|
||||
{ id: 'idle', label: 'idle (dark)', mode: 'dark', preset: 'idle' },
|
||||
{ id: 'idle-light', label: 'idle (light)', mode: 'light', preset: 'idle' },
|
||||
{ id: 'checking', label: 'checking… (dark)', mode: 'dark', preset: 'checking' },
|
||||
{ id: 'passed', label: 'all passed (dark)', mode: 'dark', preset: 'passed' },
|
||||
{ id: 'auth-passed', label: 'with auth (light)', mode: 'light', preset: 'auth-passed' },
|
||||
{ id: 'failed', label: 'UDP failed (light)', mode: 'light', preset: 'failed' },
|
||||
{ id: 'active', label: 'active proxy (dark)', mode: 'dark', preset: 'active' },
|
||||
{ id: 'active-warn', label: 'active · UDP warn (light)', mode: 'light', preset: 'active-warn' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="About" subtitle="Brief & approach">
|
||||
<DCArtboard id="intro-card" label="reasoning" width={520} height={140}>
|
||||
<ReasoningCard/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
{variants.map(v => (
|
||||
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
|
||||
{presets.map(p => (
|
||||
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
|
||||
<PresetHost Component={v.Component} mode={p.mode} preset={p.preset}/>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
))}
|
||||
</DesignCanvas>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait until all script files loaded
|
||||
function tryMount() {
|
||||
if (window.ClassicWindow && window.MinimalWindow && window.GlassWindow && window.BrutWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — Shortlist</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
*{ box-sizing: border-box; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-classic.jsx"></script>
|
||||
<script type="text/babel" src="drover-fluent-live.jsx"></script>
|
||||
<script type="text/babel" src="drover-wizard-live.jsx"></script>
|
||||
<script type="text/babel" src="drover-hero-live.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="Drover-Go · Shortlist" subtitle="Four directions you picked. Classic devtool is fully interactive (try it). The other three are sketches.">
|
||||
<DCArtboard id="intro-card" label="about" width={520} height={120}>
|
||||
<div data-dc-static style={{
|
||||
width: 520, padding: '16px 18px',
|
||||
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Shortlist · 4 directions · все живые</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Все 4 варианта — полноценный интерактив. Кликай, заполняй форму, жми Check → Start.
|
||||
Разные UI-парадигмы (классика / Win11 / стэппер / VPN-кнопка), но одна и та же стейт-машина.
|
||||
</div>
|
||||
</div>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="classic" title="Classic devtool" subtitle="Hairlines, dense mono metrics, sober teal. Полноценный интерактив — кликай, тестируй. Dark + light.">
|
||||
<DCArtboard id="classic-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.ClassicWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="classic-light" label="light · interactive" width={W} height={H}>
|
||||
<window.ClassicWindow mode="light" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="fluent" title="01 · Fluent / Win11 native" subtitle="Системные шрифты, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
|
||||
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.FluentWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="wizard" title="03 · Wizard / stepper" subtitle="3 шага: Configure → Verify → Connect. Шаги переключаются автоматически по состоянию (idle/checking/active). Light + dark.">
|
||||
<DCArtboard id="wizard-light" label="light · interactive" width={W} height={H}>
|
||||
<window.WizardWindow mode="light" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="wizard-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.WizardWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection id="hero" title="07 · Big toggle (VPN-style)" subtitle="Большая круглая кнопка — нажал, проверка пошла, при успехе авто-старт. Поля прокси — в раскрывающемся блоке снизу.">
|
||||
<DCArtboard id="hero-dark" label="dark · interactive" width={W} height={H}>
|
||||
<window.HeroWindow mode="dark" />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="hero-light" label="light · interactive" width={W} height={H}>
|
||||
<window.HeroWindow mode="light" />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
function tryMount() {
|
||||
if (window.ClassicWindow && window.FluentWindow && window.WizardWindow && window.HeroWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Drover-Go · Style sketches</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=DM+Mono:wght@400;500&family=Instrument+Serif&family=Fraunces:opsz,wght@9..144,400;9..144,600&family=VT323&family=Major+Mono+Display&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0eee9;font-family:'Inter',system-ui,sans-serif;color:#1a1a1a;-webkit-font-smoothing:antialiased}
|
||||
*{box-sizing:border-box}
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-sketches.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<SketchesApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Drover-Go — round 2 (dark only)</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="drover-shared.jsx"></script>
|
||||
<script type="text/babel" src="drover-compact.jsx"></script>
|
||||
<script type="text/babel" src="drover-cli.jsx"></script>
|
||||
<script type="text/babel" src="drover-studio.jsx"></script>
|
||||
<script type="text/babel" src="drover-workshop.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
// Patch useDrover to record latest D per slot id (for preset driving).
|
||||
if (!window.__drvSlots) {
|
||||
const orig = window.useDrover;
|
||||
window.__drvSlots = new Map();
|
||||
window.useDrover = function(initial) {
|
||||
const D = orig(initial);
|
||||
const slot = (initial && initial.__slot) || null;
|
||||
React.useEffect(() => { if (slot) window.__drvSlots.set(slot, D); });
|
||||
return D;
|
||||
};
|
||||
}
|
||||
|
||||
function PresetHost({ Component, preset }) {
|
||||
const slot = React.useId();
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const wait = () => new Promise(r => requestAnimationFrame(r));
|
||||
(async () => {
|
||||
await wait(); await wait();
|
||||
const D = window.__drvSlots.get(slot);
|
||||
if (!D || cancelled) return;
|
||||
if (preset === 'idle') return;
|
||||
if (preset === 'checking') { D.runCheck(); return; }
|
||||
const waitChecked = async () => {
|
||||
for (let i = 0; i < 60 && !cancelled; i++) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
if (window.__drvSlots.get(slot)?.phase === 'checked') break;
|
||||
}
|
||||
};
|
||||
if (preset === 'passed') { D.runCheck(); await waitChecked(); return; }
|
||||
if (preset === 'auth-passed') {
|
||||
D.update({ auth: true, login: 'drover', password: 'secret123' });
|
||||
await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck();
|
||||
await waitChecked(); return;
|
||||
}
|
||||
if (preset === 'failed') {
|
||||
D.setScenario?.('udpFail'); await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck();
|
||||
await waitChecked(); return;
|
||||
}
|
||||
if (preset === 'active') {
|
||||
D.runCheck(); await waitChecked(); await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
if (preset === 'active-warn') {
|
||||
D.setScenario?.('udpFail'); await wait();
|
||||
window.__drvSlots.get(slot)?.runCheck(); await waitChecked(); await wait();
|
||||
window.__drvSlots.get(slot)?.startProxy?.();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return;
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; window.__drvSlots.delete(slot); };
|
||||
}, [preset, slot]);
|
||||
return <Component initial={{ __slot: slot }} />;
|
||||
}
|
||||
|
||||
function ReasoningCard() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 540, padding: '16px 18px', background: '#ffffff',
|
||||
border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
|
||||
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Drover-Go · round 2</div>
|
||||
<div style={{ color: '#5c5650' }}>
|
||||
Только dark. 4 новых направления, более сдержанных и функциональных:
|
||||
Compact Pro (плотный, мониторинговый), CLI (терминал/фосфор),
|
||||
Studio (Linear-ish, спокойный), Workshop (industrial slate + amber).
|
||||
Каждый — 6 состояний: idle → checking → all-passed → with-auth → UDP-failed → active → active-warn.
|
||||
Отметь, какие нравятся — расширим в light + tweaks.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { DesignCanvas, DCSection, DCArtboard } = window;
|
||||
const W = 480, H = 640;
|
||||
const variants = [
|
||||
{ id: 'compact', Component: window.CompactWindow,
|
||||
title: '1 · Compact Pro', subtitle: 'Плотный, мониторинговый. Wireshark-ish. Минимум воздуха.' },
|
||||
{ id: 'cli', Component: window.CliWindow,
|
||||
title: '2 · Cyber CLI', subtitle: 'Фосфорный терминал. CRT-сканлайны. Всё через `> command --flag`.' },
|
||||
{ id: 'studio', Component: window.StudioWindow,
|
||||
title: '3 · Studio', subtitle: 'Современный, calm, Linear-ish. Тонкие карты, синий accent.' },
|
||||
{ id: 'workshop', Component: window.WorkshopWindow,
|
||||
title: '4 · Workshop', subtitle: 'Industrial slate + amber. Шильдики, лево-акцентный border.' },
|
||||
];
|
||||
const presets = [
|
||||
{ id: 'idle', label: 'idle' },
|
||||
{ id: 'checking', label: 'checking…' },
|
||||
{ id: 'passed', label: 'all passed' },
|
||||
{ id: 'auth-passed', label: 'with auth · passed' },
|
||||
{ id: 'failed', label: 'UDP failed' },
|
||||
{ id: 'active', label: 'active proxy' },
|
||||
{ id: 'active-warn', label: 'active · UDP warn' },
|
||||
];
|
||||
|
||||
return (
|
||||
<DesignCanvas>
|
||||
<DCSection id="intro" title="About" subtitle="Round 2 — dark only">
|
||||
<DCArtboard id="intro-card" label="reasoning" width={540} height={150}>
|
||||
<ReasoningCard/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
{variants.map(v => (
|
||||
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
|
||||
{presets.map(p => (
|
||||
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
|
||||
<PresetHost Component={v.Component} preset={p.id}/>
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
))}
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
function tryMount() {
|
||||
if (window.CompactWindow && window.CliWindow && window.StudioWindow && window.WorkshopWindow && window.DesignCanvas) {
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
} else {
|
||||
setTimeout(tryMount, 50);
|
||||
}
|
||||
}
|
||||
tryMount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,622 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||
// State persists to a .design-canvas.state.json sidecar via the host
|
||||
// bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const srcIds = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (!aid) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if (e.ctrlKey) {
|
||||
// trackpad pinch (or explicit ctrl+wheel)
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
return (
|
||||
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px 56px' }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
<div className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// drover-brutalist.jsx — Variant 4: Brutalist.
|
||||
// Hard borders, no rounding. Heavy mono. Monochrome + one acid lime accent.
|
||||
// Block-style "DOS-meets-zine" feeling. All-caps labels.
|
||||
|
||||
const BrutTheme = {
|
||||
d: {
|
||||
bg: '#0c0c0c', panel: '#0c0c0c', alt: '#161616',
|
||||
border: '#f4f4f4', borderDim: '#3a3a3a',
|
||||
text: '#f4f4f4', dim: '#a8a8a8', dimmer: '#6a6a6a',
|
||||
accent: '#c6ff00', danger: '#ff3a52', warn: '#ffaa00', pass: '#7eff84', skip: '#888',
|
||||
inputBg: '#0c0c0c', primaryFg: '#0c0c0c',
|
||||
},
|
||||
l: {
|
||||
bg: '#f4f1ea', panel: '#f4f1ea', alt: '#e9e5d8',
|
||||
border: '#0c0c0c', borderDim: '#bfb9a8',
|
||||
text: '#0c0c0c', dim: '#3a3a3a', dimmer: '#7a7568',
|
||||
accent: '#7d8f00', danger: '#b81e34', warn: '#a86c00', pass: '#1a7e2c', skip: '#7a7568',
|
||||
inputBg: '#f4f1ea', primaryFg: '#f4f1ea',
|
||||
},
|
||||
};
|
||||
|
||||
function BrutWindow({ mode = 'dark', initial }) {
|
||||
const t = BrutTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const isActive = D.phase === 'active';
|
||||
const fontMono = '"JetBrains Mono","IBM Plex Mono","Space Mono",ui-monospace,monospace';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontMono, fontSize: 12, overflow: 'hidden',
|
||||
border: `2px solid ${t.border}`, boxSizing:'border-box',
|
||||
}}>
|
||||
<BrutTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto' }}>
|
||||
{/* form */}
|
||||
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
|
||||
<BrutLabel t={t}>// SOCKS5 PROXY</BrutLabel>
|
||||
<div style={{ display:'flex', gap: 8 }}>
|
||||
<BField t={t} label="HOST" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 / example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
|
||||
</BField>
|
||||
<BField t={t} label="PORT" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
|
||||
</BField>
|
||||
</div>
|
||||
<div style={{ height: 10 }}/>
|
||||
<BrutCheckbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('br-login')?.focus(),30); }}>
|
||||
AUTHENTICATION
|
||||
</BrutCheckbox>
|
||||
<div style={{ display:'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.4,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<BField t={t} label="LOGIN" style={{ flex: 1 }}>
|
||||
<input id="br-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
|
||||
</BField>
|
||||
<BField t={t} label="PASSWORD" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="******"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
|
||||
</BField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<BrutPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? '>> CHECKING…' : '>> CHECK CONNECTION'}
|
||||
</BrutPrimary>
|
||||
</div>
|
||||
|
||||
{/* status */}
|
||||
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
|
||||
<BrutLabel t={t}>// STATUS</BrutLabel>
|
||||
<BrutStatus t={t} D={D} palette={palette}/>
|
||||
</div>
|
||||
|
||||
{/* actions */}
|
||||
<div style={{ padding: 14 }}>
|
||||
<div style={{ display:'flex', gap: 8 }}>
|
||||
<BrutStartBtn t={t} D={D}/>
|
||||
<BrutStopBtn t={t} D={D}/>
|
||||
</div>
|
||||
{isActive && <BrutLiveStats t={t} stats={D.stats}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrutLogs t={t} D={D}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrutTitle({ t }) {
|
||||
const cell = { width: 36, height: 28, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
|
||||
borderLeft: `2px solid ${t.border}`, color: t.text };
|
||||
return (
|
||||
<div style={{
|
||||
height: 30, background: t.alt, display:'flex', alignItems:'center',
|
||||
borderBottom: `2px solid ${t.border}`, userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 8, padding:'0 12px', flex:1 }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, background: t.accent, color: '#0c0c0c',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', fontWeight: 700, fontSize: 11,
|
||||
}}>D</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1.5 }}>DROVER-GO</span>
|
||||
<span style={{ fontSize: 10, color: t.dim }}>[v0.4.2]</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell} title="Settings"><window.IconGear color={t.text}/></div>
|
||||
<div style={cell} title="Minimize"><window.IconMin color={t.text}/></div>
|
||||
<div style={{ ...cell, background: t.danger, color: '#fff' }} title="Close"><window.IconClose color="#fff"/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutLabel({ t, children }) {
|
||||
return <div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1.5, marginBottom: 10, color: t.dim }}>{children}</div>;
|
||||
}
|
||||
function BField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10, color: t.dim, fontWeight: 700, letterSpacing: 1 }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function brutInput(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `2px solid ${disabled ? t.borderDim : t.border}`, borderRadius: 0,
|
||||
padding: '7px 9px', fontSize: 12, fontFamily: fontMono, outline:'none',
|
||||
width:'100%', boxSizing:'border-box',
|
||||
};
|
||||
}
|
||||
function BrutCheckbox({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, border: `2px solid ${t.border}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
}}>
|
||||
{checked && <span style={{ fontWeight: 900, color: '#0c0c0c', fontSize: 12, lineHeight: 1 }}>×</span>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function BrutPrimary({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
width:'100%', padding: '10px 12px', border: `2px solid ${t.border}`, borderRadius: 0,
|
||||
background: disabled ? t.alt : t.accent, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, cursor: disabled?'not-allowed':'pointer',
|
||||
fontFamily: 'inherit', textAlign:'left',
|
||||
boxShadow: disabled ? 'none' : `4px 4px 0 ${t.border}`,
|
||||
transition: 'transform .08s, box-shadow .08s',
|
||||
...style,
|
||||
}}
|
||||
onMouseDown={e => { if (!disabled) { e.currentTarget.style.transform='translate(2px,2px)'; e.currentTarget.style.boxShadow=`2px 2px 0 ${t.border}`; }}}
|
||||
onMouseUp={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
|
||||
onMouseLeave={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
function BrutStatus({ t, D, palette }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding: '4px 0' }}>
|
||||
<span style={{ width: 10, height: 10, border: `2px solid ${t.dim}`, background: 'transparent' }}/>
|
||||
<span style={{ color: t.dim, fontSize: 11.5, letterSpacing: 0.5 }}>READY TO CHECK_</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 10 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 700, fontSize: 11.5, letterSpacing: 0.7 }}>RUNNING DIAGNOSTICS…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding: '7px 10px', marginBottom: 10,
|
||||
background: D.lastSummary?.failed === 0 ? t.accent : t.warn,
|
||||
color: '#0c0c0c', fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
|
||||
border: `2px solid ${t.border}`,
|
||||
}}>
|
||||
{D.lastSummary?.failed === 0
|
||||
? '▮ ALL CHECKS PASSED. READY TO START.'
|
||||
: `▮ ${D.lastSummary?.failed}/${D.tests.length} FAILED · SOME FEATURES BROKEN`}
|
||||
</div>}
|
||||
<div style={{ border: `2px solid ${t.border}` }}>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const last = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{ borderBottom: !last ? `1px solid ${t.borderDim}` : 'none' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26, padding: '0 9px' }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 11.5 }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontSize: 10.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim }}>
|
||||
{r?.metric || (state==='running'?'…':'')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.text,
|
||||
}}><window.IconChevron color={t.text} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
borderTop: `1px solid ${t.borderDim}`,
|
||||
background: t.alt, padding: 10, fontSize: 11,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 700, marginBottom: 4, letterSpacing: 0.5 }}>! {r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error}`)}
|
||||
style={{
|
||||
marginTop: 8, background: 'transparent', border: `1.5px solid ${t.border}`,
|
||||
color: t.text, padding: '3px 8px', fontSize: 10, cursor:'pointer',
|
||||
fontFamily: 'inherit', letterSpacing: 0.5, fontWeight: 700,
|
||||
}}>[ COPY ERROR ]</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutStartBtn({ t, D }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 12px', border: `2px solid ${t.border}`,
|
||||
background: warning ? t.warn : t.accent, color: '#0c0c0c',
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
boxShadow: `4px 4px 0 ${t.border}`,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 9, height: 9, background: '#0c0c0c' }}/>
|
||||
●{warning ? ' ACTIVE / UDP-WARN' : ' ACTIVE'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <BrutPrimary t={t} disabled={!ok} onClick={D.startProxy} style={{ flex: 1 }}>{'>> START PROXYING'}</BrutPrimary>;
|
||||
}
|
||||
function BrutStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 12px', border: `2px solid ${enabled?t.border:t.borderDim}`,
|
||||
background: t.bg, color: enabled ? t.text : t.dimmer,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
|
||||
cursor: enabled ? 'pointer':'not-allowed', textAlign:'left',
|
||||
boxShadow: enabled ? `4px 4px 0 ${t.border}` : 'none',
|
||||
}}>{'■ STOP'}</button>
|
||||
);
|
||||
}
|
||||
function BrutLiveStats({ t, stats }) {
|
||||
const C = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontSize: 11, fontWeight: 700 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 9, color: t.dim, fontWeight: 700 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, padding: '7px 10px', border: `2px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
<C icon={<window.IconArrowUp color={t.text}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<C icon={<window.IconArrowDown color={t.text}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<C val={stats.tcp} lbl="TCP"/>
|
||||
<C val={stats.udp} lbl="UDP"/>
|
||||
<C val={window.fmtUptime(stats.uptimeS)} lbl="UP"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BrutLogs({ t, D }) {
|
||||
return (
|
||||
<div style={{ borderTop: `2px solid ${t.border}`, background: t.alt, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '7px 14px', display:'flex', alignItems:'center', gap: 9,
|
||||
background:'transparent', border:'none', color: t.text, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: 'inherit', fontWeight: 700, letterSpacing: 1.2,
|
||||
}}>
|
||||
<window.IconChevron color={t.text} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span>// LOGS</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dim }}>{D.logs.length} LINES</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 14px 7px' }}>
|
||||
{[['COPY ALL', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['CLEAR', D.clearLogs], ['OPEN FILE', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn || undefined} style={{
|
||||
background:'transparent', border:`1.5px solid ${t.border}`, color: t.text,
|
||||
padding: '3px 8px', fontSize: 9.5, fontWeight: 700, letterSpacing: 1, cursor:'pointer',
|
||||
fontFamily:'inherit',
|
||||
}}>[ {l} ]</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '7px 14px', fontSize: 10.5, lineHeight: 1.6, color: t.text }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dim }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{
|
||||
color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700,
|
||||
}}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.BrutWindow = BrutWindow;
|
||||
@@ -0,0 +1,440 @@
|
||||
// drover-classic.jsx — Variant 1: Classic devtool.
|
||||
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
|
||||
// Sober palette: neutral grays + one teal accent for primary action / success.
|
||||
|
||||
const ClassicTheme = {
|
||||
// dark
|
||||
d: {
|
||||
bg: '#1c1d20',
|
||||
chrome: '#15161a',
|
||||
panel: '#22242a',
|
||||
panelAlt: '#1a1c20',
|
||||
border: '#34373d',
|
||||
borderSoft:'#2a2c32',
|
||||
text: '#dde0e6',
|
||||
dim: '#8a8f99',
|
||||
dimmer: '#5b6068',
|
||||
accent: '#3ea99f', // teal
|
||||
accentDim: '#2a7d76',
|
||||
danger: '#d96565',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#15161a',
|
||||
btnBg: '#2c2f36',
|
||||
btnBgH: '#373b43',
|
||||
primaryBg: '#3ea99f',
|
||||
primaryFg: '#0c1a18',
|
||||
},
|
||||
l: {
|
||||
bg: '#f3f4f6',
|
||||
chrome: '#e8eaef',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f8f9fb',
|
||||
border: '#d8dbe1',
|
||||
borderSoft:'#e6e8ec',
|
||||
text: '#1c1f24',
|
||||
dim: '#5c6168',
|
||||
dimmer: '#8a8f97',
|
||||
accent: '#2a7d76',
|
||||
accentDim: '#bdded9',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#2f8c5a',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#ffffff',
|
||||
btnBg: '#ffffff',
|
||||
btnBgH: '#f1f2f5',
|
||||
primaryBg: '#2a7d76',
|
||||
primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function ClassicWindow({ mode = 'dark', initial }) {
|
||||
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
const allChecked = D.phase === 'checked' || D.phase === 'active';
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
|
||||
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
|
||||
}}>
|
||||
{/* ─── title bar ─── */}
|
||||
<ClassicTitleBar t={t} />
|
||||
|
||||
{/* ─── content ─── */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
|
||||
|
||||
{/* Form */}
|
||||
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Field t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
<Field t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Checkbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</Checkbox>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
|
||||
<Field t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
<Field t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</PrimaryBtn>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ height: 18 }} />
|
||||
<SectionLabel t={t}>Status</SectionLabel>
|
||||
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ height: 14 }} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<ClassicStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 14 }} />
|
||||
</div>
|
||||
|
||||
{/* Logs collapsible */}
|
||||
<ClassicLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── pieces ─────────────────────────────────────────────────────────────────
|
||||
function ClassicTitleBar({ t }) {
|
||||
const cellStyle = {
|
||||
width: 38, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color: t.dim, cursor:'pointer',
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', alignItems:'center', WebkitAppRegion:'drag', userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
|
||||
<window.BrandMark size={14} color={t.accent}/>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex', WebkitAppRegion:'no-drag' }}>
|
||||
<div style={cellStyle} title="Settings"><window.IconGear color={t.dim}/></div>
|
||||
<div style={cellStyle} title="Minimize"><window.IconMin color={t.dim}/></div>
|
||||
<div style={{...cellStyle}} title="Close"
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<window.IconClose color={t.dim}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children, t }) {
|
||||
return <div style={{
|
||||
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
color: t.dim, marginBottom: 8,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function Field({ children, label, t, style }) {
|
||||
return (
|
||||
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function inputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
};
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, children, t }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
|
||||
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
style={{
|
||||
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
|
||||
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
|
||||
transition: 'background .12s', ...style,
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── status panel ──────────────────────────────────────────────────────────
|
||||
function ClassicStatus({ t, D, palette, fontMono }) {
|
||||
const idle = D.phase === 'idle';
|
||||
if (idle) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
|
||||
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
|
||||
{/* header */}
|
||||
<div style={{
|
||||
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
|
||||
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={12}/>
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>
|
||||
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
{/* tests */}
|
||||
<div>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const isLast = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{
|
||||
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
|
||||
padding: '6px 12px',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '...' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
|
||||
background: mode_mix(t.danger, t.panel, 0.9), border: `1px solid ${mode_mix(t.danger, t.panel, 0.78)}`,
|
||||
fontSize: 11.5, color: t.text,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={smallBtn(t, fontMono)}>
|
||||
<window.IconCopy color={t.dim}/> copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconBtnStyle(t) {
|
||||
return {
|
||||
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
|
||||
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
|
||||
borderRadius: 2,
|
||||
};
|
||||
}
|
||||
function smallBtn(t, fontMono) {
|
||||
return {
|
||||
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
|
||||
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
|
||||
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
|
||||
function mode_mix(fg, bg, amt) {
|
||||
const a = hexToRgb(fg), b = hexToRgb(bg);
|
||||
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
|
||||
}
|
||||
function hexToRgb(h) {
|
||||
const v = h.replace('#','');
|
||||
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
|
||||
}
|
||||
|
||||
// ─── start/stop ────────────────────────────────────────────────────────────
|
||||
function ClassicStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
|
||||
border: `1px solid ${warning ? t.warn : t.pass}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
|
||||
}}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
|
||||
Start proxying
|
||||
</PrimaryBtn>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
|
||||
}}>
|
||||
Stop
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (icon, val) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{icon}<span>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '6px 10px', borderRadius: 3,
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
{cell(<window.IconArrowUp color={t.dim}/>, window.fmtBytes(stats.up))}
|
||||
{cell(<window.IconArrowDown color={t.dim}/>, window.fmtBytes(stats.down))}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, window.fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── logs ──────────────────────────────────────────────────────────────────
|
||||
function ClassicLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ textTransform:'uppercase' }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
|
||||
<button style={smallBtn(t, fontMono)}
|
||||
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
|
||||
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
|
||||
<button style={smallBtn(t, fontMono)}>open log file</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ClassicWindow = ClassicWindow;
|
||||
@@ -0,0 +1,263 @@
|
||||
// drover-cli.jsx — Cyber CLI: terminal aesthetic, phosphor green, ASCII feel.
|
||||
// All-mono. No round corners. Subtle CRT scanline overlay.
|
||||
|
||||
const CLI = {
|
||||
bg: '#0a0d0a', chrome: '#070a07', panel: '#0d110d', panel2: '#111511',
|
||||
border: '#1f2a1f', borderSoft: '#162016',
|
||||
text: '#bfe7c0', // phosphor green-white
|
||||
dim: '#5a8a5e', dimmer: '#3a5a3d',
|
||||
accent: '#5cf08a', accentDim: '#2a8a44',
|
||||
danger: '#ff6b6b', warn: '#ffd166', pass: '#5cf08a', skip: '#6a8a6e',
|
||||
inputBg: '#0a0e0a',
|
||||
};
|
||||
|
||||
function CliWindow({ initial }) {
|
||||
const t = CLI;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","IBM Plex Mono","Cascadia Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontMono, fontSize: 12, overflow:'hidden', position:'relative',
|
||||
border:'1px solid #000', textShadow: `0 0 1px ${t.accent}30`,
|
||||
}}>
|
||||
{/* CRT scanlines */}
|
||||
<div style={{
|
||||
position:'absolute', inset:0, pointerEvents:'none', zIndex: 5, opacity: 0.18,
|
||||
background: 'repeating-linear-gradient(to bottom, transparent 0, transparent 2px, rgba(0,0,0,.55) 3px, transparent 4px)',
|
||||
}}/>
|
||||
{/* vignette */}
|
||||
<div style={{
|
||||
position:'absolute', inset:0, pointerEvents:'none', zIndex: 4,
|
||||
background: 'radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,.45) 100%)',
|
||||
}}/>
|
||||
|
||||
{/* title bar — like a terminal tab */}
|
||||
<div style={{
|
||||
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', position:'relative', zIndex: 6,
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 11px', flex:1 }}>
|
||||
<span style={{ color: t.accent }}>$</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, letterSpacing: 0.6 }}>drover-go</span>
|
||||
<span style={{ fontSize: 10.5, color: t.dim }}>— ~ — 80×24</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
{['settings','min','close'].map(k => (
|
||||
<span key={k} style={{
|
||||
width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
cursor:'pointer', color: t.dim, fontSize: 11,
|
||||
}}>{k==='close'?'×':k==='min'?'−':'⚙'}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px', position:'relative', zIndex: 6 }}>
|
||||
{/* prompt-style form */}
|
||||
<div style={cliHead(t)}>┌── socks5 proxy ─────────────────────────────────────────</div>
|
||||
<div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, paddingTop: 4, paddingBottom: 6 }}>
|
||||
<CliRow t={t}>
|
||||
<span style={{ color: t.dim }}>host:</span>
|
||||
<CliInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
|
||||
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
<span style={{ color: t.dim }}>port:</span>
|
||||
<CliInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="12334" onSubmit={D.runCheck} style={{ width: 70 }}/>
|
||||
</CliRow>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={{ cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{ color: t.dim }}>auth:</span>{' '}
|
||||
<span onClick={() => {
|
||||
D.update({ auth: !D.form.auth });
|
||||
if (!D.form.auth) setTimeout(()=>document.getElementById('cli-login')?.focus(),30);
|
||||
}}
|
||||
style={{
|
||||
color: D.form.auth ? t.accent : t.dim, fontWeight: 600, cursor:'pointer',
|
||||
borderBottom: `1px dotted ${t.accent}`,
|
||||
}}>{D.form.auth ? '[ on ]' : '[ off ]'}</span>
|
||||
</label>
|
||||
</div>
|
||||
{D.form.auth && (
|
||||
<CliRow t={t} style={{ marginTop: 6 }}>
|
||||
<span style={{ color: t.dim }}>user:</span>
|
||||
<CliInput id="cli-login" t={t} value={D.form.login} onChange={v => D.update({ login: v })}
|
||||
placeholder="login" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
<span style={{ color: t.dim }}>pass:</span>
|
||||
<CliInput t={t} value={D.form.password} onChange={v => D.update({ password: v })} type="password"
|
||||
placeholder="••••••" onSubmit={D.runCheck} style={{ flex: 1 }}/>
|
||||
</CliRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
marginTop: 8, width:'100%', padding:'7px 12px', background:'transparent',
|
||||
border:`1px solid ${(D.phase==='checking'||isActive)?t.dim:t.accent}`,
|
||||
color: (D.phase==='checking'||isActive)?t.dim:t.accent, fontFamily: fontMono,
|
||||
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', borderRadius: 0,
|
||||
textShadow: (D.phase==='checking'||isActive)?'none':`0 0 6px ${t.accent}80`,
|
||||
}}>
|
||||
{D.phase==='checking'
|
||||
? <>{'>'} running…<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>_</span></>
|
||||
: <>{'> check_connection --strict'}</>}
|
||||
</button>
|
||||
|
||||
{/* status */}
|
||||
<div style={{ height: 12 }}/>
|
||||
<div style={cliHead(t)}>┌── status ─────────────────────────────────────────────────</div>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, color: t.dim }}>
|
||||
{'> '} ready to check<span style={{ animation: 'drv-blink 1s steps(2) infinite' }}>_</span>
|
||||
</div>
|
||||
: <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}` }}>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ color: t.dim }}>{'> '} running diagnostics<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>...</span></div>
|
||||
: (D.lastSummary?.failed === 0
|
||||
? <div style={{ color: t.accent, fontWeight: 600 }}>[ ✓ ] all checks passed. ready to start.</div>
|
||||
: <div style={{ color: t.warn, fontWeight: 600 }}>[ ! ] {D.lastSummary?.failed}/{D.tests.length} failed. some features won't work.</div>
|
||||
)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const sym = state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'~':state==='running'?'›':'·';
|
||||
const c = state==='passed'?t.pass:state==='failed'?t.danger:state==='skipped'?t.skip:state==='running'?t.accent:t.dimmer;
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display:'flex', gap: 9, alignItems:'baseline', padding:'1px 0' }}>
|
||||
<span style={{ color: c, width: 10 }}>{sym}</span>
|
||||
<span style={{ color: state==='pending'?t.dimmer:t.text, flex: 1 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ color: c, fontSize: 10.5 }}>{r?.metric || (state==='running'?'…':'')}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', padding: 0,
|
||||
}}>{r.expanded?'[−]':'[+]'}</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin:'2px 0 6px 19px', padding:'4px 8px', borderLeft:`2px solid ${t.danger}`,
|
||||
background: 'rgba(255,107,107,0.06)', fontSize: 10.5,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600 }}>! {r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* actions */}
|
||||
<div style={{ height: 12 }}/>
|
||||
<div style={{ display:'flex', gap: 6 }}>
|
||||
{(() => {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const warning = isActive && (D.lastSummary?.failed||0) > 0;
|
||||
if (isActive) {
|
||||
const c = warning ? t.warn : t.accent;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${c}`, color: c, fontWeight: 700,
|
||||
background: `${c}14`, textShadow: `0 0 6px ${c}80`,
|
||||
display:'flex', alignItems:'center', gap: 7,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 3, background: c, boxShadow:`0 0 6px ${c}` }}/>
|
||||
[ ACTIVE{warning ? ' / UDP-WARN' : ''} ]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${ok?t.accent:t.dim}`,
|
||||
background: ok ? `${t.accent}14` : 'transparent',
|
||||
color: ok ? t.accent : t.dim, fontFamily: fontMono, fontWeight: 600, fontSize: 11.5,
|
||||
letterSpacing: 1, textAlign:'left', cursor: ok?'pointer':'not-allowed',
|
||||
textShadow: ok ? `0 0 6px ${t.accent}80` : 'none', borderRadius: 0,
|
||||
}}>{'>'} start_proxying</button>
|
||||
);
|
||||
})()}
|
||||
<button onClick={D.stopProxy} disabled={!isActive} style={{
|
||||
flex: 1, padding:'7px 12px', border:`1px solid ${isActive?t.dim:t.borderSoft}`,
|
||||
background:'transparent', color: isActive ? t.text : t.dimmer, fontFamily: fontMono,
|
||||
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
|
||||
cursor: isActive?'pointer':'not-allowed', borderRadius: 0,
|
||||
}}>{'■'} stop</button>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 8, padding:'5px 9px', border:`1px dashed ${t.borderSoft}`,
|
||||
color: t.dim, fontSize: 10.5, display:'flex', justifyContent:'space-between',
|
||||
}}>
|
||||
<span>↑ {window.fmtBytes(D.stats.up).padEnd(10)}</span>
|
||||
<span>↓ {window.fmtBytes(D.stats.down).padEnd(10)}</span>
|
||||
<span>tcp={D.stats.tcp}</span>
|
||||
<span>udp={D.stats.udp}</span>
|
||||
<span>up={window.fmtUptime(D.stats.uptimeS)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* logs */}
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 6 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'6px 14px', display:'flex', alignItems:'center', gap: 8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, fontSize: 11, letterSpacing: 0.6,
|
||||
}}>
|
||||
<span>{D.logsOpen?'▼':'▶'}</span>
|
||||
<span style={{ fontWeight: 600 }}>tail -f drover.log</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 14px 6px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.borderSoft}`, color: t.dim,
|
||||
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 0,
|
||||
}}>{`[${l}]`}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 110, overflowY:'auto', padding:'5px 14px',
|
||||
fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent }}>{l.level}</span>{' '}
|
||||
<span style={{ color: t.text }}>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cliHead(t) { return { color: t.dim, fontSize: 10.5, marginBottom: 2, letterSpacing: 0.3 }; }
|
||||
function CliRow({ children, style }) {
|
||||
return <div style={{ display:'flex', alignItems:'center', gap: 7, ...style }}>{children}</div>;
|
||||
}
|
||||
function CliInput({ value, onChange, placeholder, type, onSubmit, style, t, id }) {
|
||||
return <input id={id} value={value} type={type||'text'}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: t.accent, border:'none',
|
||||
borderBottom:`1px solid ${t.borderSoft}`, borderRadius: 0,
|
||||
padding:'3px 4px', fontFamily: 'inherit', fontSize: 12,
|
||||
outline:'none', boxSizing:'border-box', textShadow:`0 0 4px ${t.accent}60`, ...style,
|
||||
}}/>;
|
||||
}
|
||||
|
||||
window.CliWindow = CliWindow;
|
||||
@@ -0,0 +1,293 @@
|
||||
// drover-v2.jsx — Round 2: four new dark-only variants.
|
||||
// Reuses window.useDrover / StatusDot / icons / fmt helpers from drover-shared.jsx.
|
||||
|
||||
// =============================================================================
|
||||
// V5 — COMPACT PRO. High-density, table-like. Wireshark/HTOP feel. Mono numbers.
|
||||
// =============================================================================
|
||||
const CPT = {
|
||||
bg: '#101216', chrome: '#0a0c0f', panel: '#15181d', panel2: '#1a1d23',
|
||||
border: '#262a31', borderSoft: '#1d2026',
|
||||
text: '#e1e3e8', dim: '#838892', dimmer: '#535862',
|
||||
accent: '#7aa9ff', danger: '#e5685a', warn: '#d6a64a', pass: '#5fc888', skip: '#6a6f78',
|
||||
inputBg: '#0c0e12', primaryFg: '#0b1426',
|
||||
};
|
||||
|
||||
function CompactWindow({ initial }) {
|
||||
const t = CPT;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 12, overflow: 'hidden',
|
||||
border: '1px solid #000',
|
||||
}}>
|
||||
{/* title bar */}
|
||||
<div style={{
|
||||
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 10px', flex:1 }}>
|
||||
<window.BrandMark size={12} color={t.accent}/>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600 }}>drover-go</span>
|
||||
<span style={{ fontSize: 10, color: t.dimmer, fontFamily: fontMono }}>0.4.2</span>
|
||||
<span style={{ fontSize: 10, color: t.dim, marginLeft: 8, fontFamily: fontMono }}>
|
||||
· {D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<CompactCell t={t}><window.IconGear color={t.dim}/></CompactCell>
|
||||
<CompactCell t={t}><window.IconMin color={t.dim}/></CompactCell>
|
||||
<CompactCell t={t} hover="#c0463f"><window.IconClose color={t.dim}/></CompactCell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto' }}>
|
||||
{/* Row 1: form, single tight line */}
|
||||
<div style={{ padding: '10px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={cptHead(t)}>SOCKS5</div>
|
||||
<div style={{ display:'flex', gap: 6, alignItems:'center' }}>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })} placeholder="host" style={{ flex: 1 }}
|
||||
onSubmit={D.runCheck}/>
|
||||
<span style={{ color: t.dimmer, fontFamily: fontMono }}>:</span>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="port" style={{ width: 64 }} onSubmit={D.runCheck}/>
|
||||
<CptCheck t={t} checked={D.form.auth} onChange={(v) => {
|
||||
D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('cpt-login')?.focus(),30);
|
||||
}}>auth</CptCheck>
|
||||
</div>
|
||||
{D.form.auth && (
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<CptInput id="cpt-login" t={t} fontMono={fontMono} value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} placeholder="login"
|
||||
style={{ flex: 1 }} onSubmit={D.runCheck}/>
|
||||
<CptInput t={t} fontMono={fontMono} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} placeholder="password"
|
||||
style={{ flex: 1 }} onSubmit={D.runCheck}/>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
|
||||
marginTop: 8, width:'100%', padding:'6px 10px', borderRadius: 2, fontFamily: fontMono,
|
||||
background: (D.phase==='checking'||isActive)?t.panel2:t.accent,
|
||||
color: (D.phase==='checking'||isActive)?t.dimmer:t.primaryFg,
|
||||
border:`1px solid ${t.border}`, fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', textAlign: 'left',
|
||||
}}>
|
||||
{'>>'} {D.phase==='checking'?'CHECKING…':'check connection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: status table, full width */}
|
||||
<div style={{ padding: '8px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={cptHead(t)}>
|
||||
<span>STATUS</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono }}>
|
||||
{D.phase === 'idle' && 'idle'}
|
||||
{D.phase === 'checking' && `${Object.keys(D.results).length}/${D.tests.length}`}
|
||||
{(D.phase === 'checked' || D.phase === 'active') &&
|
||||
(D.lastSummary?.failed === 0 ? 'all-pass' : `${D.lastSummary?.failed}/${D.tests.length} fail`)}
|
||||
</span>
|
||||
</div>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
<span style={{ color: t.dimmer }}>›</span> ready to check_
|
||||
</div>
|
||||
: <CompactStatusTable t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</div>
|
||||
|
||||
{/* Row 3: actions */}
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
<div style={{ display:'flex', gap: 6 }}>
|
||||
<CompactStartBtn t={t} D={D} fontMono={fontMono}/>
|
||||
<CompactStopBtn t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '5px 8px', background: t.panel,
|
||||
border:`1px solid ${t.borderSoft}`, borderRadius: 2,
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.dim,
|
||||
display:'flex', justifyContent:'space-between',
|
||||
}}>
|
||||
<span><window.IconArrowUp color={t.pass}/> {window.fmtBytes(D.stats.up)}</span>
|
||||
<span><window.IconArrowDown color={t.accent}/> {window.fmtBytes(D.stats.down)}</span>
|
||||
<span>tcp:{D.stats.tcp}</span>
|
||||
<span>udp:{D.stats.udp}</span>
|
||||
<span>up:{window.fmtUptime(D.stats.uptimeS)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<CompactLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCell({ children, t, hover }) {
|
||||
const [h, setH] = React.useState(false);
|
||||
return <div onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)} style={{
|
||||
width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
cursor:'pointer', background: h && hover ? hover : 'transparent', transition:'background .1s',
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function cptHead(t) {
|
||||
return {
|
||||
fontSize: 9.5, letterSpacing: 1.5, color: t.dim, fontWeight: 700,
|
||||
marginBottom: 6, display:'flex', alignItems:'center',
|
||||
};
|
||||
}
|
||||
function CptInput({ value, onChange, placeholder, type, style, onSubmit, t, fontMono, id }) {
|
||||
return <input id={id} value={value} type={type||'text'}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: t.text, border:`1px solid ${t.border}`,
|
||||
borderRadius: 2, padding:'5px 7px', fontFamily: fontMono, fontSize: 11.5,
|
||||
outline:'none', boxSizing:'border-box', ...style,
|
||||
}}/>;
|
||||
}
|
||||
function CptCheck({ checked, onChange, t, children }) {
|
||||
return (
|
||||
<label style={{
|
||||
display:'inline-flex', alignItems:'center', gap: 5, cursor:'pointer', userSelect:'none',
|
||||
padding:'4px 7px', border:`1px solid ${checked?t.accent:t.border}`, borderRadius: 2,
|
||||
fontFamily: '"JetBrains Mono",monospace', fontSize: 11,
|
||||
background: checked ? `${t.accent}22` : 'transparent', color: checked ? t.accent : t.dim,
|
||||
}}>
|
||||
<span>[{checked ? 'x' : ' '}]</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function CompactStatusTable({ t, D, palette, fontMono }) {
|
||||
return (
|
||||
<div style={{ fontFamily: fontMono, fontSize: 11 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{
|
||||
display:'flex', alignItems:'center', gap: 7, padding:'2px 0', height: 20,
|
||||
color: state === 'pending' ? t.dimmer : t.text,
|
||||
}}>
|
||||
<span style={{ width: 10, color: t.dimmer }}>{
|
||||
state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'–':state==='running'?'›':'·'
|
||||
}</span>
|
||||
<window.StatusDot state={state} palette={palette} size={10}/>
|
||||
<span title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto',
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim,
|
||||
}}>{r?.metric || (state==='running'?'…':'')}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 2, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 6px 17px', padding:'5px 8px',
|
||||
background: t.panel2, borderLeft:`2px solid ${t.danger}`,
|
||||
fontSize: 10.5, color: t.dim,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function CompactStartBtn({ t, D, fontMono }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'6px 10px', border:`1px solid ${c}`, borderRadius: 2,
|
||||
background: `${c}1a`, color: c, fontFamily: fontMono, fontWeight: 700, fontSize: 11.5,
|
||||
display:'flex', alignItems:'center', gap: 6,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: c }}/>
|
||||
ACTIVE{warning ? ' · UDP-FALLBACK' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'6px 10px', borderRadius: 2,
|
||||
background: ok ? t.accent : t.panel, color: ok ? t.primaryFg : t.dimmer,
|
||||
border:`1px solid ${ok ? t.accent : t.border}`, fontFamily: fontMono,
|
||||
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
|
||||
cursor: ok ? 'pointer' : 'not-allowed',
|
||||
}}>{'>'} start proxying</button>
|
||||
);
|
||||
}
|
||||
function CompactStopBtn({ t, D, fontMono }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'6px 10px', borderRadius: 2,
|
||||
background: t.panel, color: enabled ? t.text : t.dimmer,
|
||||
border:`1px solid ${t.border}`, fontFamily: fontMono,
|
||||
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}>{'■'} stop</button>
|
||||
);
|
||||
}
|
||||
function CompactLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'6px 12px', display:'flex', alignItems:'center', gap: 7,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, fontSize: 10.5, letterSpacing: 1,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 700 }}>LOGS</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 4, padding:'0 12px 6px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, color: t.dim,
|
||||
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 2,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 110, overflowY:'auto', padding:'5px 12px',
|
||||
fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 700 }}>{l.level.padEnd(5)}</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CompactWindow = CompactWindow;
|
||||
@@ -0,0 +1,416 @@
|
||||
// drover-fluent-live.jsx — Fluent / Win11 live variant.
|
||||
// System-native: Segoe UI Variable, acrylic-blue (#0067c0), light cards on a tinted bg.
|
||||
|
||||
const FLUENT_THEME = {
|
||||
l: {
|
||||
bg: '#f3f3f3',
|
||||
chrome: '#fafafa',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f7f7f7',
|
||||
border: 'rgba(0,0,0,.06)',
|
||||
borderHard:'#d6d6d6',
|
||||
text: '#1a1a1a',
|
||||
dim: '#666666',
|
||||
dimmer: '#999999',
|
||||
accent: '#0067c0',
|
||||
accentHover:'#005a9e',
|
||||
danger: '#c42b1c',
|
||||
warn: '#9d5d00',
|
||||
pass: '#107c10',
|
||||
skip: '#888888',
|
||||
inputBg: '#fafafa',
|
||||
inputBorder:'#888',
|
||||
activeBg: '#dff6dd',
|
||||
activeBorder:'#9bc99b',
|
||||
warnBg: '#fff4ce',
|
||||
warnBorder:'#dba81a',
|
||||
},
|
||||
d: {
|
||||
bg: '#202020',
|
||||
chrome: '#181818',
|
||||
panel: '#2b2b2b',
|
||||
panelAlt: '#252525',
|
||||
border: 'rgba(255,255,255,.06)',
|
||||
borderHard:'#3a3a3a',
|
||||
text: '#f0f0f0',
|
||||
dim: '#9b9b9b',
|
||||
dimmer: '#6a6a6a',
|
||||
accent: '#4cc2ff',
|
||||
accentHover:'#6cd0ff',
|
||||
danger: '#ff6b6b',
|
||||
warn: '#fce100',
|
||||
pass: '#6ccb5f',
|
||||
skip: '#7a7a7a',
|
||||
inputBg: '#2c2c2c',
|
||||
inputBorder:'#666',
|
||||
activeBg: '#1d2f1c',
|
||||
activeBorder:'#3d6e3a',
|
||||
warnBg: '#3a3017',
|
||||
warnBorder:'#7a6320',
|
||||
},
|
||||
};
|
||||
|
||||
function FluentWindow({ mode = 'light', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = FLUENT_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Segoe UI Variable','Segoe UI',system-ui,-apple-system,sans-serif";
|
||||
const fontMono = "'Cascadia Mono','Cascadia Code',Consolas,'JetBrains Mono',ui-monospace,monospace";
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
|
||||
borderRadius: 0,
|
||||
}}>
|
||||
<FluentTitleBar t={t} mode={mode} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* SOCKS5 form card */}
|
||||
<FluentCard t={t}>
|
||||
<FluentCardTitle t={t}>SOCKS5 Proxy</FluentCardTitle>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host}
|
||||
onChange={e => D.update({ host: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
style={fluentInputStyle(t, fontMono)} />
|
||||
</FluentField>
|
||||
<FluentField t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port}
|
||||
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
style={fluentInputStyle(t, fontMono)} />
|
||||
</FluentField>
|
||||
</div>
|
||||
<FluentCheckbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('flu-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</FluentCheckbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.45, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<FluentField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="flu-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="user" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
|
||||
</FluentField>
|
||||
<FluentField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
placeholder="••••••" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
|
||||
</FluentField>
|
||||
</div>
|
||||
<FluentPrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</FluentPrimaryBtn>
|
||||
</FluentCard>
|
||||
|
||||
{/* Status card */}
|
||||
<FluentCard t={t}>
|
||||
<FluentCardTitle t={t}>Status</FluentCardTitle>
|
||||
<FluentStatus t={t} D={D} palette={palette} fontMono={fontMono} themeKey={themeKey} />
|
||||
</FluentCard>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<FluentStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <FluentLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
|
||||
<FluentLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentTitleBar({ t, mode }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.border}`,
|
||||
display: 'flex', alignItems: 'center',
|
||||
fontFamily: "'Segoe UI Variable','Segoe UI',system-ui,sans-serif",
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', flex: 1 }}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<FluentTitleBtn t={t}><window.IconGear color={t.dim} /></FluentTitleBtn>
|
||||
<FluentTitleBtn t={t}><window.IconMin color={t.dim} /></FluentTitleBtn>
|
||||
<FluentTitleBtn t={t} hoverBg="#c42b1c" hoverFg="#fff"><window.IconClose color={t.dim} /></FluentTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function FluentTitleBtn({ children, t, hoverBg, hoverFg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ width: 46, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.12)') : 'transparent',
|
||||
color: hover && hoverFg ? hoverFg : 'inherit',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentCard({ t, children, style }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 6,
|
||||
padding: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
display: 'flex', flexDirection: 'column', gap: 8, ...style,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
function FluentCardTitle({ t, children }) {
|
||||
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.2 }}>{children}</div>;
|
||||
}
|
||||
function FluentField({ t, label, children, style }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2, ...style }}>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function fluentInputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
height: 30, background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderBottom: `1.5px solid ${t.inputBorder}`,
|
||||
borderRadius: 4, padding: '0 10px',
|
||||
fontFamily: fontMono, fontSize: 12.5, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s',
|
||||
};
|
||||
}
|
||||
function FluentCheckbox({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5, marginTop: 2 }}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 4,
|
||||
border: `1.5px solid ${checked ? t.accent : t.borderHard}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display: 'none' }} />
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function FluentPrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 34, width: '100%', borderRadius: 4, border: 'none',
|
||||
background: disabled ? t.panelAlt : (hover ? t.accentHover : t.accent),
|
||||
color: disabled ? t.dimmer : '#fff', fontWeight: 600, fontSize: 13,
|
||||
boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.12)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background .12s', marginTop: 4, ...style,
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentStatus({ t, D, palette, fontMono, themeKey }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', color: t.dim, fontSize: 12.5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }} />
|
||||
<span>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5,
|
||||
color: D.phase === 'checking' ? t.accent
|
||||
: D.lastSummary?.failed === 0 ? t.pass : t.warn,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={12} />
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dim, fontFamily: fontMono, fontSize: 11, fontWeight: 400 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <span>All checks passed. Ready to start.</span>
|
||||
: <span>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, marginTop: 4 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 2px', minHeight: 22 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={12} />
|
||||
<span style={{ fontSize: 12.5, color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 4px 21px', padding: '8px 10px', borderRadius: 4,
|
||||
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
|
||||
border: `1px solid ${t.danger}`,
|
||||
fontSize: 11.5,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panelAlt, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 3, cursor: 'pointer' }}>
|
||||
copy error
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, height: 34, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
background: warning ? t.warnBg : t.activeBg,
|
||||
border: `1px solid ${warning ? t.warnBorder : t.activeBorder}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass }} />
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FluentPrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1, marginTop: 0 }}>
|
||||
Start proxying
|
||||
</FluentPrimaryBtn>
|
||||
);
|
||||
}
|
||||
function FluentStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
flex: 1, height: 34, borderRadius: 4, fontWeight: 600, fontSize: 12.5,
|
||||
background: enabled && hover ? t.panelAlt : t.panel,
|
||||
color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.borderHard}`, cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
transition: 'background .12s',
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function FluentLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (label, val) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: t.dim, fontSize: 11.5, fontFamily: fontMono }}>
|
||||
{label}<span style={{ color: t.text }}>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 12px', borderRadius: 4,
|
||||
background: t.panel, border: `1px solid ${t.border}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
{cell(<window.IconArrowUp color={t.dim} />, window.fmtBytes(stats.up))}
|
||||
{cell(<window.IconArrowDown color={t.dim} />, window.fmtBytes(stats.down))}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>↑t</span>, window.fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FluentLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width: '100%', padding: '8px 14px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'transparent', border: 'none', color: t.dim, cursor: 'pointer',
|
||||
fontSize: 11.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'} />
|
||||
<span>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.border}` }}>
|
||||
<div style={{ display: 'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.border}` }}>
|
||||
{[
|
||||
['Copy all', () => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))],
|
||||
['Clear', D.clearLogs],
|
||||
['Open log file', () => {}],
|
||||
].map(([l, fn], i) => (
|
||||
<button key={i} onClick={fn} style={{
|
||||
padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 3, cursor: 'pointer',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.FluentWindow = FluentWindow;
|
||||
@@ -0,0 +1,348 @@
|
||||
// drover-glass.jsx — Variant 3: Glassmorphism.
|
||||
// Soft animated gradient backdrop. Frosted blur on every panel. Subtle glow on active.
|
||||
|
||||
const GlassTheme = {
|
||||
d: {
|
||||
bgGrad: 'radial-gradient(120% 80% at 0% 0%, #2b1d4a 0%, #131326 50%, #0d1024 100%)',
|
||||
glassBg: 'rgba(255,255,255,0.06)',
|
||||
glassBg2: 'rgba(255,255,255,0.10)',
|
||||
border: 'rgba(255,255,255,0.14)',
|
||||
borderSoft: 'rgba(255,255,255,0.08)',
|
||||
text: '#f3f1ff', dim: '#bdbcd6', dimmer: '#7e7d99',
|
||||
accent: '#9b8bff', accentGlow: '#9b8bff',
|
||||
danger: '#ff8a98', warn: '#ffc46b', pass: '#7ee0b3', skip: '#9aa0aa',
|
||||
inputBg: 'rgba(0,0,0,0.18)', primaryFg: '#1a1538',
|
||||
},
|
||||
l: {
|
||||
bgGrad: 'radial-gradient(110% 70% at 0% 0%, #c8d8ff 0%, #f0e8ff 55%, #ffeaf2 100%)',
|
||||
glassBg: 'rgba(255,255,255,0.55)',
|
||||
glassBg2: 'rgba(255,255,255,0.75)',
|
||||
border: 'rgba(255,255,255,0.7)',
|
||||
borderSoft: 'rgba(255,255,255,0.5)',
|
||||
text: '#1a1538', dim: '#5e5b7a', dimmer: '#8e8cab',
|
||||
accent: '#5e4bcf', accentGlow: '#9b8bff',
|
||||
danger: '#c0463f', warn: '#a06a14', pass: '#2c8a5a', skip: '#8a8aa0',
|
||||
inputBg: 'rgba(255,255,255,0.6)', primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function GlassWindow({ mode = 'dark', initial }) {
|
||||
const t = GlassTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const fontMono = '"JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, position: 'relative',
|
||||
background: t.bgGrad, overflow: 'hidden', borderRadius: 0,
|
||||
}}>
|
||||
{/* gradient orbs */}
|
||||
<div style={{ position:'absolute', width: 280, height: 280, borderRadius:'50%',
|
||||
background: 'radial-gradient(closest-side, rgba(155,139,255,0.55), transparent)',
|
||||
top: -80, right: -80, filter: 'blur(20px)', pointerEvents:'none' }}/>
|
||||
<div style={{ position:'absolute', width: 240, height: 240, borderRadius:'50%',
|
||||
background: mode==='dark' ? 'radial-gradient(closest-side, rgba(126,224,179,0.35), transparent)'
|
||||
: 'radial-gradient(closest-side, rgba(255,180,210,0.55), transparent)',
|
||||
bottom: -90, left: -60, filter: 'blur(30px)', pointerEvents:'none' }}/>
|
||||
|
||||
{/* title */}
|
||||
<GlassTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px 0', position:'relative', zIndex: 1 }}>
|
||||
<GlassPanel t={t}>
|
||||
<GlassHeader t={t}>SOCKS5 Proxy</GlassHeader>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<GField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
|
||||
</GField>
|
||||
<GField t={t} label="Port" style={{ width: 96 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
|
||||
</GField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassToggle t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('gl-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</GlassToggle>
|
||||
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.4,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<GField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="gl-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
|
||||
</GField>
|
||||
<GField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
|
||||
</GField>
|
||||
</div>
|
||||
<div style={{ height: 14 }}/>
|
||||
<GlassPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</GlassPrimary>
|
||||
</GlassPanel>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassPanel t={t}>
|
||||
<GlassHeader t={t}>Status</GlassHeader>
|
||||
<GlassStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
|
||||
</GlassPanel>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<GlassPanel t={t}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<GlassStartBtn t={t} D={D}/>
|
||||
<GlassStopBtn t={t} D={D}/>
|
||||
</div>
|
||||
{isActive && <GlassLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
|
||||
</GlassPanel>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
|
||||
<GlassLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassTitle({ t }) {
|
||||
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{
|
||||
height: 36, background: t.glassBg, backdropFilter: 'blur(20px)',
|
||||
borderBottom: `1px solid ${t.borderSoft}`, display:'flex', alignItems:'center', position:'relative', zIndex: 2,
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={15} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell}><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell}><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell}><window.IconClose color={t.dim}/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassPanel({ t, children, style }) {
|
||||
return <div style={{
|
||||
background: t.glassBg, backdropFilter: 'blur(24px) saturate(140%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(140%)',
|
||||
border: `1px solid ${t.border}`, borderRadius: 14, padding: 14,
|
||||
boxShadow: `inset 0 1px 0 ${t.borderSoft}`,
|
||||
...style,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function GlassHeader({ t, children }) {
|
||||
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 1.5, textTransform:'uppercase' }}>{children}</div>;
|
||||
}
|
||||
function GField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11, color: t.dim }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function glassInput(t, fontUI, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderSoft}`, borderRadius: 8, padding: '8px 11px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
backdropFilter: 'blur(8px)',
|
||||
};
|
||||
}
|
||||
function GlassToggle({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : 'rgba(255,255,255,0.18)',
|
||||
boxShadow: checked ? `0 0 12px ${t.accentGlow}80` : 'none',
|
||||
transition: 'background .14s, box-shadow .14s', display:'flex', alignItems:'center',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 7, background: 'white',
|
||||
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function GlassPrimary({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
width:'100%', padding: '10px 14px', border:'none', borderRadius: 10,
|
||||
background: disabled ? 'rgba(255,255,255,0.08)' : `linear-gradient(135deg, ${t.accent}, ${t.accentGlow})`,
|
||||
color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13, cursor: disabled?'not-allowed':'pointer',
|
||||
boxShadow: disabled ? 'none' : `0 6px 24px ${t.accentGlow}55, inset 0 1px 0 rgba(255,255,255,0.4)`,
|
||||
...style,
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
function GlassStatus({ t, D, palette, fontMono }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const allOk = D.lastSummary?.failed === 0;
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding: '7px 11px', borderRadius: 8, marginBottom: 10,
|
||||
background: allOk ? 'rgba(126,224,179,0.16)' : 'rgba(255,196,107,0.18)',
|
||||
border: `1px solid ${allOk ? t.pass : t.warn}55`,
|
||||
color: allOk ? t.pass : t.warn, fontWeight: 600, fontSize: 12.5,
|
||||
}}>{allOk ? 'All checks passed. Ready to start.' : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ padding:'4px 0' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={14}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
|
||||
{r?.metric}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 23px', padding: 10, borderRadius: 8,
|
||||
background: 'rgba(255,138,152,0.10)', border: `1px solid ${t.danger}40`,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function GlassStartBtn({ t, D }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const checkedOk = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 10, fontWeight: 600, fontSize: 13,
|
||||
background: warning ? 'rgba(255,196,107,0.16)' : 'rgba(126,224,179,0.16)',
|
||||
border: `1px solid ${c}66`, color: c,
|
||||
boxShadow: `0 0 24px ${c}40, inset 0 1px 0 rgba(255,255,255,0.18)`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 9,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c, boxShadow: `0 0 8px ${c}` }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <GlassPrimary t={t} onClick={D.startProxy} disabled={!checkedOk} style={{ flex: 1 }}>Start proxying</GlassPrimary>;
|
||||
}
|
||||
function GlassStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.08)', color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.borderSoft}`, fontWeight: 600, fontSize: 13,
|
||||
cursor: enabled ? 'pointer':'not-allowed', backdropFilter: 'blur(8px)',
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function GlassLiveStats({ t, stats, fontMono }) {
|
||||
const C = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 9.5, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 11, paddingTop: 11, borderTop: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<C icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<C icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<C val={stats.tcp} lbl="tcp"/>
|
||||
<C val={stats.udp} lbl="udp"/>
|
||||
<C val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function GlassLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{
|
||||
borderTop: `1px solid ${t.borderSoft}`,
|
||||
background: t.glassBg, backdropFilter: 'blur(20px)',
|
||||
flexShrink: 0, position:'relative', zIndex: 2,
|
||||
}}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ fontWeight: 600, letterSpacing: 0.5 }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
|
||||
{['Copy all','Clear','Open log file'].map((l,i) => (
|
||||
<button key={l} onClick={i===0 ? () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
|
||||
style={{
|
||||
background:'rgba(255,255,255,0.05)', border:`1px solid ${t.borderSoft}`,
|
||||
borderRadius: 6, padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.GlassWindow = GlassWindow;
|
||||
@@ -0,0 +1,424 @@
|
||||
// drover-hero-live.jsx — Big toggle / VPN-style variant.
|
||||
// Centerpiece: a giant connect button. Form lives in an expandable section below.
|
||||
// Compact form (host:port) shown collapsed in active state; full form in idle.
|
||||
|
||||
const HERO_THEME = {
|
||||
d: {
|
||||
bg: 'radial-gradient(ellipse at top, #142340 0%, #0e1426 60%, #080d1c 100%)',
|
||||
chrome: 'transparent',
|
||||
panel: '#15192a',
|
||||
panelAlt: '#1a1f33',
|
||||
border: 'rgba(255,255,255,.08)',
|
||||
borderHard:'rgba(255,255,255,.18)',
|
||||
text: '#ffffff',
|
||||
textDim: '#cbd1d9',
|
||||
dim: '#7a8499',
|
||||
dimmer: '#5a6178',
|
||||
accent: '#5dd4b3',
|
||||
accentDeep:'#2da085',
|
||||
accentDarker:'#1a6e5b',
|
||||
danger: '#ff6b6b',
|
||||
warn: '#f3c764',
|
||||
pass: '#5dd4b3',
|
||||
skip: '#7a8499',
|
||||
},
|
||||
l: {
|
||||
bg: 'linear-gradient(180deg, #f3f7fb 0%, #e8edf4 100%)',
|
||||
chrome: 'transparent',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f5f7fb',
|
||||
border: 'rgba(0,0,0,.07)',
|
||||
borderHard:'rgba(0,0,0,.15)',
|
||||
text: '#0e1830',
|
||||
textDim: '#3a4356',
|
||||
dim: '#6a7383',
|
||||
dimmer: '#9aa3b2',
|
||||
accent: '#1aa787',
|
||||
accentDeep:'#0f8c70',
|
||||
accentDarker:'#0a6655',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#1aa787',
|
||||
skip: '#7a8499',
|
||||
},
|
||||
};
|
||||
|
||||
function HeroWindow({ mode = 'dark', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = HERO_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
|
||||
const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
|
||||
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const failed = summary?.failed ?? 0;
|
||||
const isActive = phase === 'active';
|
||||
const warning = isActive && failed > 0;
|
||||
|
||||
// big-button click handler. Two-step: if idle/checked, go.
|
||||
const handleBigClick = () => {
|
||||
if (phase === 'active') return D.stopProxy();
|
||||
if (phase === 'checking') return;
|
||||
if (phase === 'idle') {
|
||||
// run check, then auto-start if it passed
|
||||
(async () => {
|
||||
await D.runCheck();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (phase === 'checked' && !allFailed) D.startProxy();
|
||||
if (phase === 'checked' && allFailed) D.runCheck();
|
||||
};
|
||||
|
||||
// After runCheck completes from idle, auto-start if all passed.
|
||||
const prevPhase = React.useRef(phase);
|
||||
React.useEffect(() => {
|
||||
if (prevPhase.current === 'checking' && phase === 'checked') {
|
||||
const sum = D.lastSummary;
|
||||
if (sum && sum.failed === 0) {
|
||||
// small delay so user perceives the "checked" state briefly
|
||||
setTimeout(() => D.startProxy(), 600);
|
||||
}
|
||||
}
|
||||
prevPhase.current = phase;
|
||||
}, [phase]);
|
||||
|
||||
// Big-button label / colors
|
||||
let buttonState;
|
||||
if (phase === 'active') buttonState = warning ? 'active-warn' : 'active';
|
||||
else if (phase === 'checking') buttonState = 'checking';
|
||||
else if (phase === 'checked' && allFailed) buttonState = 'failed';
|
||||
else buttonState = 'idle';
|
||||
|
||||
const ringColor = ({
|
||||
idle: t.accent,
|
||||
checking: t.accent,
|
||||
active: t.accent,
|
||||
'active-warn': t.warn,
|
||||
failed: t.danger,
|
||||
})[buttonState];
|
||||
|
||||
const headline = ({
|
||||
idle: 'Tap to connect',
|
||||
checking: 'Verifying…',
|
||||
active: 'Discord is protected',
|
||||
'active-warn': 'Connected · UDP fallback',
|
||||
failed: 'Connection failed',
|
||||
})[buttonState];
|
||||
|
||||
const subline = ({
|
||||
idle: `via ${D.form.host}`,
|
||||
checking: `running 7 checks…`,
|
||||
active: `via ${D.form.host} · ${window.fmtUptime(D.stats.uptimeS)}`,
|
||||
'active-warn': `voice/screen disabled · ${window.fmtUptime(D.stats.uptimeS)}`,
|
||||
failed: `${failed} of ${D.tests.length} checks failed`,
|
||||
})[buttonState];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
|
||||
}}>
|
||||
{/* Title bar */}
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontSize: 12.5 }}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600, color: t.text }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: t.dim }}>0.4.2</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: ringColor, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{phase === 'active' && <span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: ringColor }} />}
|
||||
{phase === 'idle' && 'idle'}
|
||||
{phase === 'checking' && 'checking…'}
|
||||
{phase === 'checked' && (allFailed ? 'failed' : 'ready')}
|
||||
{phase === 'active' && (warning ? 'connected · warn' : 'connected')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', marginLeft: 8 }}>
|
||||
<HeroTitleBtn t={t}><window.IconGear color={t.dim} /></HeroTitleBtn>
|
||||
<HeroTitleBtn t={t}><window.IconMin color={t.dim} /></HeroTitleBtn>
|
||||
<HeroTitleBtn t={t} hoverBg="#c42b1c"><window.IconClose color={t.dim} /></HeroTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Big button + headlines */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 18px 0', gap: 14, minHeight: 0 }}>
|
||||
<BigToggle t={t} state={buttonState} onClick={handleBigClick} progress={
|
||||
phase === 'checking' ? Object.keys(D.results).length / D.tests.length : 0
|
||||
} />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 600 }}>{headline}</div>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4, fontFamily: fontMono }}>{subline}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats — only when active */}
|
||||
{isActive && (
|
||||
<div style={{
|
||||
display: 'flex', width: '100%', background: t.panel,
|
||||
border: `1px solid ${t.border}`, borderRadius: 12, padding: 12, justifyContent: 'space-around',
|
||||
}}>
|
||||
<HeroStat icon="↑" v={fmtCompact(D.stats.up)} u={fmtUnit(D.stats.up)} c={t.accent} mono={fontMono} t={t} />
|
||||
<HeroStat icon="↓" v={fmtCompact(D.stats.down)} u={fmtUnit(D.stats.down)} c={t.accent} mono={fontMono} t={t} />
|
||||
<HeroStat icon="◇" v={D.stats.tcp} u="tcp" c={t.text} mono={fontMono} t={t} />
|
||||
<HeroStat icon="◈" v={D.stats.udp} u="udp" c={warning ? t.warn : t.text} mono={fontMono} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnostic mini-list during check or after */}
|
||||
{(phase === 'checking' || (phase === 'checked' && !isActive)) && (
|
||||
<div style={{ width: '100%', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 8px', minHeight: 24, fontSize: 12.5 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={11} />
|
||||
<span style={{ color: state === 'pending' ? t.dimmer : t.textDim }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 18, height: 18, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 8px 4px 28px', padding: '8px 10px', borderRadius: 6,
|
||||
background: themeKey === 'd' ? 'rgba(255,107,107,.08)' : '#fdf2f2',
|
||||
border: `1px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: form (idle) or footer chips (active) */}
|
||||
<div style={{ padding: '0 18px 14px' }}>
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<button onClick={() => setShowForm(s => !s)}
|
||||
style={{
|
||||
width: '100%', padding: '11px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', color: t.text,
|
||||
fontSize: 12.5, fontFamily: fontUI, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 26, height: 26, borderRadius: 13, background: t.panelAlt,
|
||||
border: `1px solid ${t.border}`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: fontMono, fontSize: 10, color: t.accent,
|
||||
}}>S5</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Proxy</div>
|
||||
<div style={{ fontSize: 11, color: t.dim, fontFamily: fontMono }}>{D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}</div>
|
||||
</div>
|
||||
<window.IconChevron color={t.dim} dir={showForm ? 'up' : 'down'} />
|
||||
</button>
|
||||
{showForm && (
|
||||
<div className="drv-fadein" style={{ borderTop: `1px solid ${t.border}`, padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })}
|
||||
onEnter={D.runCheck} placeholder="95.165.72.59 или example.com" label="Host" style={{ flex: 1 }} />
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
onEnter={D.runCheck} placeholder="12334" label="Port" style={{ width: 90 }} />
|
||||
</div>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5 }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4,
|
||||
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
|
||||
background: D.form.auth ? t.accent : 'transparent',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .12s',
|
||||
}}>
|
||||
{D.form.auth && <svg width="10" height="10" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke={themeKey === 'd' ? '#0c1a18' : '#fff'} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={D.form.auth}
|
||||
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('hero-login')?.focus(), 30); }}
|
||||
style={{ display: 'none' }} />
|
||||
<span style={{ color: t.textDim }}>Authentication</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<HeroInput t={t} fontMono={fontMono} id="hero-login" value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} onEnter={D.runCheck}
|
||||
placeholder="user" label="Login" style={{ flex: 1 }} disabled={!D.form.auth} />
|
||||
<HeroInput t={t} fontMono={fontMono} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} onEnter={D.runCheck}
|
||||
placeholder="••••••" label="Password" style={{ flex: 1 }} disabled={!D.form.auth} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs strip */}
|
||||
<button onClick={() => setShowDetails(s => !s)}
|
||||
style={{
|
||||
width: '100%', marginTop: 8, padding: '6px 4px', display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', color: t.dim,
|
||||
fontSize: 11, fontFamily: fontMono, textAlign: 'left',
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={showDetails ? 'down' : 'right'} />
|
||||
<span>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="drv-fadein" style={{
|
||||
background: t.panelAlt, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
maxHeight: 110, overflow: 'auto', padding: '8px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.textDim,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Big toggle button ──────────────────────────────────────────────────
|
||||
function BigToggle({ t, state, onClick, progress = 0 }) {
|
||||
// size = 168 px circle; outer pulsing rings
|
||||
const isActive = state === 'active' || state === 'active-warn';
|
||||
const isChecking = state === 'checking';
|
||||
const isFailed = state === 'failed';
|
||||
const isWarn = state === 'active-warn';
|
||||
|
||||
const accent = isWarn ? t.warn : isFailed ? t.danger : t.accent;
|
||||
const accentDeep = isWarn ? t.warn : isFailed ? t.danger : t.accentDeep;
|
||||
const accentDarker = isWarn ? t.warn : isFailed ? t.danger : t.accentDarker;
|
||||
|
||||
const ring = 168;
|
||||
const stroke = 5;
|
||||
const r = (ring - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} disabled={isChecking}
|
||||
style={{
|
||||
position: 'relative', width: ring, height: ring, marginTop: 8,
|
||||
background: isActive
|
||||
? `radial-gradient(circle at 35% 30%, ${accent} 0%, ${accentDeep} 60%, ${accentDarker} 100%)`
|
||||
: isFailed
|
||||
? `radial-gradient(circle at 35% 30%, ${t.danger} 0%, #8c2c2c 100%)`
|
||||
: `radial-gradient(circle at 35% 30%, ${t.panel} 0%, ${t.panelAlt} 100%)`,
|
||||
boxShadow: isActive
|
||||
? `0 0 60px ${accent}66, 0 0 0 6px ${accent}22, 0 0 0 12px ${accent}10`
|
||||
: `0 0 0 2px ${accent}22, 0 6px 24px rgba(0,0,0,.15)`,
|
||||
border: 'none', borderRadius: '50%', cursor: isChecking ? 'wait' : 'pointer',
|
||||
color: isActive || isFailed ? '#fff' : accent,
|
||||
transition: 'background .35s, box-shadow .35s, color .2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
|
||||
}}>
|
||||
{/* pulsing ring on active */}
|
||||
{isActive && (
|
||||
<span style={{
|
||||
position: 'absolute', inset: -16, borderRadius: '50%',
|
||||
border: `1px solid ${accent}66`, animation: 'drv-pulse-ring 2s ease-out infinite',
|
||||
}} />
|
||||
)}
|
||||
{/* progress ring while checking */}
|
||||
{isChecking && (
|
||||
<svg width={ring} height={ring} style={{ position: 'absolute', inset: 0, transform: 'rotate(-90deg)' }}>
|
||||
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={`${accent}30`} strokeWidth={stroke} />
|
||||
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={accent} strokeWidth={stroke} strokeLinecap="round"
|
||||
strokeDasharray={c} strokeDashoffset={c * (1 - progress)}
|
||||
style={{ transition: 'stroke-dashoffset .35s' }} />
|
||||
{/* spinner segment */}
|
||||
<circle cx={ring/2} cy={ring/2} r={r-12} fill="none" stroke={accent} strokeWidth="2" strokeLinecap="round"
|
||||
strokeDasharray={`${(c-24)*0.18} ${(c-24)*0.82}`}
|
||||
style={{ animation: 'drv-spin 1.2s linear infinite', transformOrigin: '50% 50%' }} />
|
||||
</svg>
|
||||
)}
|
||||
<span style={{ fontSize: 42, lineHeight: 1, fontWeight: 100 }}>⏻</span>
|
||||
<span style={{ fontSize: 11, letterSpacing: '.25em', marginTop: 6, opacity: 0.95, fontWeight: 600 }}>
|
||||
{isActive ? 'ACTIVE' : isChecking ? 'TESTING' : isFailed ? 'RETRY' : 'OFF'}
|
||||
</span>
|
||||
<style>{`
|
||||
@keyframes drv-pulse-ring { 0%{ transform: scale(1); opacity:.5; } 70%{ transform: scale(1.18); opacity:0;} 100%{transform:scale(1.18); opacity:0;}}
|
||||
`}</style>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStat({ icon, v, u, c, mono, t }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', minWidth: 56 }}>
|
||||
<div style={{ fontSize: 11, color: t.accent }}>{icon}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: mono, marginTop: 2, color: c }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: t.dim, letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTitleBtn({ children, t, hoverBg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 26, height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', borderRadius: 5,
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.16)') : 'transparent',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroInput({ t, fontMono, value, onChange, onEnter, placeholder, label, style, type, disabled, id }) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }}>{label}</span>
|
||||
<input id={id} type={type} value={value} disabled={disabled}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && onEnter?.()}
|
||||
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
height: 32, background: t.panelAlt, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${focus ? t.accent : t.border}`,
|
||||
borderRadius: 7, padding: '0 10px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
boxShadow: focus ? `0 0 0 3px ${t.accent}22` : 'none',
|
||||
}} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtCompact(n) {
|
||||
if (n < 1024) return n.toFixed(0);
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(0);
|
||||
return (n / 1024 / 1024).toFixed(1);
|
||||
}
|
||||
function fmtUnit(n) {
|
||||
if (n < 1024) return 'B/s';
|
||||
if (n < 1024 * 1024) return 'KB/s';
|
||||
return 'MB/s';
|
||||
}
|
||||
|
||||
window.HeroWindow = HeroWindow;
|
||||
@@ -0,0 +1,358 @@
|
||||
// drover-minimal.jsx — Variant 2: Minimal / Fluent-aligned.
|
||||
// Windows 11 Mica feel. Generous spacing. Subtle accents. Segoe UI Variable.
|
||||
// Soft cards on a tinted background, accent: a calm slate-blue.
|
||||
|
||||
const MinTheme = {
|
||||
d: {
|
||||
bg: '#1e1f23', mica: 'rgba(255,255,255,0.02)', chrome: 'rgba(255,255,255,0.03)',
|
||||
panel: 'rgba(255,255,255,0.04)', panel2: 'rgba(255,255,255,0.06)',
|
||||
border: 'rgba(255,255,255,0.08)', borderStrong: 'rgba(255,255,255,0.14)',
|
||||
text: '#f3f3f3', dim: '#b0b3ba', dimmer: '#7a7d85',
|
||||
accent: '#76b3ff', accentBg: 'rgba(118,179,255,0.16)',
|
||||
danger: '#ff7a7a', warn: '#ffb86b', pass: '#7ad29c', skip: '#9aa0aa',
|
||||
inputBg: 'rgba(255,255,255,0.04)', inputBorder: 'rgba(255,255,255,0.16)',
|
||||
primaryBg: '#76b3ff', primaryFg: '#0d1722',
|
||||
},
|
||||
l: {
|
||||
bg: '#f6f6f8', mica: 'rgba(255,255,255,0.5)', chrome: 'rgba(255,255,255,0.65)',
|
||||
panel: '#ffffff', panel2: '#fafafc',
|
||||
border: 'rgba(0,0,0,0.06)', borderStrong: 'rgba(0,0,0,0.14)',
|
||||
text: '#1c1d20', dim: '#4f5460', dimmer: '#8a8f97',
|
||||
accent: '#3a72c7', accentBg: 'rgba(58,114,199,0.10)',
|
||||
danger: '#c0463f', warn: '#9c6a14', pass: '#2c8a5a', skip: '#7c8088',
|
||||
inputBg: '#ffffff', inputBorder: 'rgba(0,0,0,0.18)',
|
||||
primaryBg: '#3a72c7', primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
function MinimalWindow({ mode = 'light', initial }) {
|
||||
const t = MinTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const isActive = D.phase === 'active';
|
||||
const fontUI = '"Segoe UI Variable","Segoe UI",system-ui,-apple-system,sans-serif';
|
||||
const fontMono = '"Cascadia Mono","Consolas",ui-monospace,monospace';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13.5, overflow: 'hidden',
|
||||
boxShadow: mode === 'dark' ? '0 0 0 1px rgba(255,255,255,0.06)' : '0 0 0 1px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
{/* title */}
|
||||
<MinTitle t={t}/>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding: '8px 16px 0' }}>
|
||||
{/* Form card */}
|
||||
<Card t={t} style={{ padding: 16 }}>
|
||||
<CardHeader t={t}>SOCKS5 Proxy</CardHeader>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<MinField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
|
||||
</MinField>
|
||||
<MinField t={t} label="Port" style={{ width: 96 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
|
||||
</MinField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<MinToggle t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('min-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</MinToggle>
|
||||
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.45,
|
||||
pointerEvents: D.form.auth ? 'auto':'none' }}>
|
||||
<MinField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="min-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
|
||||
</MinField>
|
||||
<MinField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
|
||||
</MinField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
|
||||
width:'100%', padding: '10px 14px', borderRadius: 6, border: 'none',
|
||||
background: (D.phase === 'checking' || isActive) ? t.panel2 : t.primaryBg,
|
||||
color: (D.phase === 'checking' || isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13.5, cursor: D.phase==='checking'?'not-allowed':'pointer',
|
||||
fontFamily: fontUI,
|
||||
}}>
|
||||
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
|
||||
{/* Status card */}
|
||||
<Card t={t} style={{ padding: 14 }}>
|
||||
<CardHeader t={t}>Status</CardHeader>
|
||||
<MinStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
|
||||
</Card>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
|
||||
{/* Action card */}
|
||||
<Card t={t} style={{ padding: 14 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<MinStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<MinStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && <MinLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
|
||||
</Card>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<MinLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinTitle({ t }) {
|
||||
const cell = { width: 46, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{
|
||||
height: 36, background: t.chrome, display:'flex', alignItems:'center',
|
||||
borderBottom: `1px solid ${t.border}`, userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={16} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell} title="Settings"><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell} title="Minimize"><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell} title="Close"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#c0463f'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.dim; }}>
|
||||
<window.IconClose/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Card({ t, children, style }) {
|
||||
return <div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8, ...style,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
function CardHeader({ t, children }) {
|
||||
return <div style={{ fontSize: 12, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 0.2 }}>{children}</div>;
|
||||
}
|
||||
function MinField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
function minInput(t, fontUI, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.inputBorder}`, borderRadius: 5, padding: '8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
};
|
||||
}
|
||||
function MinToggle({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer', userSelect:'none' }}>
|
||||
<span style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : t.borderStrong, transition: 'background .14s',
|
||||
display:'flex', alignItems:'center',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 7, background: 'white',
|
||||
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function MinStatus({ t, D, palette, fontMono }) {
|
||||
if (D.phase === 'idle') {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, padding: '6px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? <SummaryPill t={t} kind="ok">All checks passed. Ready to start.</SummaryPill>
|
||||
: <SummaryPill t={t} kind="warn">{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</SummaryPill>}
|
||||
</div>
|
||||
|
||||
<div style={{ display:'flex', flexDirection:'column', gap: 2 }}>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ borderRadius: 4, padding: '4px 4px' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={14}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
|
||||
{r?.metric}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}} title="Подробнее">
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 24px', padding: 10, background: t.panel2, borderRadius: 6,
|
||||
borderLeft: `2px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`${test.label}: ${r.error}`)}
|
||||
style={{
|
||||
marginTop: 6, background:'transparent', border:`1px solid ${t.border}`,
|
||||
color: t.dim, borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor:'pointer',
|
||||
display:'inline-flex', gap:4, alignItems:'center', fontFamily: fontMono,
|
||||
}}>
|
||||
<window.IconCopy color={t.dim}/> copy error
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SummaryPill({ t, kind, children }) {
|
||||
const c = kind === 'ok' ? t.pass : t.warn;
|
||||
return <div style={{
|
||||
padding: '7px 11px', borderRadius: 6, fontSize: 12.5, fontWeight: 500,
|
||||
background: kind==='ok' ? 'rgba(122,210,156,0.12)' : 'rgba(255,184,107,0.12)',
|
||||
color: c, border: `1px solid ${c}40`, width: '100%',
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function MinStartBtn({ t, D, fontUI }) {
|
||||
const phase = D.phase;
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13.5,
|
||||
background: warning ? 'rgba(255,184,107,0.14)' : 'rgba(122,210,156,0.14)',
|
||||
border: `1px solid ${c}55`, color: c,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8, fontFamily: fontUI,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button onClick={D.startProxy} disabled={!checkedOk} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6, border:'none',
|
||||
background: checkedOk ? t.primaryBg : t.panel2, color: checkedOk ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 600, fontSize: 13.5, cursor: checkedOk ? 'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>
|
||||
);
|
||||
}
|
||||
function MinStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding: '10px 14px', borderRadius: 6,
|
||||
background: 'transparent', color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${enabled ? t.borderStrong : t.border}`, fontWeight: 600, fontSize: 13.5,
|
||||
cursor: enabled ? 'pointer' : 'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>
|
||||
);
|
||||
}
|
||||
function MinLiveStats({ t, stats, fontMono }) {
|
||||
const Cell = ({ icon, val, lbl }) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}
|
||||
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, paddingTop: 10, borderTop: `1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center', color: t.dim,
|
||||
}}>
|
||||
<Cell icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
|
||||
<Cell icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
|
||||
<Cell val={stats.tcp} lbl="tcp"/>
|
||||
<Cell val={stats.udp} lbl="udp"/>
|
||||
<Cell val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MinLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ fontWeight: 600 }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
|
||||
{['Copy all','Clear','Open log file'].map((l,i) => (
|
||||
<button key={l} onClick={i===0 ? () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
|
||||
style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
|
||||
padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.MinimalWindow = MinimalWindow;
|
||||
@@ -0,0 +1,308 @@
|
||||
// drover-shared.jsx — shared hooks, state machine, types for all Drover-Go variants.
|
||||
// Each variant imports these via window globals and renders them in its own visual language.
|
||||
|
||||
// ─── Test catalog ──────────────────────────────────────────────────────────
|
||||
const ALL_TESTS = [
|
||||
{ id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' },
|
||||
{ id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' },
|
||||
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
|
||||
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
|
||||
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
|
||||
{ id: 'stun', label: 'UDP round-trip via STUN', desc: 'UDP-пакеты ходят туда-обратно' },
|
||||
{ id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
|
||||
];
|
||||
|
||||
// Pre-baked scenarios so the prototype feels alive. Each entry per test:
|
||||
// { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' }
|
||||
const SCENARIOS = {
|
||||
// Default happy path (no auth)
|
||||
happy: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// With auth
|
||||
happyAuth: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
auth: { result: 'passed', metric: 'user/pass · ok' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// UDP fails — common Discord scenario
|
||||
udpFail: {
|
||||
tcp: { result: 'passed', metric: '17 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'failed', metric: 'X\'07 cmd not supported',
|
||||
error: 'Прокси не поддерживает UDP ASSOCIATE.',
|
||||
hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' },
|
||||
stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' },
|
||||
api: { result: 'passed', metric: '204 OK · 92 ms' },
|
||||
},
|
||||
};
|
||||
|
||||
function getTests(authEnabled) {
|
||||
return ALL_TESTS.filter(t => !t.authOnly || authEnabled);
|
||||
}
|
||||
|
||||
// ─── Drover state hook ─────────────────────────────────────────────────────
|
||||
// Owns: form values, diagnostic phase, per-test results, drover-active state,
|
||||
// live stats counter, log buffer.
|
||||
// phase: 'idle' | 'checking' | 'checked' | 'active'
|
||||
function useDrover(initial = {}) {
|
||||
const [form, setForm] = React.useState({
|
||||
host: '95.165.72.59',
|
||||
port: '12334',
|
||||
auth: false,
|
||||
login: '',
|
||||
password: '',
|
||||
...initial,
|
||||
});
|
||||
const [phase, setPhase] = React.useState('idle');
|
||||
const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded}
|
||||
const [running, setRunning] = React.useState(null); // currently-running test id
|
||||
const [scenario, setScenario] = React.useState('happy'); // for tweaks
|
||||
const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
const [logs, setLogs] = React.useState(() => seedLogs());
|
||||
const [logsOpen, setLogsOpen] = React.useState(false);
|
||||
const tests = getTests(form.auth);
|
||||
const lastSummary = React.useMemo(() => {
|
||||
if (phase !== 'checked' && phase !== 'active') return null;
|
||||
const ids = tests.map(t => t.id);
|
||||
const failed = ids.filter(id => results[id]?.result === 'failed').length;
|
||||
return { total: ids.length, failed };
|
||||
}, [phase, results, tests]);
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────
|
||||
function update(patch) { setForm(f => ({ ...f, ...patch })); }
|
||||
|
||||
function pushLog(level, msg) {
|
||||
setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]);
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
if (phase === 'checking') return;
|
||||
setPhase('checking');
|
||||
setResults({});
|
||||
setRunning(null);
|
||||
pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
|
||||
const list = getTests(form.auth);
|
||||
const sc = form.auth && scenario === 'happy' ? SCENARIOS.happyAuth : SCENARIOS[scenario];
|
||||
for (const t of list) {
|
||||
setRunning(t.id);
|
||||
// shorter when scenario is failing past skipped tests
|
||||
const r = sc[t.id];
|
||||
if (r?.result === 'skipped') {
|
||||
await sleep(120);
|
||||
} else {
|
||||
await sleep(380 + Math.random()*220);
|
||||
}
|
||||
setResults(prev => ({ ...prev, [t.id]: { ...r, expanded: r?.result === 'failed' } }));
|
||||
pushLog(r?.result === 'failed' ? 'ERROR' : r?.result === 'skipped' ? 'WARN' : 'INFO',
|
||||
`${t.label}: ${r?.result}${r?.metric ? ' · ' + r.metric : ''}`);
|
||||
}
|
||||
setRunning(null);
|
||||
setPhase('checked');
|
||||
}
|
||||
|
||||
function startProxy() {
|
||||
if (phase !== 'checked') return;
|
||||
if (lastSummary?.failed === tests.length) return;
|
||||
setPhase('active');
|
||||
pushLog('INFO', 'drover: bound 127.0.0.1:1080 · routing discord traffic');
|
||||
}
|
||||
|
||||
function stopProxy() {
|
||||
if (phase !== 'active') return;
|
||||
setPhase('checked');
|
||||
setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
pushLog('INFO', 'drover: stopped');
|
||||
}
|
||||
|
||||
// Live stats while active
|
||||
React.useEffect(() => {
|
||||
if (phase !== 'active') return;
|
||||
const id = setInterval(() => {
|
||||
setStats(s => ({
|
||||
up: Math.max(0, s.up + (Math.random()*40000 - 18000)),
|
||||
down: Math.max(0, s.down + (Math.random()*120000 - 50000)),
|
||||
tcp: Math.max(2, Math.min(28, s.tcp + (Math.random() < 0.3 ? (Math.random()<.5?-1:1) : 0))),
|
||||
udp: Math.max(0, Math.min(8, s.udp + (Math.random() < 0.2 ? (Math.random()<.5?-1:1) : 0))),
|
||||
uptimeS: s.uptimeS + 1,
|
||||
}));
|
||||
}, 1000);
|
||||
// initial values
|
||||
setStats({ up: 42000, down: 180000, tcp: 6, udp: 2, uptimeS: 0 });
|
||||
return () => clearInterval(id);
|
||||
}, [phase]);
|
||||
|
||||
// Toggle a test's expanded explanation
|
||||
function toggleExpand(id) {
|
||||
setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } }));
|
||||
}
|
||||
|
||||
return {
|
||||
form, update,
|
||||
phase, setPhase,
|
||||
tests, results, running,
|
||||
scenario, setScenario,
|
||||
stats,
|
||||
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
||||
lastSummary,
|
||||
runCheck, startProxy, stopProxy,
|
||||
toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function seedLogs() {
|
||||
const t = Date.now();
|
||||
return [
|
||||
{ t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' },
|
||||
{ t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' },
|
||||
{ t: t-9000, level: 'INFO', msg: 'no active session' },
|
||||
];
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n < 1024) return n.toFixed(0) + ' B/s';
|
||||
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s';
|
||||
return (n/1024/1024).toFixed(2) + ' MB/s';
|
||||
}
|
||||
function fmtUptime(s) {
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${ss}s`;
|
||||
return `${ss}s`;
|
||||
}
|
||||
function fmtTime(t) {
|
||||
const d = new Date(t);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
|
||||
}
|
||||
|
||||
// ─── Shared icons (small, original) ────────────────────────────────────────
|
||||
// Drover-Go mark: a downward chevron through a ring — "tunneled traffic".
|
||||
function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) {
|
||||
const s = size;
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
|
||||
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconGear({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconMin({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
function IconClose({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
function IconChevron({ size=12, color='currentColor', dir='down' }) {
|
||||
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
|
||||
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconCopy({ size=12, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconArrowUp({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
function IconArrowDown({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// ─── Test row state icons (per visual variant supplies its own colors) ─────
|
||||
function StatusDot({ state, palette, size = 12 }) {
|
||||
// state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
const c = palette[state] || palette.pending;
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
|
||||
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
|
||||
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === 'passed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
|
||||
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
// pending
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// CSS for the spinner — injected once.
|
||||
if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'drv-shared-css';
|
||||
s.textContent = `
|
||||
@keyframes drv-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} }
|
||||
@keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} }
|
||||
@keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} }
|
||||
.drv-fadein { animation: drv-fadein .18s ease-out; }
|
||||
.drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; }
|
||||
.drv-shimmer::after {
|
||||
content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent);
|
||||
transform:translateX(-100%); animation: drv-shim 1.6s linear infinite;
|
||||
}
|
||||
@keyframes drv-shim { to { transform: translateX(100%); } }
|
||||
/* Hide scrollbars for log panes inside artboards */
|
||||
.drv-log::-webkit-scrollbar { width:6px; }
|
||||
.drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
Object.assign(window, {
|
||||
useDrover, getTests, ALL_TESTS, SCENARIOS,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
BrandMark, StatusDot,
|
||||
IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown,
|
||||
});
|
||||
@@ -0,0 +1,903 @@
|
||||
// Drover-Go · style sketches v2 — "feels like real software" set.
|
||||
// Each artboard explores a DIFFERENT software UI paradigm + element vocabulary,
|
||||
// not just a different palette. 480×640 fixed Win11 window unless noted.
|
||||
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
|
||||
const Win = ({ children, bg = '#fff', radius = 8, ring = 'rgba(0,0,0,.18)' }) => (
|
||||
<div style={{
|
||||
width: W, height: H, background: bg, borderRadius: radius,
|
||||
boxShadow: `0 0 0 1px ${ring}, 0 24px 60px rgba(0,0,0,.18)`,
|
||||
overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column'
|
||||
}}>{children}</div>
|
||||
);
|
||||
|
||||
const Mark = ({ size = 14, color = '#0c8c7a' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6.5" stroke={color} strokeWidth="1.5"/>
|
||||
<path d="M5.5 5.5 8.5 8 5.5 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 5.5 12 8 9 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity=".5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TitleButtons = ({ color = '#666' }) => (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, color }}>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>⚙</span>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>—</span>
|
||||
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>×</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 1 · Fluent / Win11 native — the safe baseline
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchFluent = () => {
|
||||
const f = "'Segoe UI Variable','Segoe UI',system-ui,sans-serif";
|
||||
return (
|
||||
<Win bg="#f3f3f3" radius={10}>
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#1a1a1a' }}>
|
||||
<Mark color="#0067c0" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>0.4.2</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: f, fontSize: 13.5, flex: 1, display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
|
||||
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>SOCKS5 Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<FluentField flex={1} label="Host" value="95.165.72.59" />
|
||||
<FluentField width={88} label="Port" value="12334" />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5 }}>
|
||||
<span style={{ width: 16, height: 16, borderRadius: 4, border: '1px solid #ccc', background: '#fff' }} />
|
||||
<span style={{ color: '#888' }}>Authentication</span>
|
||||
</label>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontFamily: f, fontWeight: 600, fontSize: 13, boxShadow: '0 1px 0 rgba(0,0,0,.1) inset, 0 1px 2px rgba(0,103,192,.3)' }}>Check connection</button>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>Status</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#0067c0', fontSize: 12.5, marginBottom: 8 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #0067c0', borderRightColor: 'transparent', animation: 'spin 1s linear infinite' }} />
|
||||
Running diagnostics…
|
||||
</div>
|
||||
{[
|
||||
['✓', 'TCP reachability', '12 ms', '#107c10'],
|
||||
['✓', 'SOCKS5 greeting', 'ok', '#107c10'],
|
||||
['◐', 'TCP CONNECT to Discord', '…', '#0067c0'],
|
||||
['·', 'UDP ASSOCIATE', '', '#bbb'],
|
||||
['·', 'UDP round-trip via STUN', '', '#bbb'],
|
||||
['·', 'Discord API reachable', '', '#bbb'],
|
||||
].map(([ic, name, val, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '4px 0', fontSize: 12.5 }}>
|
||||
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
|
||||
<span style={{ flex: 1 }}>{name}</span>
|
||||
<span style={{ color: '#888', fontFamily: "'Cascadia Mono', monospace", fontSize: 11.5 }}>{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 36, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, opacity: 0.4 }}>Start proxying</button>
|
||||
<button style={{ flex: 1, height: 36, background: '#fff', border: '1px solid #ddd', borderRadius: 4, color: '#aaa' }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const FluentField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width, display: 'block' }}>
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ height: 30, background: '#fafafa', borderRadius: 4, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#1a1a1a', borderBottom: '1.5px solid #888', boxShadow: '0 0 0 1px rgba(0,0,0,.06)' }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 2 · Sidebar nav (VS Code / Postman / Tower) — left rail with sections
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchSidebar = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#1e1f22" radius={8} ring="rgba(255,255,255,.06)">
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #2a2c30', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
|
||||
<Mark color="#4f8eff" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>— proxy: 95.165.72.59:12334</span>
|
||||
<TitleButtons color="#888" />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* sidebar */}
|
||||
<div style={{ width: 56, background: '#191a1d', borderRight: '1px solid #2a2c30', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0', gap: 4, fontFamily: f }}>
|
||||
{[['◉', 'Proxy', true], ['◇', 'Diagnose'], ['≋', 'Traffic'], ['≣', 'Logs'], ['⚙', 'Settings']].map(([ic, l, on], i) => (
|
||||
<div key={i} style={{ width: 40, height: 40, borderRadius: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: on ? '#fff' : '#888', background: on ? '#2c3140' : 'transparent', borderLeft: on ? '2px solid #4f8eff' : '2px solid transparent', position: 'relative', fontSize: 14 }}>
|
||||
<span>{ic}</span>
|
||||
<span style={{ fontSize: 8, marginTop: 1 }}>{l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: f, fontSize: 13, color: '#cbd1d9' }}>
|
||||
{/* breadcrumb / tabs */}
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'flex-end', padding: '0 0 0 0', borderBottom: '1px solid #2a2c30', background: '#191a1d' }}>
|
||||
<div style={{ padding: '6px 12px', background: '#1e1f22', borderRight: '1px solid #2a2c30', borderTop: '2px solid #4f8eff', fontSize: 11.5, color: '#fff' }}>● Proxy</div>
|
||||
<div style={{ padding: '6px 12px', borderRight: '1px solid #2a2c30', fontSize: 11.5, color: '#888' }}>diagnostic.run</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Endpoint</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<div style={{ background: '#2c3140', border: '1px solid #3a4050', borderRight: 'none', padding: '0 10px', fontFamily: m, fontSize: 11, color: '#4f8eff', display: 'flex', alignItems: 'center', borderRadius: '4px 0 0 4px' }}>SOCKS5</div>
|
||||
<div style={{ flex: 1, background: '#15171b', border: '1px solid #3a4050', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff' }}>95.165.72.59</div>
|
||||
<div style={{ width: 70, background: '#15171b', border: '1px solid #3a4050', borderLeft: 'none', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff', borderRadius: '0 4px 4px 0' }}>:12334</div>
|
||||
<button style={{ padding: '0 16px', background: '#4f8eff', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}>Run ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Diagnostic results</div>
|
||||
<div style={{ background: '#15171b', border: '1px solid #2a2c30', borderRadius: 5, fontFamily: m, fontSize: 11.5 }}>
|
||||
{[
|
||||
['✓', 'tcp_reachability', '12 ms', '#21d07a'],
|
||||
['✓', 'socks5_greeting', 'ok', '#21d07a'],
|
||||
['✓', 'socks5_authentication', 'ok', '#21d07a'],
|
||||
['✓', 'tcp_connect_discord', '38 ms', '#21d07a'],
|
||||
['✗', 'udp_associate', 'EPERM: blocked', '#ff5e5e'],
|
||||
['—', 'udp_stun_rtt', 'skipped', '#666'],
|
||||
['—', 'discord_api', 'skipped', '#666'],
|
||||
].map(([ic, n, v, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '5px 10px', borderBottom: i < 6 ? '1px solid #1f2126' : 'none' }}>
|
||||
<span style={{ width: 16, color: c }}>{ic}</span>
|
||||
<span style={{ flex: 1, color: c === '#666' ? '#666' : '#cbd1d9' }}>{n}</span>
|
||||
<span style={{ color: c }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#3a1f1f', border: '1px solid #6e2a2a', borderRadius: 5, fontSize: 11.5, color: '#ff9e9e' }}>
|
||||
⚠ 1 of 7 checks failed. UDP voice/screenshare won't work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* status bar */}
|
||||
<div style={{ height: 22, display: 'flex', alignItems: 'center', padding: '0 10px', background: '#15171b', borderTop: '1px solid #2a2c30', fontFamily: m, fontSize: 10.5, color: '#888' }}>
|
||||
<span style={{ color: '#ff9e9e' }}>● 6/7</span>
|
||||
<span style={{ marginLeft: 12 }}>idle</span>
|
||||
<span style={{ marginLeft: 'auto' }}>UTF-8 · LF · drover.toml</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 3 · Step wizard / Tab progression — DigiCert / Tunnelblick connect-flow
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchWizard = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
return (
|
||||
<Win bg="#fafafa" radius={8} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12, color: '#1a1a1a' }}>
|
||||
<Mark color="#1e6fd9" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', fontFamily: f, color: '#1a1a1a' }}>
|
||||
{/* stepper */}
|
||||
<div style={{ display: 'flex', padding: '14px 18px', gap: 0, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
|
||||
{[['1', 'Configure', true], ['2', 'Verify', true], ['3', 'Connect', false]].map(([n, l, done], i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: '50%', background: i === 1 ? '#1e6fd9' : (done ? '#1e6fd9' : '#ddd'), color: '#fff', fontSize: 11, fontWeight: 600, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{done && i !== 1 ? '✓' : n}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: i === 1 ? 600 : 400, color: i === 1 ? '#1a1a1a' : '#888' }}>{l}</span>
|
||||
</div>
|
||||
{i < 2 && <div style={{ flex: 1, height: 1, background: i === 0 ? '#1e6fd9' : '#ddd', margin: '0 10px' }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 14, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Verifying your proxy</h2>
|
||||
<div style={{ fontSize: 12.5, color: '#666', marginTop: 4 }}>We're running 7 checks against <span style={{ fontFamily: "'JetBrains Mono',monospace", color: '#1a1a1a' }}>95.165.72.59:12334</span> to make sure Discord will work end-to-end.</div>
|
||||
</div>
|
||||
{/* progress + ring */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: 14, background: '#fff', border: '1px solid #ececec', borderRadius: 8 }}>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#eee" strokeWidth="6" />
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke="#1e6fd9" strokeWidth="6" strokeLinecap="round" strokeDasharray="163.4" strokeDashoffset="70" transform="rotate(-90 32 32)" />
|
||||
<text x="32" y="36" textAnchor="middle" fontSize="14" fontWeight="600" fill="#1a1a1a">4/7</text>
|
||||
</svg>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Testing UDP relay…</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>This usually takes 5–15 seconds.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12.5 }}>
|
||||
{[
|
||||
['✓', 'TCP reachability', '12 ms', '#21a655'],
|
||||
['✓', 'SOCKS5 greeting', 'ok', '#21a655'],
|
||||
['✓', 'SOCKS5 authentication', 'ok', '#21a655'],
|
||||
['✓', 'TCP CONNECT to Discord', '38 ms', '#21a655'],
|
||||
['◐', 'UDP ASSOCIATE', 'running…', '#1e6fd9'],
|
||||
['·', 'UDP round-trip via STUN', '', '#bbb'],
|
||||
['·', 'Discord API reachable', '', '#bbb'],
|
||||
].map(([ic, n, v, c], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '5px 10px', background: i === 4 ? '#eef4fc' : 'transparent', borderRadius: 4 }}>
|
||||
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
|
||||
<span style={{ flex: 1, color: c === '#bbb' ? '#999' : '#1a1a1a' }}>{n}</span>
|
||||
<span style={{ color: c, fontFamily: "'JetBrains Mono',monospace", fontSize: 11.5 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 50, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '0 18px', borderTop: '1px solid #ececec', background: '#fff' }}>
|
||||
<button style={{ height: 30, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Back</button>
|
||||
<button style={{ height: 30, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12.5, fontWeight: 600, opacity: 0.5 }}>Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 4 · Network monitor — gauges, sparklines, multi-pane (Wireshark / Activity)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchMonitor = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#fcfcfc" radius={6} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #e6e6e6', fontFamily: f, fontSize: 12, background: '#f5f5f5' }}>
|
||||
<Mark color="#0c8c7a" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, fontFamily: m, fontSize: 10, color: '#21a655' }}>● ACTIVE 4m 12s</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: f, fontSize: 12, color: '#222', overflow: 'hidden' }}>
|
||||
{/* compact endpoint */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#21a655' }} />
|
||||
<span style={{ fontFamily: m, fontSize: 11.5 }}>95.165.72.59:12334</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 11 }}>SOCKS5 · auth</span>
|
||||
<button style={{ background: 'transparent', border: '1px solid #ddd', borderRadius: 4, padding: '2px 8px', fontSize: 11 }}>Edit</button>
|
||||
</div>
|
||||
{/* meters */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 8 }}>
|
||||
<Gauge label="Upload" v="142" unit="KB/s" pct={28} c="#0c8c7a" />
|
||||
<Gauge label="Download" v="2.8" unit="MB/s" pct={84} c="#1e6fd9" />
|
||||
</div>
|
||||
{/* sparkline panel */}
|
||||
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>Throughput · last 60s</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 11, color: '#222' }}>peak 3.4 MB/s</span>
|
||||
</div>
|
||||
<svg width="100%" height="60" viewBox="0 0 400 60" preserveAspectRatio="none">
|
||||
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20 L400 60 L0 60Z" fill="#1e6fd9" opacity=".15" />
|
||||
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20" fill="none" stroke="#1e6fd9" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* connections table */}
|
||||
<div style={{ flex: 1, background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', padding: '6px 10px', background: '#f5f5f5', borderBottom: '1px solid #e6e6e6', fontSize: 10.5, color: '#888', letterSpacing: '.06em', textTransform: 'uppercase' }}>
|
||||
<span style={{ width: 50 }}>Proto</span>
|
||||
<span style={{ flex: 1 }}>Endpoint</span>
|
||||
<span style={{ width: 80, textAlign: 'right' }}>Up</span>
|
||||
<span style={{ width: 80, textAlign: 'right' }}>Down</span>
|
||||
</div>
|
||||
{[
|
||||
['UDP', 'voice-eu-rtc-1.discord', '142 KB/s', '2.4 MB/s', '#1e6fd9'],
|
||||
['TCP', 'gateway.discord:443', '8 KB/s', '12 KB/s', '#0c8c7a'],
|
||||
['TCP', 'cdn.discordapp:443', '0', '380 KB/s', '#0c8c7a'],
|
||||
['TCP', 'media.discordapp:443', '0', '8 KB/s', '#0c8c7a'],
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '5px 10px', fontFamily: m, fontSize: 11, borderBottom: i < 3 ? '1px solid #f0f0f0' : 'none' }}>
|
||||
<span style={{ width: 50, color: r[4], fontWeight: 600 }}>{r[0]}</span>
|
||||
<span style={{ flex: 1 }}>{r[1]}</span>
|
||||
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[2]}</span>
|
||||
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 32, background: '#21a655', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}>● Active · 4m 12s</button>
|
||||
<button style={{ flex: 1, height: 32, background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Gauge = ({ label, v, unit, pct, c }) => (
|
||||
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 22, fontWeight: 600, fontFamily: "'JetBrains Mono',monospace" }}>{v}</span>
|
||||
<span style={{ fontSize: 11, color: '#888' }}>{unit}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: '#f0f0f0', borderRadius: 2, marginTop: 6, overflow: 'hidden' }}>
|
||||
<div style={{ width: pct + '%', height: '100%', background: c }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 5 · Inspector / Properties — devtools-style key:value list
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchInspector = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#fff" radius={6} ring="rgba(0,0,0,.12)">
|
||||
<div style={{ height: 28, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 10.5 }}>0.4.2</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
{/* tabs */}
|
||||
<div style={{ height: 28, display: 'flex', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5, background: '#fafafa' }}>
|
||||
{['Inspector', 'Network', 'Console', 'Settings'].map((t, i) => (
|
||||
<div key={t} style={{ padding: '0 14px', display: 'flex', alignItems: 'center', borderBottom: i === 0 ? '2px solid #1a1a1a' : '2px solid transparent', color: i === 0 ? '#1a1a1a' : '#888', fontWeight: i === 0 ? 600 : 400 }}>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontFamily: m, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Group title="proxy" expanded>
|
||||
<KV k="protocol" v="SOCKS5" t="enum" />
|
||||
<KV k="host" v={<input style={{ all: 'unset', width: '100%', fontFamily: m, fontSize: 11.5, color: '#1a1a1a' }} defaultValue="95.165.72.59" />} editable />
|
||||
<KV k="port" v="12334" t="int" editable />
|
||||
<KV k="auth.enabled" v="true" t="bool" />
|
||||
<KV k="auth.user" v="alice" t="str" editable />
|
||||
<KV k="auth.pass" v="••••••••" t="str" editable />
|
||||
</Group>
|
||||
<Group title="diagnostic.last_run" expanded>
|
||||
<KV k="started_at" v="14:32:01" t="time" />
|
||||
<KV k="duration" v="3.2 s" t="dur" />
|
||||
<KV k="passed" v="7" t="int" color="#21a655" />
|
||||
<KV k="failed" v="0" t="int" />
|
||||
<KV k="checks[0].name" v="tcp_reachability" />
|
||||
<KV k="checks[0].rtt" v="12 ms" />
|
||||
<KV k="checks[1].name" v="socks5_greeting" />
|
||||
<KV k="checks[2].name" v="socks5_authentication" />
|
||||
</Group>
|
||||
<Group title="state" expanded>
|
||||
<KV k="status" v="ready" t="enum" color="#21a655" />
|
||||
<KV k="active" v="false" t="bool" />
|
||||
</Group>
|
||||
</div>
|
||||
<div style={{ height: 32, display: 'flex', gap: 6, padding: '0 8px', alignItems: 'center', borderTop: '1px solid #ececec', background: '#fafafa' }}>
|
||||
<button style={{ height: 22, padding: '0 10px', background: '#fff', border: '1px solid #ccc', borderRadius: 3, fontFamily: f, fontSize: 11 }}>Run diagnostic</button>
|
||||
<button style={{ height: 22, padding: '0 10px', background: '#1a1a1a', color: '#fff', border: 'none', borderRadius: 3, fontFamily: f, fontSize: 11, fontWeight: 600 }}>▸ Start proxying</button>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 10.5, color: '#888' }}>14 props · saved</span>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Group = ({ title, expanded, children }) => (
|
||||
<div>
|
||||
<div style={{ padding: '4px 10px', background: '#f0f0f0', fontSize: 10.5, fontWeight: 600, color: '#444', display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: 4, fontSize: 9 }}>{expanded ? '▼' : '▶'}</span>{title}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
const KV = ({ k, v, t, color, editable }) => (
|
||||
<div style={{ display: 'flex', padding: '3px 10px', borderBottom: '1px solid #f5f5f5', alignItems: 'center', fontSize: 11 }}>
|
||||
<span style={{ width: 150, color: '#888' }}>{k}</span>
|
||||
<span style={{ flex: 1, color: color || '#1a1a1a', background: editable ? '#f9f9f9' : 'transparent', borderRadius: 2, padding: '1px 4px' }}>{v}</span>
|
||||
{t && <span style={{ marginLeft: 8, fontSize: 9.5, color: '#bbb', textTransform: 'uppercase' }}>{t}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 6 · Command palette + form — Raycast / Linear / cmd+k feel
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchPalette = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#0f1015" radius={14} ring="rgba(255,255,255,.08)">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
|
||||
<Mark color="#a5b4ff" />
|
||||
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
|
||||
<TitleButtons color="#666" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 14, fontFamily: f, color: '#e8e8ea', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
{/* command bar */}
|
||||
<div style={{ background: '#15171c', border: '1px solid #2a2d36', borderRadius: 9, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, boxShadow: '0 0 0 3px rgba(165,180,255,.08)' }}>
|
||||
<span style={{ color: '#a5b4ff', fontSize: 14 }}>⌘</span>
|
||||
<input style={{ all: 'unset', flex: 1, fontFamily: f, fontSize: 13.5, color: '#fff' }} placeholder="Search commands…" defaultValue="check con" />
|
||||
<span style={{ fontFamily: m, fontSize: 10, color: '#666', padding: '2px 6px', background: '#1a1c22', border: '1px solid #2a2d36', borderRadius: 4 }}>esc</span>
|
||||
</div>
|
||||
{/* suggestion */}
|
||||
<div style={{ background: '#15171c', borderRadius: 9, padding: 6 }}>
|
||||
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#23264a', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#a5b4ff', color: '#0f1015', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 12 }}>▸</span>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>Check connection</span>
|
||||
<span style={{ fontFamily: m, fontSize: 10.5, color: '#a5b4ff' }}>↵</span>
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 10, color: '#888' }}>
|
||||
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#1a1c22', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>●</span>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>Start proxying</span>
|
||||
<span style={{ fontFamily: m, fontSize: 10.5 }}>⇧↵</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Form below — proxy quick-edit */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 10, minHeight: 0 }}>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase' }}>Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<PalField flex={1} label="Host" value="95.165.72.59" />
|
||||
<PalField width={86} label="Port" value="12334" />
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase', marginTop: 4 }}>Last run · 7/7 passed</div>
|
||||
{[
|
||||
['tcp_reachability', '12 ms'],
|
||||
['socks5_greeting', 'ok'],
|
||||
['udp_associate', 'relay 1.2.3.4'],
|
||||
['udp_stun_rtt', '24 ms'],
|
||||
['discord_api', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '4px 10px', fontFamily: m, fontSize: 11.5, background: '#15171c', borderRadius: 5 }}>
|
||||
<span style={{ color: '#21d07a', marginRight: 8 }}>✓</span>
|
||||
<span style={{ flex: 1, color: '#cbd1d9' }}>{n}</span>
|
||||
<span style={{ color: '#888' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontFamily: m, fontSize: 10.5, color: '#666' }}>
|
||||
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}>↵</kbd> run</span>
|
||||
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}>⇧↵</kbd> start</span>
|
||||
<span style={{ marginLeft: 'auto' }}>idle · ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const PalField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 10.5, color: '#888', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ height: 32, background: '#15171c', border: '1px solid #2a2d36', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#fff', fontFamily: "'JetBrains Mono',monospace" }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 7 · Big toggle / Hero — TunnelBear / NordVPN big-button connect
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchHero = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#0e1426" radius={14} ring="rgba(255,255,255,.06)">
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#cbd1d9' }}>
|
||||
<Mark color="#5dd4b3" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#5dd4b3' }}>● connected</span>
|
||||
<TitleButtons color="#666" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '20px 18px', fontFamily: f, color: '#fff', display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', overflow: 'hidden' }}>
|
||||
{/* Big circular button */}
|
||||
<div style={{ width: 180, height: 180, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%, #5dd4b3 0%, #2da085 60%, #1a6e5b 100%)', boxShadow: '0 0 60px rgba(93,212,179,.4), 0 0 0 6px rgba(93,212,179,.1), 0 0 0 12px rgba(93,212,179,.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', position: 'relative', marginTop: 12 }}>
|
||||
<div style={{ position: 'absolute', inset: -20, borderRadius: '50%', border: '1px solid rgba(93,212,179,.2)', animation: 'pulse 2s infinite' }} />
|
||||
<span style={{ fontSize: 36 }}>⏻</span>
|
||||
<span style={{ fontSize: 11, letterSpacing: '.2em', marginTop: 4, opacity: .9 }}>ACTIVE</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 600 }}>Discord is protected</div>
|
||||
<div style={{ fontSize: 12.5, color: '#7a8499', marginTop: 4, fontFamily: m }}>via 95.165.72.59 · 4m 12s</div>
|
||||
</div>
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 0, width: '100%', background: '#15192a', borderRadius: 10, padding: 12, justifyContent: 'space-around' }}>
|
||||
{[
|
||||
['↑', '142', 'KB/s'],
|
||||
['↓', '2.8', 'MB/s'],
|
||||
['◇', '14', 'tcp'],
|
||||
['◈', '3', 'udp'],
|
||||
].map(([k, v, u], i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#5dd4b3' }}>{k}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: m, marginTop: 2 }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: '#7a8499', letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button style={{ width: '100%', height: 38, background: 'transparent', color: '#cbd1d9', border: '1px solid #2a3042', borderRadius: 9, fontWeight: 500, fontSize: 13 }}>Disconnect</button>
|
||||
<div style={{ width: '100%', fontSize: 11, color: '#5a6178', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 10 }}>▸</span><span>Logs (3 new)</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes pulse{0%,100%{transform:scale(1);opacity:.4}50%{transform:scale(1.08);opacity:0}}`}</style>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 8 · Toolbar + ribbon — old-school Office / Audacity feel
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchToolbar = () => {
|
||||
const f = "'Segoe UI', system-ui, sans-serif";
|
||||
const m = "'Consolas', 'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#ebebeb" radius={4} ring="rgba(0,0,0,.18)">
|
||||
<div style={{ height: 26, display: 'flex', alignItems: 'center', padding: '0 8px', background: 'linear-gradient(180deg,#f5f5f5,#dcdcdc)', borderBottom: '1px solid #b8b8b8', fontFamily: f, fontSize: 11 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 6, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>— [Connected]</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
{/* menu bar */}
|
||||
<div style={{ height: 22, display: 'flex', padding: '0 6px', background: '#ebebeb', borderBottom: '1px solid #c8c8c8', fontFamily: f, fontSize: 11, alignItems: 'center', gap: 2 }}>
|
||||
{['File', 'Edit', 'Run', 'View', 'Tools', 'Help'].map(m => (
|
||||
<span key={m} style={{ padding: '2px 8px', borderRadius: 2 }}>{m}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* ribbon */}
|
||||
<div style={{ display: 'flex', padding: '6px 8px', background: 'linear-gradient(180deg,#f5f5f5,#e8e8e8)', borderBottom: '1px solid #c8c8c8', gap: 4 }}>
|
||||
<Tool icon="▶" label="Run" primary />
|
||||
<Tool icon="●" label="Start" />
|
||||
<Tool icon="■" label="Stop" disabled />
|
||||
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
|
||||
<Tool icon="⚙" label="Config" />
|
||||
<Tool icon="⎘" label="Copy" />
|
||||
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
|
||||
<Tool icon="≣" label="Logs" />
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 10, display: 'flex', flexDirection: 'column', gap: 8, fontFamily: f, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden' }}>
|
||||
<Fieldset label="SOCKS5 Proxy">
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ width: 40 }}>Host:</span>
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>95.165.72.59</div>
|
||||
<span>Port:</span>
|
||||
<div style={{ width: 70, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>12334</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 }}>
|
||||
<span style={{ width: 14, height: 14, border: '1px solid #888', background: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10 }}>✓</span>
|
||||
<span>Authentication</span>
|
||||
<span style={{ marginLeft: 12 }}>User:</span>
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center' }}>alice</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
<Fieldset label="Diagnostics" flex>
|
||||
<div style={{ background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', flex: 1, fontFamily: m, fontSize: 11, overflow: 'auto' }}>
|
||||
{[
|
||||
['✓', 'TCP reachability', 'PASS · 12 ms'],
|
||||
['✓', 'SOCKS5 greeting', 'PASS · ok'],
|
||||
['✓', 'SOCKS5 authentication', 'PASS · ok'],
|
||||
['✓', 'TCP CONNECT to Discord', 'PASS · 38 ms'],
|
||||
['✓', 'UDP ASSOCIATE', 'PASS · relay 1.2.3.4'],
|
||||
['✓', 'UDP round-trip via STUN', 'PASS · 24 ms'],
|
||||
['✓', 'Discord API reachable', 'PASS · 200'],
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '3px 8px', borderBottom: i < 6 ? '1px dotted #ddd' : 'none' }}>
|
||||
<span style={{ width: 14, color: '#107c10' }}>{r[0]}</span>
|
||||
<span style={{ flex: 1 }}>{r[1]}</span>
|
||||
<span style={{ color: '#666' }}>{r[2]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Fieldset>
|
||||
</div>
|
||||
{/* status bar */}
|
||||
<div style={{ height: 22, display: 'flex', padding: '0 8px', background: '#dcdcdc', borderTop: '1px solid #b8b8b8', alignItems: 'center', fontSize: 10.5, fontFamily: f, gap: 12 }}>
|
||||
<span>Ready</span>
|
||||
<span>● 7/7</span>
|
||||
<span>↑ 142 KB/s ↓ 2.8 MB/s</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: m }}>uptime 4m 12s</span>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const Tool = ({ icon, label, primary, disabled }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2px 8px', borderRadius: 2, background: primary ? 'linear-gradient(180deg,#fff,#e0e0e0)' : 'transparent', border: primary ? '1px solid #888' : '1px solid transparent', minWidth: 44, opacity: disabled ? 0.4 : 1 }}>
|
||||
<span style={{ fontSize: 16, color: primary ? '#107c10' : '#1a1a1a' }}>{icon}</span>
|
||||
<span style={{ fontSize: 10, marginTop: 1 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
const Fieldset = ({ label, children, flex }) => (
|
||||
<div style={{ border: '1px solid #b8b8b8', borderRadius: 2, padding: '10px 8px 8px', position: 'relative', background: '#f5f5f5', flex: flex ? 1 : undefined, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ position: 'absolute', top: -8, left: 8, background: '#ebebeb', padding: '0 4px', fontSize: 10.5, fontWeight: 600 }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 9 · Modern dev tool — Studio (Linear-ish, restrained dark)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchStudio = () => {
|
||||
const f = "'Inter', sans-serif";
|
||||
return (
|
||||
<Win bg="#0e0f12" radius={12} ring="rgba(255,255,255,.08)">
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#e8e8ea' }}>
|
||||
<Mark color="#7c8aff" />
|
||||
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#5a5d6a', fontSize: 11 }}>0.4.2</span>
|
||||
<TitleButtons color="#777a86" />
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: f, color: '#e8e8ea', flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, textTransform: 'uppercase' }}>SOCKS5 Proxy</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<StField flex={1} label="Host" value="95.165.72.59" />
|
||||
<StField width={86} label="Port" value="12334" />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5, color: '#a1a3ad' }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: 4, background: '#7c8aff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 10 }}>✓</span>
|
||||
Authentication
|
||||
</label>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600, fontSize: 13 }}>Check connection</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, display: 'flex', textTransform: 'uppercase' }}>
|
||||
Status <span style={{ marginLeft: 'auto', fontSize: 11, color: '#21d07a', fontWeight: 500, textTransform: 'none' }}>● All systems</span>
|
||||
</div>
|
||||
<div style={{ background: '#15171c', border: '1px solid #1f2228', borderRadius: 9, padding: 4, flex: 1 }}>
|
||||
{[
|
||||
['TCP reachability', '12 ms'],
|
||||
['SOCKS5 greeting', 'ok'],
|
||||
['SOCKS5 authentication', 'ok'],
|
||||
['TCP CONNECT to Discord', '38 ms'],
|
||||
['UDP ASSOCIATE', 'relay 1.2.3.4'],
|
||||
['UDP round-trip via STUN', '24 ms'],
|
||||
['Discord API reachable', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '6px 8px', borderRadius: 6, fontSize: 12.5 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#21d07a22', border: '1px solid #21d07a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#21d07a', fontSize: 9, marginRight: 10 }}>✓</span>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span style={{ color: '#777a86', fontFamily: "'Geist Mono',monospace", fontSize: 11.5 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={{ flex: 1, height: 36, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600 }}>Start proxying</button>
|
||||
<button style={{ flex: 1, height: 36, background: '#1a1c22', color: '#e8e8ea', border: '1px solid #2a2d36', borderRadius: 7 }}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const StField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 11, color: '#777a86', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ height: 32, background: '#15171c', border: '1px solid #1f2228', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13 }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 10 · Pipeline / DAG — visualize the test chain as a flow
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchPipeline = () => {
|
||||
const f = "'Inter', system-ui, sans-serif";
|
||||
const m = "'JetBrains Mono', monospace";
|
||||
const Node = ({ x, y, label, status, sub }) => {
|
||||
const c = status === 'pass' ? '#21a655' : status === 'fail' ? '#e85a4f' : status === 'run' ? '#1e6fd9' : '#aaa';
|
||||
const bg = status === 'pass' ? '#e8f7ee' : status === 'fail' ? '#fdebe9' : status === 'run' ? '#eaf1fb' : '#f4f4f4';
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<rect width="120" height="40" rx="6" fill={bg} stroke={c} strokeWidth="1.5" />
|
||||
<circle cx="14" cy="20" r="5" fill={c} />
|
||||
<text x="26" y="17" fontSize="11" fontWeight="600" fill="#1a1a1a" fontFamily={f}>{label}</text>
|
||||
<text x="26" y="30" fontSize="9.5" fill="#666" fontFamily={m}>{sub}</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
const Arrow = ({ from, to, dashed }) => (
|
||||
<line x1={from[0]} y1={from[1]} x2={to[0]} y2={to[1]} stroke="#bbb" strokeWidth="1.5" strokeDasharray={dashed ? '3 3' : ''} markerEnd="url(#ar)" />
|
||||
);
|
||||
return (
|
||||
<Win bg="#fafafa" radius={6}>
|
||||
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12 }}>
|
||||
<Mark color="#1a1a1a" size={12} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>· pipeline view</span>
|
||||
<TitleButtons />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* compact endpoint */}
|
||||
<div style={{ padding: 10, display: 'flex', gap: 6, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
|
||||
<span style={{ fontSize: 11, color: '#888', fontFamily: m }}>SOCKS5</span>
|
||||
<input style={{ flex: 1, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="95.165.72.59" />
|
||||
<input style={{ width: 70, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="12334" />
|
||||
<button style={{ height: 26, padding: '0 12px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11.5 }}>Run ▸</button>
|
||||
</div>
|
||||
{/* DAG */}
|
||||
<div style={{ flex: 1, padding: 12, background: 'radial-gradient(circle at 1px 1px, #ddd 1px, transparent 0) 0 0/16px 16px', overflow: 'auto' }}>
|
||||
<svg width="456" height="380" viewBox="0 0 456 380">
|
||||
<defs>
|
||||
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#bbb"/></marker>
|
||||
</defs>
|
||||
<Node x={20} y={20} label="TCP reach" sub="12 ms" status="pass" />
|
||||
<Arrow from={[140, 40]} to={[160, 40]} />
|
||||
<Node x={160} y={20} label="SOCKS5 greet" sub="ok" status="pass" />
|
||||
<Arrow from={[280, 40]} to={[300, 40]} />
|
||||
<Node x={300} y={20} label="SOCKS5 auth" sub="ok" status="pass" />
|
||||
<Arrow from={[360, 60]} to={[360, 90]} />
|
||||
<Node x={300} y={90} label="CONNECT discord" sub="38 ms" status="pass" />
|
||||
<Arrow from={[300, 110]} to={[180, 160]} />
|
||||
<Arrow from={[300, 110]} to={[180, 220]} />
|
||||
<Node x={60} y={140} label="UDP ASSOCIATE" sub="running…" status="run" />
|
||||
<Arrow from={[180, 160]} to={[60, 240]} dashed />
|
||||
<Node x={60} y={220} label="UDP STUN RTT" sub="pending" status="pending" />
|
||||
<Node x={60} y={300} label="Discord API" sub="pending" status="pending" />
|
||||
<Arrow from={[180, 240]} to={[60, 320]} dashed />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ padding: 10, borderTop: '1px solid #ececec', background: '#fff', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#1e6fd9', fontFamily: m }}>● 4/7 running</span>
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<button style={{ height: 28, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12 }}>Cancel</button>
|
||||
<button style={{ height: 28, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12, fontWeight: 600, opacity: 0.5 }}>Start proxying</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 11 · Bauhaus — geometric blocks, primary colors, asymmetric grid
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchBauhaus = () => {
|
||||
const f = "'Space Grotesk', sans-serif";
|
||||
return (
|
||||
<Win bg="#f4ede0" radius={0} ring="#000">
|
||||
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '3px solid #000', fontFamily: f, fontSize: 13, fontWeight: 700 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#e63946' }} />
|
||||
<span style={{ width: 14, height: 14, background: '#1d3557', marginLeft: 6 }} />
|
||||
<span style={{ width: 0, height: 0, borderLeft: '7px solid transparent', borderRight: '7px solid transparent', borderBottom: '14px solid #f1c40f', marginLeft: 6 }} />
|
||||
<span style={{ marginLeft: 12 }}>DROVER-GO</span>
|
||||
<span style={{ marginLeft: 'auto', fontWeight: 400, fontSize: 11 }}>v0.4.2 · ⚙ · — · ×</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, fontFamily: f, color: '#000', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: 8 }}>
|
||||
<div style={{ gridColumn: '1 / -1', background: '#1d3557', color: '#fff', padding: 14, border: '3px solid #000' }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .7 }}>FORM 01</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginTop: 2 }}>SOCKS5 PROXY</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<BaField flex={1} label="HOST" value="95.165.72.59" dark />
|
||||
<BaField width={84} label="PORT" value="12334" dark />
|
||||
</div>
|
||||
</div>
|
||||
<button style={{ background: '#e63946', color: '#fff', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}>CHECK →</button>
|
||||
<button style={{ background: '#f1c40f', color: '#000', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}>● START</button>
|
||||
<div style={{ gridColumn: '1 / -1', border: '3px solid #000', background: '#fff', padding: 14, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .6 }}>STATUS · 7/7</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 10 }}>ALL CHECKS PASSED</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 14px' }}>
|
||||
{[
|
||||
['TCP reach', '12ms'],
|
||||
['SOCKS5 greet', 'ok'],
|
||||
['SOCKS5 auth', 'ok'],
|
||||
['CONNECT', '38ms'],
|
||||
['UDP assoc', 'ok'],
|
||||
['UDP STUN', '24ms'],
|
||||
['API', '200'],
|
||||
].map(([n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', fontSize: 12, borderBottom: '1px solid #000' }}>
|
||||
<span style={{ width: 8, height: 8, background: '#e63946', marginRight: 8 }} />
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span style={{ fontWeight: 700 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const BaField = ({ label, value, flex, width, dark }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 9.5, marginBottom: 3, opacity: .7, letterSpacing: '.1em' }}>{label}</div>
|
||||
<div style={{ height: 30, background: dark ? '#fff' : '#000', color: dark ? '#000' : '#fff', border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 13, fontWeight: 600 }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// 12 · Brutalist — hard borders, mono, acid lime
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
const SketchBrutalist = () => {
|
||||
const m = "'Geist Mono', 'JetBrains Mono', monospace";
|
||||
return (
|
||||
<Win bg="#f3f1e8" radius={0} ring="#000">
|
||||
<div style={{ height: 38, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '2px solid #000', fontFamily: m, fontSize: 12, color: '#000', background: '#e8e4d2' }}>
|
||||
<span style={{ fontWeight: 700 }}>▶ DROVER-GO</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 10 }}>v0.4.2</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 14, fontWeight: 700 }}>
|
||||
<span>[⚙]</span><span>[—]</span><span>[X]</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 14, fontFamily: m, fontSize: 12, color: '#000', flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ border: '2px solid #000', background: '#fff', padding: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// SOCKS5 PROXY</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<BrField flex={1} label="HOST" value="95.165.72.59" />
|
||||
<BrField width={92} label="PORT" value="12334" />
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12 }}>[X] AUTHENTICATION</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<BrField flex={1} label="LOGIN" value="alice" />
|
||||
<BrField flex={1} label="PASSWORD" value="********" />
|
||||
</div>
|
||||
<button style={{ marginTop: 12, width: '100%', height: 36, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700, fontSize: 13, cursor: 'pointer' }}>► CHECK CONNECTION</button>
|
||||
</div>
|
||||
<div style={{ border: '2px solid #000', background: '#fff', padding: 10, flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// STATUS · 7/7 OK</div>
|
||||
{[
|
||||
['[OK]', 'TCP_REACHABILITY', '12ms'],
|
||||
['[OK]', 'SOCKS5_GREETING', 'ok'],
|
||||
['[OK]', 'SOCKS5_AUTH', 'ok'],
|
||||
['[OK]', 'CONNECT_DISCORD', '38ms'],
|
||||
['[OK]', 'UDP_ASSOCIATE', 'relay 1.2.3.4'],
|
||||
['[OK]', 'UDP_STUN_RTT', '24ms'],
|
||||
['[OK]', 'DISCORD_API', '200'],
|
||||
].map(([s, n, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', padding: '3px 0', borderBottom: i < 6 ? '1px dashed #999' : 'none' }}>
|
||||
<span style={{ width: 38, fontWeight: 700 }}>{s}</span>
|
||||
<span style={{ flex: 1 }}>{n}</span>
|
||||
<span>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button style={{ flex: 1, height: 38, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}>► START</button>
|
||||
<button style={{ flex: 1, height: 38, background: '#fff', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}>■ STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
</Win>
|
||||
);
|
||||
};
|
||||
const BrField = ({ label, value, flex, width }) => (
|
||||
<label style={{ flex, width }}>
|
||||
<div style={{ fontSize: 9.5, marginBottom: 3 }}>{label}</div>
|
||||
<div style={{ height: 30, border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 12.5, background: '#fff' }}>{value}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// SketchesApp
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const SKETCHES = [
|
||||
{ id: 'fluent', label: '01 · Fluent / Win11 native', el: <SketchFluent /> },
|
||||
{ id: 'sidebar', label: '02 · IDE sidebar (VS Code-ish)', el: <SketchSidebar /> },
|
||||
{ id: 'wizard', label: '03 · Wizard / stepper', el: <SketchWizard /> },
|
||||
{ id: 'monitor', label: '04 · Network monitor (gauges)', el: <SketchMonitor /> },
|
||||
{ id: 'inspector', label: '05 · Inspector / properties', el: <SketchInspector /> },
|
||||
{ id: 'palette', label: '06 · Command palette (cmd+k)', el: <SketchPalette /> },
|
||||
{ id: 'hero', label: '07 · Big toggle (VPN-style)', el: <SketchHero /> },
|
||||
{ id: 'toolbar', label: '08 · Toolbar / ribbon (classic)', el: <SketchToolbar /> },
|
||||
{ id: 'studio', label: '09 · Studio (Linear-ish)', el: <SketchStudio /> },
|
||||
{ id: 'pipeline', label: '10 · Pipeline / DAG view', el: <SketchPipeline /> },
|
||||
{ id: 'bauhaus', label: '11 · Bauhaus blocks', el: <SketchBauhaus /> },
|
||||
{ id: 'brutalist', label: '12 · Brutalist (mono+lime)', el: <SketchBrutalist /> },
|
||||
];
|
||||
|
||||
const SketchesApp = () => (
|
||||
<DesignCanvas>
|
||||
<DCSection id="about" title="Drover-Go" subtitle="12 directions — every one looks/works like real software, not a marketing site. Different UI paradigms (sidebar, wizard, palette, ribbon, DAG, gauges…), not just different palettes.">
|
||||
<div data-dc-static style={{ width: 720, padding: '20px 24px', background: '#fff', borderRadius: 10, boxShadow: '0 0 0 1px rgba(0,0,0,.06)', fontSize: 13, lineHeight: 1.55, color: '#3a3530' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 14 }}>What changed</div>
|
||||
<p style={{ margin: '0 0 8px' }}>Removed the "marketing" stylings (ticket, synthwave, soft pastel, editorial serif). Replaced with software-native paradigms — each card explores a <em>different way to organize the same UI</em>, not just a different paint job:</p>
|
||||
<ul style={{ margin: '0 0 8px', paddingLeft: 18 }}>
|
||||
<li><b>Layout patterns:</b> sidebar IDE · wizard/stepper · pipeline/DAG · ribbon · palette+form · big-toggle hero</li>
|
||||
<li><b>Element vocab:</b> gauges + sparklines · key/value inspector · fieldsets · tabs+breadcrumbs · status bar · toolbar buttons</li>
|
||||
<li><b>Aesthetic:</b> Fluent (Win11) · classic Office · Linear/Studio · brutalist · Bauhaus blocks</li>
|
||||
</ul>
|
||||
<p style={{ margin: 0, color: '#7a7065' }}>Tell me which paradigm/aesthetic combo to take forward.</p>
|
||||
</div>
|
||||
</DCSection>
|
||||
<DCSection id="sketches" title="12 directions" subtitle="Drag to reorder. Click any artboard's expand icon to focus.">
|
||||
{SKETCHES.map(s => (
|
||||
<DCArtboard key={s.id} id={s.id} label={s.label} width={W} height={H}>
|
||||
{s.el}
|
||||
</DCArtboard>
|
||||
))}
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
|
||||
// Expose individual sketches so other files (e.g. shortlist) can render them.
|
||||
Object.assign(window, {
|
||||
SketchFluent, SketchSidebar, SketchWizard, SketchMonitor,
|
||||
SketchInspector, SketchPalette, SketchHero, SketchToolbar,
|
||||
SketchStudio, SketchPipeline, SketchBauhaus, SketchBrutalist,
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// drover-studio.jsx — Studio: modern, calm, Linear-ish. Subtle blue accent.
|
||||
|
||||
const STD = {
|
||||
bg: '#0e0f12', chrome: '#0a0b0d', panel: '#15171b', panel2: '#1a1c21',
|
||||
border: '#22252b', borderSoft: '#1a1c21',
|
||||
text: '#e7e9ee', dim: '#9095a0', dimmer: '#5e6470',
|
||||
accent: '#6c8aff', accentSoft: 'rgba(108,138,255,0.14)',
|
||||
danger: '#ff7d70', danSoft: 'rgba(255,125,112,0.12)',
|
||||
warn: '#f5b13d', warnSoft: 'rgba(245,177,61,0.12)',
|
||||
pass: '#6cd391', passSoft: 'rgba(108,211,145,0.12)',
|
||||
skip: '#7e848e',
|
||||
inputBg: '#0e0f12',
|
||||
primaryFg: '#0c1226',
|
||||
};
|
||||
|
||||
function StudioWindow({ initial }) {
|
||||
const t = STD;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,-apple-system,sans-serif';
|
||||
const fontMono = '"JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 13, overflow:'hidden', border:'1px solid #000',
|
||||
}}>
|
||||
<StdTitle t={t}/>
|
||||
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px' }}>
|
||||
|
||||
<StdSection t={t} title="SOCKS5 Proxy">
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<StdField t={t} label="Host" style={{ flex: 1 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.host}
|
||||
onChange={v => D.update({ host: v })} onSubmit={D.runCheck}
|
||||
placeholder="95.165.72.59 или example.com"/>
|
||||
</StdField>
|
||||
<StdField t={t} label="Port" style={{ width: 96 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.port}
|
||||
onChange={v => D.update({ port: v.replace(/\D/g,'') })} onSubmit={D.runCheck}
|
||||
placeholder="12334" inputMode="numeric"/>
|
||||
</StdField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 12 }}/>
|
||||
<StdCheck t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('std-login')?.focus(),30); }}>
|
||||
Authentication
|
||||
</StdCheck>
|
||||
<div style={{
|
||||
display:'flex', gap: 10, marginTop: 10,
|
||||
opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth?'auto':'none',
|
||||
}}>
|
||||
<StdField t={t} label="Login" style={{ flex: 1 }}>
|
||||
<StdInput id="std-login" t={t} fontUI={fontUI} value={D.form.login}
|
||||
onChange={v => D.update({ login: v })} onSubmit={D.runCheck} placeholder="user" disabled={!D.form.auth}/>
|
||||
</StdField>
|
||||
<StdField t={t} label="Password" style={{ flex: 1 }}>
|
||||
<StdInput t={t} fontUI={fontUI} value={D.form.password} type="password"
|
||||
onChange={v => D.update({ password: v })} onSubmit={D.runCheck} placeholder="••••••"
|
||||
disabled={!D.form.auth}/>
|
||||
</StdField>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
width:'100%', padding:'9px 14px', borderRadius: 6, border:'none',
|
||||
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
|
||||
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 13, cursor: D.phase==='checking'?'not-allowed':'pointer',
|
||||
fontFamily: fontUI,
|
||||
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
|
||||
</StdSection>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<StdSection t={t} title="Status" right={
|
||||
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
|
||||
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
|
||||
}>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim, padding:'2px 0' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 3, background: t.dimmer }}/>
|
||||
Ready to check
|
||||
</div>
|
||||
: <StdStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</StdSection>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<StdSection t={t}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<StdStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<StdStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<StdStat icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(D.stats.up)} fontMono={fontMono} t={t}/>
|
||||
<StdStat icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(D.stats.down)} fontMono={fontMono} t={t}/>
|
||||
<StdStat val={D.stats.tcp} lbl="TCP" fontMono={fontMono} t={t}/>
|
||||
<StdStat val={D.stats.udp} lbl="UDP" fontMono={fontMono} t={t}/>
|
||||
<StdStat val={window.fmtUptime(D.stats.uptimeS)} lbl="UP" fontMono={fontMono} t={t}/>
|
||||
</div>
|
||||
)}
|
||||
</StdSection>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
<StdLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StdTitle({ t }) {
|
||||
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
|
||||
return (
|
||||
<div style={{ height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`, display:'flex', alignItems:'center', userSelect:'none' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
|
||||
<window.BrandMark size={15} color={t.accent}/>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
|
||||
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
<div style={cell}><window.IconGear color={t.dim}/></div>
|
||||
<div style={cell}><window.IconMin color={t.dim}/></div>
|
||||
<div style={cell}><window.IconClose color={t.dim}/></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StdSection({ t, title, right, children }) {
|
||||
return (
|
||||
<section>
|
||||
{(title || right) && (
|
||||
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
|
||||
{title && <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.5 }}>{title}</div>}
|
||||
{right && <div style={{ marginLeft:'auto', fontSize: 11, color: t.dimmer, fontFamily:'"JetBrains Mono",monospace' }}>{right}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ background: t.panel, border:`1px solid ${t.border}`, borderRadius: 8, padding: 14 }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function StdField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function StdInput({ t, fontUI, value, onChange, type, placeholder, onSubmit, disabled, id, inputMode }) {
|
||||
return <input id={id} value={value} type={type||'text'} disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border:`1px solid ${t.border}`, borderRadius: 5, padding:'8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
}}/>;
|
||||
}
|
||||
function StdCheck({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, border:`1px solid ${checked?t.accent:t.border}`,
|
||||
background: checked ? t.accent : 'transparent',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', transition:'all .12s',
|
||||
}}>
|
||||
{checked && <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5l2 2 4-4.4" stroke={t.primaryFg} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
|
||||
<span style={{ fontSize: 13 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function StdStatus({ t, D, palette, fontMono }) {
|
||||
const allOk = D.lastSummary?.failed === 0;
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding:'7px 11px', marginBottom: 10, borderRadius: 6, fontSize: 12.5, fontWeight: 500,
|
||||
background: allOk ? t.passSoft : t.warnSoft,
|
||||
border:`1px solid ${allOk ? t.pass : t.warn}40`,
|
||||
color: allOk ? t.pass : t.warn,
|
||||
}}>
|
||||
{allOk ? 'All checks passed. Ready to start.'
|
||||
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
|
||||
</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={13}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
|
||||
}}>{r?.metric}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 23px', padding: 10, borderRadius: 6,
|
||||
background: t.danSoft, border: `1px solid ${t.danger}30`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function StdStartBtn({ t, D, fontUI }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed || 0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13,
|
||||
background: warning ? t.warnSoft : t.passSoft, color: c, border:`1px solid ${c}55`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 4, background: c }}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, border:'none',
|
||||
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 600, fontSize: 13, cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>;
|
||||
}
|
||||
function StdStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return <button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 6, background:'transparent',
|
||||
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
|
||||
fontWeight: 600, fontSize: 13, cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>;
|
||||
}
|
||||
function StdStat({ icon, val, lbl, fontMono, t }) {
|
||||
return <div style={{ display:'flex', alignItems:'center', gap: 4 }}>
|
||||
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing: 0.5 }}>{lbl}</span>}
|
||||
</div>;
|
||||
}
|
||||
function StdLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 9,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 600 }}>Logs</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
|
||||
{[['Copy all', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['Clear', D.clearLogs], ['Open log file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
|
||||
padding:'4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
|
||||
fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.StudioWindow = StudioWindow;
|
||||
@@ -0,0 +1,455 @@
|
||||
// drover-wizard-live.jsx — Wizard / stepper variant.
|
||||
// 3 steps: Configure → Verify → Connect. State derives from D.phase.
|
||||
// Step 1 = idle (form). Step 2 = checking/checked (diagnostics). Step 3 = active (running proxy).
|
||||
|
||||
const WIZARD_THEME = {
|
||||
l: {
|
||||
bg: '#fafafa',
|
||||
chrome: '#ffffff',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f6f7f9',
|
||||
border: '#ececec',
|
||||
borderHard:'#dcdcdc',
|
||||
text: '#1a1a1a',
|
||||
dim: '#666666',
|
||||
dimmer: '#9aa0a6',
|
||||
accent: '#1e6fd9',
|
||||
accentSoft:'#eef4fc',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#21a655',
|
||||
skip: '#888888',
|
||||
},
|
||||
d: {
|
||||
bg: '#0f1115',
|
||||
chrome: '#171a20',
|
||||
panel: '#1a1d23',
|
||||
panelAlt: '#13151a',
|
||||
border: '#2a2d33',
|
||||
borderHard:'#3a3d45',
|
||||
text: '#e6e8ec',
|
||||
dim: '#9aa0a6',
|
||||
dimmer: '#5d6168',
|
||||
accent: '#5b9bf0',
|
||||
accentSoft:'#1a253a',
|
||||
danger: '#e57373',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
},
|
||||
};
|
||||
|
||||
function WizardWindow({ mode = 'light', initial }) {
|
||||
const themeKey = mode === 'dark' ? 'd' : 'l';
|
||||
const t = WIZARD_THEME[themeKey];
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
|
||||
const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
|
||||
|
||||
// Determine current step from phase
|
||||
const phase = D.phase;
|
||||
const step = phase === 'idle' ? 1
|
||||
: (phase === 'checking' || phase === 'checked') ? 2
|
||||
: 3; // active
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.45,
|
||||
borderRadius: 0,
|
||||
}}>
|
||||
<WizardTitleBar t={t} />
|
||||
|
||||
{/* Stepper */}
|
||||
<div style={{
|
||||
display: 'flex', padding: '14px 18px', alignItems: 'center',
|
||||
borderBottom: `1px solid ${t.border}`, background: t.chrome, gap: 0,
|
||||
}}>
|
||||
{[[1, 'Configure'], [2, 'Verify'], [3, 'Connect']].map(([n, label], i) => {
|
||||
const done = step > n;
|
||||
const current = step === n;
|
||||
const dim = step < n;
|
||||
return (
|
||||
<React.Fragment key={n}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
background: dim ? t.panelAlt : t.accent,
|
||||
border: dim ? `1px solid ${t.borderHard}` : 'none',
|
||||
color: dim ? t.dimmer : '#fff',
|
||||
fontSize: 11, fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background .2s',
|
||||
}}>{done ? '✓' : n}</span>
|
||||
<span style={{ fontSize: 12.5, fontWeight: current ? 600 : 400, color: dim ? t.dimmer : t.text }}>{label}</span>
|
||||
</div>
|
||||
{i < 2 && <div style={{
|
||||
flex: 1, height: 1, background: step > n ? t.accent : t.borderHard,
|
||||
margin: '0 10px', transition: 'background .2s',
|
||||
}} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{step === 1 && <WizConfigure t={t} D={D} fontMono={fontMono} />}
|
||||
{step === 2 && <WizVerify t={t} D={D} fontMono={fontMono} palette={palette} themeKey={themeKey} />}
|
||||
{step === 3 && <WizConnect t={t} D={D} fontMono={fontMono} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
height: 54, display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
||||
gap: 8, padding: '0 18px', borderTop: `1px solid ${t.border}`, background: t.chrome,
|
||||
}}>
|
||||
<WizardSecondaryBtn t={t} onClick={() => {
|
||||
if (step === 2) D.stopProxy?.(), D.setPhase('idle');
|
||||
if (step === 3) D.stopProxy();
|
||||
}} disabled={step === 1}>
|
||||
{step === 3 ? 'Disconnect' : 'Back'}
|
||||
</WizardSecondaryBtn>
|
||||
<WizardPrimaryBtn t={t}
|
||||
onClick={() => {
|
||||
if (step === 1) D.runCheck();
|
||||
else if (step === 2) D.startProxy();
|
||||
}}
|
||||
disabled={
|
||||
(step === 1 && (D.phase === 'checking')) ||
|
||||
(step === 2 && (D.phase === 'checking' || D.lastSummary?.failed === D.tests.length)) ||
|
||||
(step === 3)
|
||||
}>
|
||||
{step === 1 && 'Verify →'}
|
||||
{step === 2 && (D.phase === 'checking' ? 'Verifying…' : 'Connect →')}
|
||||
{step === 3 && 'Connected'}
|
||||
</WizardPrimaryBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardTitleBar({ t }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, display: 'flex', alignItems: 'center', padding: '0 12px',
|
||||
borderBottom: `1px solid ${t.border}`, fontSize: 12, color: t.text, background: t.chrome,
|
||||
}}>
|
||||
<window.BrandMark size={14} color={t.accent} />
|
||||
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex' }}>
|
||||
<WizTitleBtn t={t}><window.IconGear color={t.dim} /></WizTitleBtn>
|
||||
<WizTitleBtn t={t}><window.IconMin color={t.dim} /></WizTitleBtn>
|
||||
<WizTitleBtn t={t} hoverBg="#c0463f" hoverFg="#fff"><window.IconClose color={t.dim} /></WizTitleBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WizTitleBtn({ children, t, hoverBg, hoverFg }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ width: 38, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: hover ? (hoverBg || 'rgba(127,127,127,.1)') : 'transparent',
|
||||
color: hover && hoverFg ? hoverFg : 'inherit',
|
||||
borderRadius: 4,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: Configure ───────────────────────────────────────────────────
|
||||
function WizConfigure({ t, D, fontMono }) {
|
||||
const inputStyle = (disabled) => ({
|
||||
height: 36, background: t.panel, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderHard}`, borderRadius: 6, padding: '0 12px',
|
||||
fontFamily: fontMono, fontSize: 13, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
});
|
||||
const onFocus = (e) => { e.target.style.borderColor = t.accent; e.target.style.boxShadow = `0 0 0 3px ${t.accentSoft}`; };
|
||||
const onBlur = (e) => { e.target.style.borderColor = t.borderHard; e.target.style.boxShadow = 'none'; };
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Configure your proxy</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
Введите адрес SOCKS5-сервера. На следующем шаге мы проверим, что Discord будет работать через него.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: 16, display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Host</span>
|
||||
<input value={D.form.host}
|
||||
onChange={e => D.update({ host: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
style={inputStyle(false)} />
|
||||
</label>
|
||||
<label style={{ width: 100, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Port</span>
|
||||
<input value={D.form.port}
|
||||
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
style={inputStyle(false)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: 'pointer', userSelect: 'none', fontSize: 13 }}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 4,
|
||||
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
|
||||
background: D.form.auth ? t.accent : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{D.form.auth && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={D.form.auth}
|
||||
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('wiz-login')?.focus(), 30); }}
|
||||
style={{ display: 'none' }} />
|
||||
<span>Authentication</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Login</span>
|
||||
<input id="wiz-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="user" style={inputStyle(!D.form.auth)} />
|
||||
</label>
|
||||
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Password</span>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
onFocus={onFocus} onBlur={onBlur}
|
||||
placeholder="••••••" style={inputStyle(!D.form.auth)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: t.dim, marginTop: 'auto', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: '50%', border: `1px solid ${t.borderHard}`,
|
||||
fontSize: 9, color: t.dim, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>i</span>
|
||||
Нажмите <span style={{ color: t.text, fontWeight: 600 }}>Verify →</span>, чтобы продолжить.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2: Verify ───────────────────────────────────────────────────
|
||||
function WizVerify({ t, D, fontMono, palette, themeKey }) {
|
||||
const phase = D.phase;
|
||||
const total = D.tests.length;
|
||||
const completed = Object.keys(D.results).length;
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
const ringFrac = phase === 'checked' ? 1 : completed / total;
|
||||
const ringDash = 163.4;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
{phase === 'checking' ? 'Verifying your proxy' : (failed === 0 ? 'All checks passed' : 'Some checks failed')}
|
||||
</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
{phase === 'checking'
|
||||
? <>Запускаем 7 проверок против <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span></>
|
||||
: (failed === 0
|
||||
? <>Прокси работает. Discord — голос, чат и демонстрация — будет работать через него.</>
|
||||
: <>{failed} из {total} проверок не прошли. Часть функций работать не будет.</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* progress + ring */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16, padding: 14,
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
}}>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="26" fill="none" stroke={t.border} strokeWidth="6" />
|
||||
<circle cx="32" cy="32" r="26" fill="none"
|
||||
stroke={failed > 0 ? t.danger : phase === 'checked' ? t.pass : t.accent}
|
||||
strokeWidth="6" strokeLinecap="round"
|
||||
strokeDasharray={ringDash}
|
||||
strokeDashoffset={ringDash * (1 - ringFrac)}
|
||||
transform="rotate(-90 32 32)"
|
||||
style={{ transition: 'stroke-dashoffset .35s, stroke .2s' }} />
|
||||
<text x="32" y="36" textAnchor="middle" fontSize="13" fontWeight="600" fill={t.text}>
|
||||
{phase === 'checked' ? `${total - failed}/${total}` : `${completed}/${total}`}
|
||||
</text>
|
||||
</svg>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||
{phase === 'checking' ? <>Testing <span style={{ fontFamily: fontMono }}>{D.tests.find(x => x.id === D.running)?.label || '…'}</span></> : (failed === 0 ? 'Готово к подключению' : 'Завершено с ошибками')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: t.dim, marginTop: 2 }}>
|
||||
{phase === 'checking' ? 'This usually takes 5–15 seconds.' : 'См. отчёт ниже.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* test list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 12.5 }}>
|
||||
{D.tests.map((test) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 9, padding: '5px 10px',
|
||||
background: D.running === test.id ? t.accentSoft : 'transparent',
|
||||
borderRadius: 4, minHeight: 26,
|
||||
}}>
|
||||
<window.StatusDot state={state} palette={palette} size={12} />
|
||||
<span style={{ color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>{test.label}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? 'running…' : '')}
|
||||
</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)}
|
||||
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
|
||||
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '2px 0 4px 22px', padding: '8px 10px', borderRadius: 6,
|
||||
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
|
||||
border: `1px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim }}>{r.hint}</div>
|
||||
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
|
||||
style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>copy error</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: Connect (active) ───────────────────────────────────────────
|
||||
function WizConnect({ t, D, fontMono }) {
|
||||
const stats = D.stats;
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 10, height: 10, borderRadius: 5, background: failed > 0 ? t.warn : t.pass,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
{failed > 0 ? 'Connected · UDP fallback' : 'Connected'}
|
||||
</h2>
|
||||
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
|
||||
Discord routes through <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span>
|
||||
{' · '}<span style={{ fontFamily: fontMono }}>{window.fmtUptime(stats.uptimeS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* stats grid */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8,
|
||||
}}>
|
||||
<WizStat t={t} fontMono={fontMono} label="Upload" value={window.fmtBytes(stats.up)} accent={t.accent} />
|
||||
<WizStat t={t} fontMono={fontMono} label="Download" value={window.fmtBytes(stats.down)} accent={t.pass} />
|
||||
<WizStat t={t} fontMono={fontMono} label="TCP" value={stats.tcp} accent={t.accent} />
|
||||
<WizStat t={t} fontMono={fontMono} label="UDP" value={stats.udp} accent={failed > 0 ? t.warn : t.pass} />
|
||||
</div>
|
||||
|
||||
{/* logs panel inline */}
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: 12, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 600, letterSpacing: 0.2 }}>RECENT LOG</span>
|
||||
<button onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}
|
||||
style={{ marginLeft: 'auto', padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>copy</button>
|
||||
<button onClick={D.clearLogs}
|
||||
style={{ marginLeft: 6, padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
|
||||
background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
|
||||
borderRadius: 4, cursor: 'pointer' }}>clear</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
flex: 1, overflowY: 'auto', fontFamily: fontMono, fontSize: 11,
|
||||
lineHeight: 1.55, color: t.dim, minHeight: 0,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.slice(-30).map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WizStat({ t, fontMono, label, value, accent }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
}}>
|
||||
<div style={{ fontSize: 10.5, color: t.dim, letterSpacing: 0.6, textTransform: 'uppercase', fontWeight: 600 }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: fontMono, marginTop: 3, color: accent }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardPrimaryBtn({ t, onClick, disabled, children }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 32, padding: '0 18px', borderRadius: 6, border: 'none',
|
||||
background: disabled ? t.panelAlt : (hover ? '#1a5fbf' : t.accent),
|
||||
color: disabled ? t.dimmer : '#fff',
|
||||
fontWeight: 600, fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.18)',
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
function WizardSecondaryBtn({ t, onClick, disabled, children }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
height: 32, padding: '0 14px', borderRadius: 6,
|
||||
background: hover && !disabled ? t.panelAlt : t.panel,
|
||||
color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.borderHard}`,
|
||||
fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
window.WizardWindow = WizardWindow;
|
||||
@@ -0,0 +1,322 @@
|
||||
// drover-workshop.jsx — Workshop: industrial slate + amber accent. Subtle texture.
|
||||
|
||||
const WSH = {
|
||||
bg: '#181a1d', chrome: '#121417', panel: '#1f2226', panel2: '#23272c',
|
||||
border: '#2c3036', borderSoft: '#222529',
|
||||
text: '#e5e3df', dim: '#9a958c', dimmer: '#5e5b56',
|
||||
accent: '#e89a3c', accentSoft: 'rgba(232,154,60,0.15)',
|
||||
danger: '#d56654', warn: '#d4a248', pass: '#7fb37b', skip: '#7c7872',
|
||||
inputBg: '#141619',
|
||||
primaryFg: '#1a120a',
|
||||
};
|
||||
|
||||
function WorkshopWindow({ initial }) {
|
||||
const t = WSH;
|
||||
const D = window.useDrover(initial);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
|
||||
const fontUI = '"IBM Plex Sans","Inter",system-ui,sans-serif';
|
||||
const fontMono = '"IBM Plex Mono","JetBrains Mono",ui-monospace,monospace';
|
||||
const isActive = D.phase === 'active';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
|
||||
fontFamily: fontUI, fontSize: 13, overflow:'hidden',
|
||||
border:'1px solid #000', position:'relative',
|
||||
}}>
|
||||
{/* subtle grain */}
|
||||
<div style={{
|
||||
position:'absolute', inset: 0, pointerEvents:'none', opacity: 0.5,
|
||||
backgroundImage: `radial-gradient(rgba(255,255,255,0.014) 1px, transparent 1px)`,
|
||||
backgroundSize: '3px 3px',
|
||||
}}/>
|
||||
|
||||
{/* title — workshop label tag */}
|
||||
<div style={{
|
||||
height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`,
|
||||
display:'flex', alignItems:'center', userSelect:'none', position:'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 4, alignSelf:'stretch', background: t.accent, marginRight: 12,
|
||||
}}/>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, flex: 1 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: 1.5, textTransform:'uppercase' }}>Drover-Go</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: t.dim, fontFamily: fontMono, padding:'1px 5px',
|
||||
border:`1px solid ${t.border}`, borderRadius: 1,
|
||||
}}>v0.4.2</span>
|
||||
</div>
|
||||
<div style={{ display:'flex' }}>
|
||||
{[<window.IconGear color={t.dim}/>, <window.IconMin color={t.dim}/>, <window.IconClose color={t.dim}/>].map((ic,i) => (
|
||||
<div key={i} style={{
|
||||
width: 40, height: 36, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
|
||||
borderLeft:`1px solid ${t.borderSoft}`,
|
||||
}}>{ic}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px', position:'relative', zIndex: 1 }}>
|
||||
{/* form */}
|
||||
<WshHead t={t}>SOCKS5 PROXY</WshHead>
|
||||
<div style={{ background: t.panel, padding: 13, borderLeft:`3px solid ${t.accent}`, borderRadius: 1 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<WshField t={t} label="HOST" style={{ flex: 1 }}>
|
||||
<WshInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
|
||||
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
<WshField t={t} label="PORT" style={{ width: 96 }}>
|
||||
<WshInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
</div>
|
||||
<div style={{ height: 11 }}/>
|
||||
<WshSwitch t={t} checked={D.form.auth}
|
||||
onChange={v => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('wsh-login')?.focus(),30); }}>
|
||||
AUTHENTICATION
|
||||
</WshSwitch>
|
||||
<div style={{
|
||||
display:'flex', gap: 10, marginTop: 10,
|
||||
opacity: D.form.auth?1:0.4, pointerEvents: D.form.auth?'auto':'none',
|
||||
}}>
|
||||
<WshField t={t} label="LOGIN" style={{ flex: 1 }}>
|
||||
<WshInput id="wsh-login" t={t} value={D.form.login} disabled={!D.form.auth}
|
||||
onChange={v => D.update({ login: v })} placeholder="user" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
<WshField t={t} label="PASSWORD" style={{ flex: 1 }}>
|
||||
<WshInput t={t} value={D.form.password} type="password" disabled={!D.form.auth}
|
||||
onChange={v => D.update({ password: v })} placeholder="••••••" onSubmit={D.runCheck} fontUI={fontUI}/>
|
||||
</WshField>
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
|
||||
width:'100%', padding:'9px 14px', borderRadius: 1, border:'none',
|
||||
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
|
||||
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: D.phase==='checking'?'not-allowed':'pointer', fontFamily: fontUI,
|
||||
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<WshHead t={t} right={
|
||||
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
|
||||
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
|
||||
}>STATUS</WshHead>
|
||||
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
|
||||
{D.phase === 'idle'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim }}>
|
||||
<span style={{ width: 7, height: 7, background: t.dimmer }}/>
|
||||
Ready to check
|
||||
</div>
|
||||
: <WshStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 14 }}/>
|
||||
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
|
||||
<div style={{ display:'flex', gap: 10 }}>
|
||||
<WshStartBtn t={t} D={D} fontUI={fontUI}/>
|
||||
<WshStopBtn t={t} D={D} fontUI={fontUI}/>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
|
||||
display:'flex', justifyContent:'space-between', color: t.dim,
|
||||
}}>
|
||||
<Stat val={window.fmtBytes(D.stats.up)} lbl="↑" t={t} fontMono={fontMono} hl={t.pass}/>
|
||||
<Stat val={window.fmtBytes(D.stats.down)} lbl="↓" t={t} fontMono={fontMono} hl={t.accent}/>
|
||||
<Stat val={D.stats.tcp} lbl="tcp" t={t} fontMono={fontMono}/>
|
||||
<Stat val={D.stats.udp} lbl="udp" t={t} fontMono={fontMono}/>
|
||||
<Stat val={window.fmtUptime(D.stats.uptimeS)} lbl="up" t={t} fontMono={fontMono}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 12 }}/>
|
||||
</div>
|
||||
<WshLogs t={t} D={D} fontMono={fontMono}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WshHead({ t, children, right }) {
|
||||
return (
|
||||
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
|
||||
<div style={{
|
||||
fontSize: 10.5, fontWeight: 700, letterSpacing: 2, color: t.dim,
|
||||
display:'flex', alignItems:'center', gap: 8,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 2, background: t.accent }}/>
|
||||
{children}
|
||||
</div>
|
||||
{right && <div style={{ marginLeft:'auto', fontSize: 10.5, color: t.dimmer, fontFamily:'"IBM Plex Mono",monospace', letterSpacing: 0.5 }}>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WshField({ t, label, children, style }) {
|
||||
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
|
||||
<span style={{ fontSize: 10, color: t.dim, letterSpacing: 1.5, fontWeight: 600 }}>{label}</span>{children}
|
||||
</label>;
|
||||
}
|
||||
function WshInput({ t, value, onChange, type, placeholder, onSubmit, disabled, id, fontUI, inputMode }) {
|
||||
return <input id={id} value={value} type={type||'text'} disabled={disabled} inputMode={inputMode}
|
||||
onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
|
||||
style={{
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border:`1px solid ${t.border}`, borderRadius: 1, padding:'8px 10px',
|
||||
fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
|
||||
}}/>;
|
||||
}
|
||||
function WshSwitch({ t, checked, onChange, children }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 9, cursor:'pointer' }}>
|
||||
<span style={{
|
||||
width: 32, height: 18, padding: 2, boxSizing:'border-box',
|
||||
background: checked ? t.accent : t.border,
|
||||
display:'flex', alignItems:'center', transition:'background .14s', borderRadius: 1,
|
||||
}}>
|
||||
<span style={{
|
||||
width: 12, height: 12, background: checked ? '#1a120a' : '#9a958c',
|
||||
transform: checked ? 'translateX(14px)' : 'translateX(0)', transition:'transform .14s',
|
||||
}}/>
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 1.2 }}>{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function WshStatus({ t, D, palette, fontMono }) {
|
||||
return (
|
||||
<div>
|
||||
{D.phase === 'checking'
|
||||
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
|
||||
<window.StatusDot state="running" palette={palette} size={14}/>
|
||||
<span style={{ fontWeight: 500 }}>Running diagnostics…</span>
|
||||
</div>
|
||||
: <div style={{
|
||||
padding:'8px 11px', marginBottom: 10, fontSize: 12, fontWeight: 600, letterSpacing: 0.5,
|
||||
background: D.lastSummary?.failed === 0 ? `${t.pass}1f` : t.accentSoft,
|
||||
borderLeft: `3px solid ${D.lastSummary?.failed === 0 ? t.pass : t.accent}`,
|
||||
color: D.lastSummary?.failed === 0 ? t.pass : t.accent,
|
||||
}}>
|
||||
{D.lastSummary?.failed === 0
|
||||
? 'All checks passed. Ready to start.'
|
||||
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
|
||||
</div>}
|
||||
<div>
|
||||
{D.tests.map(test => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
return (
|
||||
<div key={test.id} style={{ borderBottom:`1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 28 }}>
|
||||
<window.StatusDot state={state} palette={palette} size={13}/>
|
||||
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 12.5 }} title={test.desc}>{test.label}</span>
|
||||
<span style={{
|
||||
marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
|
||||
}}>{r?.metric}</span>
|
||||
{r?.result === 'failed' && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={{
|
||||
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
|
||||
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
|
||||
)}
|
||||
</div>
|
||||
{r?.result === 'failed' && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '0 0 6px 23px', padding:'7px 10px',
|
||||
background: t.panel2, borderLeft:`2px solid ${t.danger}`, fontSize: 12,
|
||||
}}>
|
||||
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
|
||||
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function WshStartBtn({ t, D, fontUI }) {
|
||||
const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
|
||||
const ok = D.phase === 'checked' && !allFailed;
|
||||
const active = D.phase === 'active';
|
||||
const warning = active && (D.lastSummary?.failed||0) > 0;
|
||||
if (active) {
|
||||
const c = warning ? t.warn : t.pass;
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, fontWeight: 700, fontSize: 12, letterSpacing: 1.5,
|
||||
background: `${c}1f`, color: c, borderLeft:`3px solid ${c}`,
|
||||
textTransform:'uppercase', display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{ width: 8, height: 8, background: c }}/>
|
||||
Active{warning ? ' · UDP warn' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <button onClick={D.startProxy} disabled={!ok} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, border:'none',
|
||||
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Start proxying</button>;
|
||||
}
|
||||
function WshStopBtn({ t, D, fontUI }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return <button onClick={D.stopProxy} disabled={!enabled} style={{
|
||||
flex: 1, padding:'9px 14px', borderRadius: 1, background:'transparent',
|
||||
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
|
||||
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
|
||||
cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
|
||||
}}>Stop</button>;
|
||||
}
|
||||
function Stat({ val, lbl, t, fontMono, hl }) {
|
||||
return <div style={{ display:'flex', alignItems:'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: hl||t.dimmer, textTransform:'uppercase', letterSpacing: 0.5, fontWeight: 700 }}>{lbl}</span>
|
||||
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
|
||||
</div>;
|
||||
}
|
||||
function WshLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 1 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 10,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 11,
|
||||
letterSpacing: 1.5, textTransform:'uppercase',
|
||||
}}>
|
||||
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
|
||||
<span style={{ fontWeight: 700 }}>Logs</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 10.5, color: t.dimmer, letterSpacing: 0 }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<>
|
||||
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
|
||||
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
|
||||
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
|
||||
<button key={l} onClick={fn||undefined} style={{
|
||||
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 1,
|
||||
padding:'3px 8px', fontSize: 10.5, color: t.dim, cursor:'pointer',
|
||||
fontFamily: fontMono, letterSpacing: 1, textTransform:'uppercase',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="drv-log" ref={el => el && (el.scrollTop = el.scrollHeight)}
|
||||
style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.6, color: t.dim, background: t.panel }}>
|
||||
{D.logs.map((l,i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700 }}>[{l.level}]</span>{' '}
|
||||
{l.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.WorkshopWindow = WorkshopWindow;
|
||||
@@ -0,0 +1,188 @@
|
||||
# Checker — 7-step SOCKS5 diagnostic
|
||||
|
||||
**Status**: design accepted 2026-05-01.
|
||||
**Replaces**: stub `RunCheck` in `internal/gui/app.go` that emits fake events.
|
||||
|
||||
## Why
|
||||
|
||||
The Wails GUI exposes a "Check connection" button that the user presses
|
||||
before turning the engine on. Today it walks through a hard-coded scenario
|
||||
in Go, returning bogus metrics. The user can't tell whether their proxy
|
||||
is alive, supports UDP, or whether Discord blocks it. We need an honest
|
||||
diagnostic that tells the user exactly which capability of their SOCKS5
|
||||
proxy works and which doesn't, with hex-level evidence on failure.
|
||||
|
||||
## API surface
|
||||
|
||||
```go
|
||||
// internal/checker/checker.go
|
||||
package checker
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusPassed Status = "passed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusSkipped Status = "skipped"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ProxyHost string
|
||||
ProxyPort int
|
||||
UseAuth bool
|
||||
ProxyLogin string
|
||||
ProxyPassword string
|
||||
|
||||
PerTestTimeout time.Duration
|
||||
MaxRetries int
|
||||
RetryBackoff time.Duration
|
||||
|
||||
DiscordGateway string
|
||||
DiscordAPI string
|
||||
StunServer string
|
||||
|
||||
// voice-quality burst tuning
|
||||
VoiceBurstCount int // default 30
|
||||
VoiceBurstInterval time.Duration // default 20ms
|
||||
}
|
||||
|
||||
// StatusWarn is a "soft pass" — the test technically succeeded but
|
||||
// the user should know about a degradation (e.g. voice quality at the
|
||||
// upper end of acceptable). Frontend renders it like StatusPassed but
|
||||
// keeps the Hint visible.
|
||||
const StatusWarn Status = "warn"
|
||||
|
||||
// Run streams Results to the returned channel and closes it when finished
|
||||
// or when ctx is cancelled. The first event for each test is Status=running;
|
||||
// the next is the final state (passed/failed/skipped). On retry, another
|
||||
// running+final pair is emitted with Attempt > 1.
|
||||
func Run(ctx context.Context, cfg Config) <-chan Result
|
||||
```
|
||||
|
||||
Defaults applied when zero values are passed: PerTestTimeout=5s, MaxRetries=1,
|
||||
RetryBackoff=500ms, DiscordGateway="gateway.discord.gg:443",
|
||||
DiscordAPI="https://discord.com/api/v9/gateway",
|
||||
StunServer="stun.l.google.com:19302".
|
||||
|
||||
## The seven tests
|
||||
|
||||
Sequential. Each test reuses sockets opened by previous tests when sensible.
|
||||
|
||||
| ID | What it does | Considered failed when | Skip rule |
|
||||
|----|--------------|------------------------|-----------|
|
||||
| `tcp` | `net.DialTimeout("tcp", host:port)` | dial error | never |
|
||||
| `greet` | Sends SOCKS5 client greeting `05 02 00 02` (or `05 01 00` if UseAuth=false). Reads 2 bytes. Pass = `05 00` (no auth) or `05 02` (auth required). Fail on `05 FF`, anything else, or short read | proxy returned non-SOCKS5 / refused all auth methods | skipped if `tcp` failed |
|
||||
| `auth` | Only emitted when UseAuth=true. RFC 1929 sub-negotiation: `01 LEN_LOGIN LOGIN LEN_PASS PASS`. Reads 2 bytes, expects `01 00`. | bad credentials (`01 != 00`) / short read | not in test list when UseAuth=false; skipped if `greet` failed |
|
||||
| `connect` | SOCKS5 CONNECT to `gateway.discord.gg:443` (ATYP=03 domain). Reads 10 bytes. Pass = REP=0x00. | REP != 0 (0x05 = connection refused, etc) / timeout | skipped if `greet`/`auth` failed |
|
||||
| `udp` | UDP ASSOCIATE: opens **second** TCP control channel, redoes greeting+auth there, sends `05 03 00 01 00000000 0000`, reads 10-byte reply. Pass = REP=0x00 + valid relay endpoint in BND.ADDR/BND.PORT. | REP=0x07 (cmd unsupported), other REP, short read | skipped if `greet` failed |
|
||||
| `voice-quality` | Through the relay: send `VoiceBurstCount` (default 30) STUN binding requests to `cfg.StunServer`, spaced `VoiceBurstInterval` (default 20ms). Listen until `last_send + 1.5*PerTestTimeout`. Compute `loss%`, `jitter` (mean abs delta of inter-arrival deltas, à la RFC 3550 simplified), `p50 RTT`. Metric = `"loss=2% jitter=14ms p50=42ms"`. **Pass** = loss ≤ 5% AND jitter ≤ 30ms AND p50 ≤ 250ms. **Warn-pass** (status=passed but Hint set) = loss ≤ 15% AND jitter ≤ 60ms — voice will work with audible glitches. **Fail** = anything worse. | loss > 15% OR jitter > 60ms OR p50 > 400ms OR no replies at all | skipped if `udp` failed |
|
||||
| `api` | TCP CONNECT through the proxy to `discord.com:443`, do a tiny HTTPS GET `/api/v9/gateway`. Pass = HTTP 200 or 401 (Discord returns 401 unauthenticated, that still proves reachability). | non-200/401 / TLS handshake failed / connect refused | skipped if `connect` failed |
|
||||
|
||||
For each fail, the `Hint` field carries a Russian explanation (the GUI is
|
||||
RU-localized) and `RawHex` carries the first 32 bytes of any unexpected
|
||||
response (for the expand-debug section in the UI).
|
||||
|
||||
## Cancel & retry
|
||||
|
||||
- `ctx` is honoured at every blocking call (Dial uses DialContext, reads
|
||||
use SetDeadline derived from PerTestTimeout). On cancel, current test
|
||||
emits a final `failed` result with Error="cancelled" and the channel
|
||||
closes; remaining tests get a single `skipped` event each.
|
||||
- Auto-retry once on transient errors:
|
||||
- timeout (`net.Error.Timeout()`)
|
||||
- "connection reset by peer"
|
||||
- DNS temporary failure
|
||||
- NOT retried (likely user-config error or hard failure):
|
||||
- connection refused
|
||||
- bad credentials (REP=0x02, AUTH=0x01)
|
||||
- REP=0x07 (cmd unsupported)
|
||||
- HTTP 4xx/5xx other than 401 on `api`
|
||||
- Between attempts: sleep `RetryBackoff`.
|
||||
|
||||
## Wails integration
|
||||
|
||||
`internal/gui/app.go::RunCheck(cfg Config)` becomes:
|
||||
|
||||
```go
|
||||
func (a *App) RunCheck(cfg Config) {
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
a.muCheck.Lock()
|
||||
a.cancelCheck = cancel
|
||||
a.muCheck.Unlock()
|
||||
|
||||
go func() {
|
||||
ck := mapToCheckerConfig(cfg)
|
||||
var passed, failed int
|
||||
for r := range checker.Run(ctx, ck) {
|
||||
runtime.EventsEmit(a.ctx, "check:result", r)
|
||||
if r.Status == checker.StatusPassed { passed++ }
|
||||
if r.Status == checker.StatusFailed { failed++ }
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
||||
"total": passed + failed, "passed": passed, "failed": failed,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) CancelCheck() {
|
||||
a.muCheck.Lock()
|
||||
if a.cancelCheck != nil { a.cancelCheck() }
|
||||
a.muCheck.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
A new `CancelCheck` binding lets the GUI's Cancel button stop a running
|
||||
diagnostic. The frontend's `useDrover` hook gets a `cancelCheck()`
|
||||
callback that calls it.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for each test function with a fake SOCKS5 server (`net.Listen`,
|
||||
hand-rolled byte responses) — covers happy path, every documented failure
|
||||
mode, malformed responses (truncated, wrong protocol, garbage).
|
||||
- STUN test uses a real `pion/stun` server in-process via `net.Listen("udp")`.
|
||||
- Discord-API and `connect` tests use the same fake SOCKS5 server tunneling
|
||||
to `httptest.NewTLSServer` and `net.Listen("tcp")`.
|
||||
- One end-to-end test against a real `mihomo` instance is documented in
|
||||
`docs/testing/checker-e2e.md` but not part of `go test ./...` (requires
|
||||
network).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
internal/checker/
|
||||
checker.go ─ public API: Run, Result, Config
|
||||
socks5.go ─ greeting, auth, CONNECT, UDP ASSOCIATE primitives
|
||||
stun.go ─ STUN binding-request encode/decode (no library —
|
||||
we already vendor enough; ~80 LOC)
|
||||
retry.go ─ classify(err) -> transient | permanent
|
||||
hints.go ─ map test failure → user hint (RU)
|
||||
checker_test.go ─ Run-level integration with fake server
|
||||
socks5_test.go ─ per-primitive table tests
|
||||
stun_test.go ─ encode/decode + RTT mock
|
||||
```
|
||||
|
||||
`internal/gui/app.go` gets `RunCheck` rewritten and a new `CancelCheck`
|
||||
method. The fake SCENARIOS path in app.go is removed.
|
||||
|
||||
## Out of scope (future work)
|
||||
|
||||
- IPv6 SOCKS5 ATYP=04. Discord today is IPv4; we'll add when we hit a
|
||||
proxy that's v6-only.
|
||||
- Parallel test execution (e.g. running `connect` and `udp` simultaneously
|
||||
on separate sessions). Sequential is clearer for the UI; we'll revisit
|
||||
if total runtime exceeds 10s on common networks.
|
||||
- TLS certificate pinning on `api`. The `tls.Config` is default — fine for
|
||||
reachability check.
|
||||
@@ -5,13 +5,44 @@ go 1.23
|
||||
require (
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/mod v0.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/mod v0.23.0
|
||||
golang.org/x/sys v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,34 +1,116 @@
|
||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status represents the lifecycle state of a single test.
|
||||
type Status string
|
||||
|
||||
// Result statuses emitted on the channel.
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusPassed Status = "passed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusSkipped Status = "skipped"
|
||||
// StatusWarn is a "soft pass" — the test technically succeeded but
|
||||
// the user should know about a degradation (e.g. voice quality at the
|
||||
// upper end of acceptable, or all Discord voice domains resolve but
|
||||
// the proxy filters their TCP). Frontend renders it like StatusPassed
|
||||
// but keeps the Hint visible.
|
||||
StatusWarn Status = "warn"
|
||||
)
|
||||
|
||||
// Result is one event in the diagnostic stream. Multiple Results may be
|
||||
// emitted per test (one per attempt: running + passed/failed; on retry,
|
||||
// running again then passed/failed).
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
// Config drives Run. Zero-value fields receive defaults via applyDefaults.
|
||||
type Config struct {
|
||||
ProxyHost string
|
||||
ProxyPort int
|
||||
UseAuth bool
|
||||
ProxyLogin string
|
||||
ProxyPassword string
|
||||
|
||||
PerTestTimeout time.Duration
|
||||
MaxRetries int
|
||||
RetryBackoff time.Duration
|
||||
|
||||
DiscordGateway string
|
||||
DiscordAPI string
|
||||
StunServer string
|
||||
|
||||
// Voice-quality burst tuning (see runVoiceQuality). Defaults: 30
|
||||
// packets, 20ms between sends.
|
||||
VoiceBurstCount int
|
||||
VoiceBurstInterval time.Duration
|
||||
}
|
||||
|
||||
// applyDefaults returns a copy of cfg with zero-valued knobs filled in.
|
||||
func applyDefaults(cfg Config) Config {
|
||||
if cfg.PerTestTimeout <= 0 {
|
||||
cfg.PerTestTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.MaxRetries < 0 {
|
||||
cfg.MaxRetries = 0
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
// Distinguish "explicit 0" from "unset" — spec says default is 1.
|
||||
// applyDefaults runs on a copy of the caller's Config; we treat
|
||||
// a literal zero as "use default" so a fresh `Config{}` works.
|
||||
cfg.MaxRetries = 1
|
||||
}
|
||||
if cfg.RetryBackoff < 0 {
|
||||
cfg.RetryBackoff = 500 * time.Millisecond
|
||||
}
|
||||
if cfg.RetryBackoff == 0 {
|
||||
cfg.RetryBackoff = 500 * time.Millisecond
|
||||
}
|
||||
if cfg.DiscordGateway == "" {
|
||||
cfg.DiscordGateway = "gateway.discord.gg:443"
|
||||
}
|
||||
if cfg.DiscordAPI == "" {
|
||||
cfg.DiscordAPI = "https://discord.com/api/v9/gateway"
|
||||
}
|
||||
if cfg.StunServer == "" {
|
||||
cfg.StunServer = "stun.l.google.com:19302"
|
||||
}
|
||||
if cfg.VoiceBurstCount <= 0 {
|
||||
cfg.VoiceBurstCount = 30
|
||||
}
|
||||
if cfg.VoiceBurstInterval <= 0 {
|
||||
cfg.VoiceBurstInterval = 20 * time.Millisecond
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Run executes the 7-step diagnostic and streams Results on the returned
|
||||
// channel. The channel is closed when the run finishes (or is cancelled).
|
||||
//
|
||||
// Cancel ctx to abort: the in-flight test emits a Failed Result with
|
||||
// Error="cancelled", and remaining tests each emit a single Skipped Result.
|
||||
func Run(ctx context.Context, cfg Config) <-chan Result {
|
||||
cfg = applyDefaults(cfg)
|
||||
ch := make(chan Result, 16)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
e := &executor{ctx: ctx, cfg: cfg, ch: ch}
|
||||
defer e.cleanup()
|
||||
|
||||
e.runTCP()
|
||||
e.runGreet()
|
||||
if cfg.UseAuth {
|
||||
e.runAuth()
|
||||
}
|
||||
e.runConnect()
|
||||
e.runUDP()
|
||||
e.runVoiceQuality()
|
||||
e.runAPI()
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// executor carries shared state across the 7 test methods.
|
||||
type executor struct {
|
||||
ctx context.Context
|
||||
cfg Config
|
||||
ch chan<- Result
|
||||
|
||||
// tcpConn is opened in runTCP and reused by greet/auth/connect.
|
||||
tcpConn net.Conn
|
||||
|
||||
// udpConn2 is the SECOND TCP control channel opened in runUDP.
|
||||
// Must stay alive until stun finishes — the SOCKS5 spec requires
|
||||
// the control TCP connection to remain up for the relay to be
|
||||
// valid.
|
||||
udpConn2 net.Conn
|
||||
|
||||
// udpRelay is the UDP relay endpoint announced by the proxy in
|
||||
// the UDP ASSOCIATE reply.
|
||||
udpRelay *net.UDPAddr
|
||||
|
||||
// udpClient is our local UDP socket used to talk to the relay.
|
||||
udpClient net.PacketConn
|
||||
|
||||
// Step gating: each xOK is set true on success (or "soft pass"
|
||||
// warn for voice-quality).
|
||||
tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool
|
||||
|
||||
// Cancellation latch. Once any test emits a "cancelled" failure,
|
||||
// remaining tests emit a single Skipped result with the same reason.
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
// cleanup closes any state opened during the run.
|
||||
func (e *executor) cleanup() {
|
||||
if e.tcpConn != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
}
|
||||
if e.udpConn2 != nil {
|
||||
_ = e.udpConn2.Close()
|
||||
}
|
||||
if e.udpClient != nil {
|
||||
_ = e.udpClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// emit sends a Result on the channel, respecting ctx so a stalled consumer
|
||||
// doesn't block us forever.
|
||||
func (e *executor) emit(r Result) {
|
||||
select {
|
||||
case e.ch <- r:
|
||||
case <-e.ctx.Done():
|
||||
// Best-effort: try once more so we don't drop user-visible
|
||||
// information just because cancel raced the send.
|
||||
select {
|
||||
case e.ch <- r:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkipped pushes a single skipped Result with a constant reason.
|
||||
func (e *executor) emitSkipped(id, reason string) {
|
||||
e.emit(Result{ID: id, Status: StatusSkipped, Error: reason})
|
||||
}
|
||||
|
||||
// emitCancelled pushes a single failed Result with Error="cancelled".
|
||||
func (e *executor) emitCancelled(id string, attempt int, dur time.Duration) {
|
||||
e.cancelled = true
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: "cancelled",
|
||||
Hint: hintFor(id, context.Canceled),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
}
|
||||
|
||||
// shouldSkip checks high-level guard conditions and emits the appropriate
|
||||
// pre-test Result if we shouldn't run. Returns true if the caller should
|
||||
// abort the test.
|
||||
func (e *executor) shouldSkip(id string, depOK bool) bool {
|
||||
if e.cancelled {
|
||||
e.emitSkipped(id, "cancelled")
|
||||
return true
|
||||
}
|
||||
if !depOK {
|
||||
e.emitSkipped(id, skipReason)
|
||||
return true
|
||||
}
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled(id, 1, 0)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const skipReason = "depends on previous failed step"
|
||||
|
||||
// rawHexRE pulls "...(raw=DEADBEEF)" out of a wrapped error string.
|
||||
var rawHexRE = regexp.MustCompile(`\(raw=([0-9a-fA-F]+)\)`)
|
||||
|
||||
// extractRawHex pulls the hex payload out of our `(raw=XX...)` error
|
||||
// wrapping convention. Returns "" if absent.
|
||||
func extractRawHex(s string) string {
|
||||
m := rawHexRE.FindStringSubmatch(s)
|
||||
if len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runAttempt is the inner loop shared by all tests. It handles emitting
|
||||
// running/passed/failed results, retry classification and backoff.
|
||||
//
|
||||
// run does the actual work for one attempt and returns metric + err.
|
||||
func (e *executor) runAttempt(id string, run func(ctx context.Context) (string, error)) (ok bool) {
|
||||
maxAttempts := 1 + e.cfg.MaxRetries
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled(id, attempt, 0)
|
||||
return false
|
||||
}
|
||||
|
||||
// Emit running for this attempt.
|
||||
e.emit(Result{ID: id, Status: StatusRunning, Attempt: attempt})
|
||||
|
||||
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
|
||||
start := time.Now()
|
||||
metric, err := run(attemptCtx)
|
||||
dur := time.Since(start)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusPassed,
|
||||
Metric: metric,
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Parent-ctx cancelled? Emit cancelled and stop (no retry
|
||||
// into a cancelled context). We check the PARENT ctx, not
|
||||
// attemptCtx (which always expires after PerTestTimeout).
|
||||
if e.ctx.Err() != nil {
|
||||
e.emitCancelled(id, attempt, dur)
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-attempt deadline expired (PerTestTimeout fired) —
|
||||
// treat as a transient timeout. We need to override
|
||||
// classifyError here because err's chain contains
|
||||
// context.DeadlineExceeded (joinCtxErr embeds attemptCtx.Err)
|
||||
// which classifyError treats as permanent. The semantic
|
||||
// distinction is "our per-test budget vs caller cancel" —
|
||||
// the former is exactly what retries are for.
|
||||
var class Classification
|
||||
if isContextErr(err) {
|
||||
// Parent ctx is fine (checked above), so this is a
|
||||
// per-attempt deadline = transient.
|
||||
class = ClassificationTransient
|
||||
} else {
|
||||
class = classifyError(err)
|
||||
}
|
||||
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||
if canRetry {
|
||||
// Failed-but-will-retry: still emit Failed for the
|
||||
// observer (so they see the attempt happened), but
|
||||
// loop. Some consumers only show the LAST failure;
|
||||
// emitting every attempt is the more transparent
|
||||
// option. Spec says "emit running + passed/failed
|
||||
// per attempt".
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: err.Error(),
|
||||
Hint: hintFor(id, err),
|
||||
RawHex: extractRawHex(err.Error()),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
// Sleep with cancel awareness.
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
case <-e.ctx.Done():
|
||||
// Caller cancelled during backoff — stop without retry.
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Final failure (permanent or out of retries).
|
||||
e.emit(Result{
|
||||
ID: id,
|
||||
Status: StatusFailed,
|
||||
Error: err.Error(),
|
||||
Hint: hintFor(id, err),
|
||||
RawHex: extractRawHex(err.Error()),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// proxyAddr returns the SOCKS5 proxy host:port string.
|
||||
func (e *executor) proxyAddr() string {
|
||||
return net.JoinHostPort(e.cfg.ProxyHost, strconv.Itoa(e.cfg.ProxyPort))
|
||||
}
|
||||
|
||||
// runTCP — Test 1: dial the proxy.
|
||||
func (e *executor) runTCP() {
|
||||
if e.cancelled {
|
||||
e.emitSkipped("tcp", "cancelled")
|
||||
return
|
||||
}
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled("tcp", 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("tcp", func(ctx context.Context) (string, error) {
|
||||
// Close any prior conn from a previous attempt.
|
||||
if e.tcpConn != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
}
|
||||
var d net.Dialer
|
||||
start := time.Now()
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.tcpConn = conn
|
||||
ms := time.Since(start).Milliseconds()
|
||||
return fmt.Sprintf("%dms", ms), nil
|
||||
})
|
||||
|
||||
e.tcpOK = ok
|
||||
}
|
||||
|
||||
// runGreet — Test 2: SOCKS5 method negotiation.
|
||||
func (e *executor) runGreet() {
|
||||
if e.shouldSkip("greet", e.tcpOK) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("greet", func(ctx context.Context) (string, error) {
|
||||
// Each attempt needs a fresh conn — the previous attempt
|
||||
// may have written bytes that left the proxy mid-handshake.
|
||||
if err := e.redialTCPIfNeeded(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
method, _, err := socks5Greeting(ctx, e.tcpConn, e.cfg.UseAuth)
|
||||
if err != nil {
|
||||
// Force redial on next attempt.
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
switch method {
|
||||
case 0x00:
|
||||
return "no auth", nil
|
||||
case 0x02:
|
||||
return "auth required", nil
|
||||
default:
|
||||
return fmt.Sprintf("method=0x%02X", method), nil
|
||||
}
|
||||
})
|
||||
e.greetOK = ok
|
||||
}
|
||||
|
||||
// redialTCPIfNeeded drops and re-opens tcpConn. This is called at the
|
||||
// start of each greet/auth/connect attempt after the first to give every
|
||||
// attempt a fresh connection — the proxy may have advanced state on the
|
||||
// previous attempt that we can't roll back.
|
||||
//
|
||||
// On the FIRST attempt for greet, we expect tcpConn to already be open
|
||||
// (from runTCP). The simple rule: if tcpConn==nil, redial; otherwise
|
||||
// keep it. The retry path closes tcpConn before re-running this loop.
|
||||
func (e *executor) redialTCPIfNeeded(ctx context.Context) error {
|
||||
if e.tcpConn != nil {
|
||||
return nil
|
||||
}
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.tcpConn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAuth — Test 3: user/pass sub-negotiation. Only emitted when UseAuth.
|
||||
func (e *executor) runAuth() {
|
||||
if e.shouldSkip("auth", e.greetOK) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("auth", func(ctx context.Context) (string, error) {
|
||||
// On retry: drop the conn and start fresh from greet+auth.
|
||||
// (We can't replay only auth — the proxy has already moved
|
||||
// past method negotiation.)
|
||||
// retry detection: if we have nil tcpConn here, we lost it
|
||||
// in a prior failed attempt and need to redial+regreet.
|
||||
if e.tcpConn == nil {
|
||||
var d net.Dialer
|
||||
conn, derr := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if derr != nil {
|
||||
return "", derr
|
||||
}
|
||||
e.tcpConn = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, e.tcpConn, true); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
}
|
||||
_, err := socks5Auth(ctx, e.tcpConn, e.cfg.ProxyLogin, e.cfg.ProxyPassword)
|
||||
if err != nil {
|
||||
// Force redial+regreet on next attempt.
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
return "ok", nil
|
||||
})
|
||||
e.authOK = ok
|
||||
}
|
||||
|
||||
// runConnect — Test 4: SOCKS5 CONNECT to Discord gateway.
|
||||
func (e *executor) runConnect() {
|
||||
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
|
||||
if e.shouldSkip("connect", dep) {
|
||||
return
|
||||
}
|
||||
|
||||
host, portStr, splitErr := net.SplitHostPort(e.cfg.DiscordGateway)
|
||||
if splitErr != nil {
|
||||
e.emit(Result{
|
||||
ID: "connect",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad DiscordGateway %q: %s", e.cfg.DiscordGateway, splitErr.Error()),
|
||||
Hint: hintFor("connect", splitErr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
||||
if perr != nil {
|
||||
e.emit(Result{
|
||||
ID: "connect",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad DiscordGateway port %q: %s", portStr, perr.Error()),
|
||||
Hint: hintFor("connect", perr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
ok := e.runAttempt("connect", func(ctx context.Context) (string, error) {
|
||||
// On retry: redial+greet+(auth) before re-CONNECT.
|
||||
if e.tcpConn == nil {
|
||||
var d net.Dialer
|
||||
conn, derr := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if derr != nil {
|
||||
return "", derr
|
||||
}
|
||||
e.tcpConn = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, e.tcpConn, e.cfg.UseAuth); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, e.tcpConn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
return "", aerr
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := socks5Connect(ctx, e.tcpConn, host, port)
|
||||
if err != nil {
|
||||
_ = e.tcpConn.Close()
|
||||
e.tcpConn = nil
|
||||
return "", err
|
||||
}
|
||||
return "REP=00", nil
|
||||
})
|
||||
e.connectOK = ok
|
||||
}
|
||||
|
||||
// runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE.
|
||||
func (e *executor) runUDP() {
|
||||
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
|
||||
if e.shouldSkip("udp", dep) {
|
||||
return
|
||||
}
|
||||
|
||||
ok := e.runAttempt("udp", func(ctx context.Context) (string, error) {
|
||||
// Always use a fresh control channel for UDP ASSOCIATE.
|
||||
if e.udpConn2 != nil {
|
||||
_ = e.udpConn2.Close()
|
||||
e.udpConn2 = nil
|
||||
}
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.udpConn2 = conn
|
||||
if _, _, gerr := socks5Greeting(ctx, conn, e.cfg.UseAuth); gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, conn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
return "", aerr
|
||||
}
|
||||
}
|
||||
relay, _, uerr := socks5UDPAssociate(ctx, conn)
|
||||
if uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
e.udpRelay = relay
|
||||
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
|
||||
})
|
||||
e.udpOK = ok
|
||||
}
|
||||
|
||||
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
|
||||
// relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
|
||||
//
|
||||
// - StatusPassed: loss ≤ 5%, jitter ≤ 30ms, p50 ≤ 250ms.
|
||||
// - StatusWarn: loss ≤ 15%, jitter ≤ 60ms, p50 ≤ 400ms — voice will
|
||||
// work but with audible glitches.
|
||||
// - StatusFailed: anything worse, OR no replies at all.
|
||||
//
|
||||
// On warn/pass, voiceQualityOK is true (downstream tests proceed). On
|
||||
// failure it stays false.
|
||||
func (e *executor) runVoiceQuality() {
|
||||
if e.shouldSkip("voice-quality", e.udpOK) {
|
||||
return
|
||||
}
|
||||
|
||||
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
|
||||
if splitErr != nil {
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
|
||||
Hint: hintFor("voice-quality", splitErr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
||||
if perr != nil {
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
|
||||
Hint: hintFor("voice-quality", perr),
|
||||
Attempt: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
stunPort := uint16(port64)
|
||||
|
||||
maxAttempts := 1 + e.cfg.MaxRetries
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.emitCancelled("voice-quality", attempt, 0)
|
||||
return
|
||||
}
|
||||
e.emit(Result{ID: "voice-quality", Status: StatusRunning, Attempt: attempt})
|
||||
|
||||
// Per-test budget: cap burst+listen at PerTestTimeout.
|
||||
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
|
||||
start := time.Now()
|
||||
|
||||
// Open a fresh local UDP socket per attempt.
|
||||
if e.udpClient != nil {
|
||||
_ = e.udpClient.Close()
|
||||
e.udpClient = nil
|
||||
}
|
||||
pc, perr := net.ListenPacket("udp", ":0")
|
||||
if perr != nil {
|
||||
cancel()
|
||||
dur := time.Since(start)
|
||||
class := classifyError(perr)
|
||||
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: fmt.Sprintf("voice-quality: listen udp: %s", perr.Error()),
|
||||
Hint: hintFor("voice-quality", perr),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
if canRetry {
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
continue
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
e.udpClient = pc
|
||||
|
||||
res, berr := runVoiceQualityBurst(
|
||||
attemptCtx, pc, e.udpRelay,
|
||||
host, stunPort,
|
||||
e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval,
|
||||
)
|
||||
dur := time.Since(start)
|
||||
cancel()
|
||||
|
||||
if berr != nil {
|
||||
// Resolution / cancellation. Treat ctx-cancel separately.
|
||||
if e.ctx.Err() != nil {
|
||||
e.emitCancelled("voice-quality", attempt, dur)
|
||||
return
|
||||
}
|
||||
class := classifyError(berr)
|
||||
canRetry := class == ClassificationTransient && attempt < maxAttempts
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: berr.Error(),
|
||||
Hint: hintFor("voice-quality", berr),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
if canRetry {
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
continue
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 100% loss with no underlying error → the relay accepted UDP
|
||||
// (per test 5) but nothing came back. Treat as transient on
|
||||
// the first attempt; permanent on the second.
|
||||
if res.Received == 0 {
|
||||
canRetry := attempt < maxAttempts
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: "no replies received",
|
||||
Hint: voiceQualityFailHint(100.0, 0, 0, 0),
|
||||
Metric: "loss=100%",
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
if canRetry {
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
continue
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
|
||||
res.LossPct, res.JitterMS, res.P50RTTMS)
|
||||
|
||||
switch {
|
||||
case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusPassed,
|
||||
Metric: metric,
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
e.voiceQualityOK = true
|
||||
return
|
||||
case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusWarn,
|
||||
Metric: metric,
|
||||
Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
e.voiceQualityOK = true
|
||||
return
|
||||
default:
|
||||
canRetry := attempt < maxAttempts
|
||||
e.emit(Result{
|
||||
ID: "voice-quality",
|
||||
Status: StatusFailed,
|
||||
Error: metric,
|
||||
Metric: metric,
|
||||
Hint: voiceQualityFailHint(res.LossPct, res.JitterMS, res.P50RTTMS, res.P95RTTMS),
|
||||
Attempt: attempt,
|
||||
Duration: dur,
|
||||
})
|
||||
if canRetry {
|
||||
select {
|
||||
case <-time.After(e.cfg.RetryBackoff):
|
||||
continue
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runAPI — Test 7: HTTP GET Discord API gateway URL through the proxy.
|
||||
func (e *executor) runAPI() {
|
||||
if e.shouldSkip("api", e.connectOK) {
|
||||
return
|
||||
}
|
||||
|
||||
e.runAttempt("api", func(ctx context.Context) (string, error) {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _network, addr string) (net.Conn, error) {
|
||||
return e.dialThroughProxy(ctx, addr)
|
||||
},
|
||||
TLSClientConfig: &tls.Config{},
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: e.cfg.PerTestTimeout,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: e.cfg.PerTestTimeout,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", e.cfg.DiscordAPI, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 || resp.StatusCode == 401 {
|
||||
return fmt.Sprintf("HTTP %d", resp.StatusCode), nil
|
||||
}
|
||||
return "", fmt.Errorf("api: HTTP %d", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// dialThroughProxy is the http.Transport.DialContext used by runAPI. It
|
||||
// opens a TCP connection to the SOCKS5 proxy, performs greet+(auth)+CONNECT
|
||||
// to addr, then returns the established conn.
|
||||
func (e *executor) dialThroughProxy(ctx context.Context, addr string) (net.Conn, error) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("api: split %q: %w", addr, err)
|
||||
}
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("api: bad port %q: %w", portStr, err)
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", e.proxyAddr())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, gerr := socks5Greeting(ctx, conn, e.cfg.UseAuth); gerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, gerr
|
||||
}
|
||||
if e.cfg.UseAuth {
|
||||
if _, aerr := socks5Auth(ctx, conn, e.cfg.ProxyLogin, e.cfg.ProxyPassword); aerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, aerr
|
||||
}
|
||||
}
|
||||
if _, cerr := socks5Connect(ctx, conn, host, port); cerr != nil {
|
||||
_ = conn.Close()
|
||||
return nil, cerr
|
||||
}
|
||||
// Clear the deadline socks5* primitives applied — http.Transport
|
||||
// manages timing past this point.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeProxy is a test SOCKS5 server with per-scenario behaviour. It also
|
||||
// optionally runs a UDP relay that echoes STUN-shaped responses crafted
|
||||
// to look like Binding Success Responses with XOR-MAPPED-ADDRESS pointing
|
||||
// back at the client's source IP.
|
||||
//
|
||||
// The TCP-side splice for the API test detects CONNECT requests targeting
|
||||
// apiTargetHost:apiTargetPort and, instead of sending a synthetic reply,
|
||||
// dials apiTargetAddr and bridges the two conns. This lets a real
|
||||
// httptest.NewServer be used as the API endpoint.
|
||||
type fakeProxy struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
scenario string
|
||||
|
||||
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
|
||||
|
||||
// udpDropEveryN, when > 0, drops every Nth packet through the relay
|
||||
// (counted across the whole listener lifetime). N=2 → 50% loss; N=10
|
||||
// → 10%; N=1 → 100% loss; 0 → no drops.
|
||||
udpDropEveryN atomic.Int32
|
||||
udpRelayCount atomic.Int32
|
||||
|
||||
// API-passthrough hook: when a CONNECT targets this host:port,
|
||||
// the proxy dials apiTargetAddr and splices the conns instead of
|
||||
// sending a fake REP=00 + close.
|
||||
apiTargetHost string
|
||||
apiTargetPort uint16
|
||||
apiTargetAddr string
|
||||
|
||||
// timeoutFirstAttempt stalls the first connection on greet to
|
||||
// drive a timeout. Subsequent connections behave normally.
|
||||
timeoutFirstAttempt atomic.Int32
|
||||
}
|
||||
|
||||
// newFakeProxy starts a TCP listener and a UDP relay (if relevant for
|
||||
// the scenario). Both are torn down via t.Cleanup.
|
||||
func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
|
||||
t.Helper()
|
||||
|
||||
fp := &fakeProxy{t: t, scenario: scenario}
|
||||
|
||||
// Start UDP relay for scenarios that need it.
|
||||
if needsUDPRelay(scenario) {
|
||||
ua, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
uconn, err := net.ListenUDP("udp", ua)
|
||||
require.NoError(t, err)
|
||||
fp.udpRelayAddr = uconn.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
t.Cleanup(func() { _ = uconn.Close() })
|
||||
go fp.runRelay(uconn)
|
||||
}
|
||||
|
||||
// Start TCP listener.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
fp.addr = ln.Addr().String()
|
||||
|
||||
if scenario == "timeout_then_ok" {
|
||||
fp.timeoutFirstAttempt.Store(1)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
go fp.serve(ln)
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
func needsUDPRelay(scenario string) bool {
|
||||
switch scenario {
|
||||
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok",
|
||||
"voice_quality_warn", "voice_quality_fail":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serve accepts connections forever until the listener is closed.
|
||||
func (fp *fakeProxy) serve(ln net.Listener) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go fp.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (fp *fakeProxy) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
// First-attempt-timeout scenario: read greet, then sleep past
|
||||
// the per-test timeout to force a deadline error.
|
||||
if fp.timeoutFirstAttempt.CompareAndSwap(1, 0) {
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = conn.Read(buf)
|
||||
time.Sleep(2 * time.Second)
|
||||
return
|
||||
}
|
||||
|
||||
br := newPeekReader(conn)
|
||||
|
||||
// Step 1: greeting.
|
||||
greet, err := readGreeting(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch fp.scenario {
|
||||
case "all_methods_rejected":
|
||||
_, _ = conn.Write([]byte{0x05, 0xFF})
|
||||
return
|
||||
case "auth_rejected":
|
||||
// Server picks user/pass.
|
||||
_, _ = conn.Write([]byte{0x05, 0x02})
|
||||
// Read auth.
|
||||
_ = readAuth(br)
|
||||
_, _ = conn.Write([]byte{0x01, 0x01}) // status=fail
|
||||
return
|
||||
}
|
||||
|
||||
// Method selection: scenarios that involve auth force 0x02 if
|
||||
// offered; otherwise prefer 0x00.
|
||||
preferAuth := fp.scenario == "happy_with_auth"
|
||||
chosen := byte(0xFF)
|
||||
if preferAuth {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x02 {
|
||||
chosen = 0x02
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x00 {
|
||||
chosen = 0x00
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
for _, m := range greet.methods {
|
||||
if m == 0x02 {
|
||||
chosen = 0x02
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == 0xFF {
|
||||
_, _ = conn.Write([]byte{0x05, 0xFF})
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x05, chosen})
|
||||
|
||||
if chosen == 0x02 {
|
||||
if err := readAuth(br); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x01, 0x00}) // success
|
||||
}
|
||||
|
||||
// Step 2: read CMD request.
|
||||
cmdReq, err := readSocks5Request(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch cmdReq.cmd {
|
||||
case 0x01: // CONNECT
|
||||
switch fp.scenario {
|
||||
case "connect_refused":
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
// API passthrough?
|
||||
if fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort {
|
||||
// Dial real target, splice.
|
||||
target, derr := net.Dial("tcp", fp.apiTargetAddr)
|
||||
if derr != nil {
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
// Clear deadline for the splice.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
_ = target.SetDeadline(time.Time{})
|
||||
// Splice. We can't get already-buffered bytes back
|
||||
// out of br trivially, but the client only sent the
|
||||
// 7+len bytes for CONNECT and we read exactly that —
|
||||
// so br has no leftover buffered bytes here.
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(target, conn); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(conn, target); done <- struct{}{} }()
|
||||
<-done
|
||||
_ = target.Close()
|
||||
return
|
||||
}
|
||||
// Default happy CONNECT.
|
||||
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
// Keep conn open briefly so client doesn't see EOF before
|
||||
// reading the 10-byte reply.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return
|
||||
case 0x03: // UDP ASSOCIATE
|
||||
if fp.scenario == "udp_unsupported" {
|
||||
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
// Reply with our UDP relay endpoint.
|
||||
ip4 := fp.udpRelayAddr.IP.To4()
|
||||
if ip4 == nil {
|
||||
_, _ = conn.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01,
|
||||
ip4[0], ip4[1], ip4[2], ip4[3],
|
||||
byte(fp.udpRelayAddr.Port >> 8), byte(fp.udpRelayAddr.Port)}
|
||||
_, _ = conn.Write(reply)
|
||||
// Keep TCP control channel open so the relay stays valid.
|
||||
// The client will close conn when done. We just block on
|
||||
// read until peer closes.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
return
|
||||
default:
|
||||
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// runRelay reads SOCKS5 UDP datagrams, parses the embedded STUN binding
|
||||
// request, and replies with a synthetic Binding Success Response carrying
|
||||
// XOR-MAPPED-ADDRESS = client's source.
|
||||
func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := uconn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Optional packet-drop simulation. udpDropEveryN of value 1 drops
|
||||
// everything; 2 drops every other packet; 10 drops 10%.
|
||||
if dropN := fp.udpDropEveryN.Load(); dropN > 0 {
|
||||
c := fp.udpRelayCount.Add(1)
|
||||
if c%dropN == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fp.udpRelayCount.Add(1)
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
// Parse SOCKS5 UDP wrapper. Expect ATYP=01.
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReq := buf[hdrLen:n]
|
||||
// Expect a binding request.
|
||||
if len(stunReq) < 20 {
|
||||
continue
|
||||
}
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReq[8:20])
|
||||
|
||||
// Build XOR-MAPPED-ADDRESS attribute value for src.
|
||||
ip4 := src.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
|
||||
// Build STUN Binding Success Response.
|
||||
stunResp := make([]byte, 20+12) // header + 4-byte attr header + 8-byte XMA
|
||||
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(stunResp[2:4], 12) // attr length
|
||||
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||
copy(stunResp[8:20], txID[:])
|
||||
// Attribute header: type, length.
|
||||
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||
// Value: 0, family=01, x-port, x-addr.
|
||||
stunResp[24] = 0
|
||||
stunResp[25] = 0x01
|
||||
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||
|
||||
// Wrap in SOCKS5 UDP header.
|
||||
out := make([]byte, 0, 10+len(stunResp))
|
||||
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||
out = append(out, ip4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
||||
out = append(out, portBuf[:]...)
|
||||
out = append(out, stunResp...)
|
||||
|
||||
_, _ = uconn.WriteToUDP(out, src)
|
||||
}
|
||||
}
|
||||
|
||||
// peekReader wraps net.Conn so we can read variable-length SOCKS5 frames.
|
||||
type peekReader struct {
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func newPeekReader(r io.Reader) *peekReader { return &peekReader{r: r} }
|
||||
|
||||
func (p *peekReader) ReadFull(n int) ([]byte, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(p.r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
type greetingMsg struct {
|
||||
methods []byte
|
||||
}
|
||||
|
||||
func readGreeting(r *peekReader) (*greetingMsg, error) {
|
||||
hdr, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return nil, fmt.Errorf("bad ver")
|
||||
}
|
||||
nMethods := int(hdr[1])
|
||||
methods, err := r.ReadFull(nMethods)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &greetingMsg{methods: methods}, nil
|
||||
}
|
||||
|
||||
func readAuth(r *peekReader) error {
|
||||
hdr, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hdr[0] != 0x01 {
|
||||
return fmt.Errorf("bad auth ver")
|
||||
}
|
||||
ulen := int(hdr[1])
|
||||
if _, err := r.ReadFull(ulen); err != nil {
|
||||
return err
|
||||
}
|
||||
plenBuf, err := r.ReadFull(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plen := int(plenBuf[0])
|
||||
if _, err := r.ReadFull(plen); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type socks5Request struct {
|
||||
cmd byte
|
||||
atyp byte
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
|
||||
func readSocks5Request(r *peekReader) (*socks5Request, error) {
|
||||
hdr, err := r.ReadFull(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return nil, fmt.Errorf("bad ver")
|
||||
}
|
||||
out := &socks5Request{cmd: hdr[1], atyp: hdr[3]}
|
||||
switch hdr[3] {
|
||||
case 0x01:
|
||||
ipBuf, err := r.ReadFull(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = net.IP(ipBuf).String()
|
||||
case 0x03:
|
||||
lenBuf, err := r.ReadFull(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostBuf, err := r.ReadFull(int(lenBuf[0]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = string(hostBuf)
|
||||
case 0x04:
|
||||
ipBuf, err := r.ReadFull(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.host = net.IP(ipBuf).String()
|
||||
default:
|
||||
return nil, fmt.Errorf("bad atyp")
|
||||
}
|
||||
portBuf, err := r.ReadFull(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.port = binary.BigEndian.Uint16(portBuf)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func methodChosen(cur, _ byte) bool { return cur != 0xFF }
|
||||
|
||||
// drainResults pulls every Result off ch into a slice (with a hard timeout
|
||||
// so a hung implementation doesn't hang the test).
|
||||
func drainResults(t *testing.T, ch <-chan Result, timeout time.Duration) []Result {
|
||||
t.Helper()
|
||||
var out []Result
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case r, ok := <-ch:
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
out = append(out, r)
|
||||
case <-deadline.C:
|
||||
t.Fatalf("checker.Run did not finish within %s; got %d results so far: %+v", timeout, len(out), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finalByID returns the LAST result emitted for the given test id, or zero.
|
||||
func finalByID(results []Result, id string) (Result, bool) {
|
||||
for i := len(results) - 1; i >= 0; i-- {
|
||||
if results[i].ID == id && results[i].Status != StatusRunning {
|
||||
return results[i], true
|
||||
}
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// hostPort splits an addr returned by net.Listener.Addr().String().
|
||||
func hostPort(addr string) (string, int) {
|
||||
host, p, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pn, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return host, pn
|
||||
}
|
||||
|
||||
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
||||
// short timeouts for tests.
|
||||
func proxyConfig(fp *fakeProxy, useAuth bool) Config {
|
||||
host, port := hostPort(fp.addr)
|
||||
cfg := Config{
|
||||
ProxyHost: host,
|
||||
ProxyPort: port,
|
||||
UseAuth: useAuth,
|
||||
PerTestTimeout: 500 * time.Millisecond,
|
||||
MaxRetries: 1,
|
||||
RetryBackoff: 30 * time.Millisecond,
|
||||
VoiceBurstCount: 10,
|
||||
VoiceBurstInterval: 5 * time.Millisecond,
|
||||
}
|
||||
if useAuth {
|
||||
cfg.ProxyLogin = "u"
|
||||
cfg.ProxyPassword = "p"
|
||||
}
|
||||
if fp.udpRelayAddr != nil {
|
||||
// no-op; relay is announced via UDP ASSOCIATE reply
|
||||
_ = fp.udpRelayAddr
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// stubAPIServer starts an httptest server returning HTTP 200 with a tiny
|
||||
// JSON body, plus arranges fakeProxy to splice CONNECTs targeting it.
|
||||
func stubAPIServer(t *testing.T, fp *fakeProxy, status int) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, `{"url":"wss://gateway.discord.gg"}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Parse the test server's host:port.
|
||||
host, port := hostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||
fp.apiTargetHost = host
|
||||
fp.apiTargetPort = uint16(port)
|
||||
fp.apiTargetAddr = srv.Listener.Addr().String()
|
||||
return srv.URL + "/api/v9/gateway"
|
||||
}
|
||||
|
||||
// stubGatewayServer stands in for gateway.discord.gg:443 so the connect
|
||||
// test has a real target. We don't actually speak TLS — the client's
|
||||
// CONNECT only reads the 10-byte SOCKS5 reply, so as long as we send
|
||||
// REP=00 the test passes. proxyConfig points DiscordGateway at this addr.
|
||||
//
|
||||
// We piggy-back on a TCP listener that does nothing.
|
||||
func stubGatewayAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Just keep open; the splice will read/write nothing
|
||||
// useful (the SOCKS5 reply is fake REP=00 from the
|
||||
// proxy itself, not from us — see fakeProxy.handle).
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func TestRun_HappyNoAuth(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:1" // unused: we patch via direct relay; see below
|
||||
|
||||
// We don't actually need DNS — runStun does net.LookupIP("ip4", host).
|
||||
// Use a literal IP so the resolver returns it.
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"}
|
||||
finals := map[string]Result{}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing final result for %q in %+v", id, results)
|
||||
finals[id] = r
|
||||
}
|
||||
for _, id := range expected {
|
||||
assert.Equal(t, StatusPassed, finals[id].Status, "test %s should pass; got %+v", id, finals[id])
|
||||
}
|
||||
|
||||
// auth must not appear (UseAuth=false).
|
||||
for _, r := range results {
|
||||
assert.NotEqual(t, "auth", r.ID, "auth must not be emitted when UseAuth=false")
|
||||
}
|
||||
|
||||
// Metrics format spot-checks.
|
||||
assert.Contains(t, finals["greet"].Metric, "no auth")
|
||||
assert.Equal(t, "REP=00", finals["connect"].Metric)
|
||||
assert.Contains(t, finals["voice-quality"].Metric, "loss=")
|
||||
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
||||
}
|
||||
|
||||
func TestRun_HappyWithAuth(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_with_auth")
|
||||
cfg := proxyConfig(fp, true)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api"}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s; results=%+v", id, results)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
||||
}
|
||||
r, _ := finalByID(results, "auth")
|
||||
assert.Equal(t, "ok", r.Metric)
|
||||
}
|
||||
|
||||
func TestRun_AuthRejected(t *testing.T) {
|
||||
fp := newFakeProxy(t, "auth_rejected")
|
||||
cfg := proxyConfig(fp, true)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
// tcp + greet pass, auth fails.
|
||||
rTCP, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rTCP.Status)
|
||||
rG, _ := finalByID(results, "greet")
|
||||
assert.Equal(t, StatusPassed, rG.Status)
|
||||
|
||||
rA, ok := finalByID(results, "auth")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rA.Status)
|
||||
assert.NotEmpty(t, rA.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "voice-quality", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_AllMethodsRejected(t *testing.T) {
|
||||
fp := newFakeProxy(t, "all_methods_rejected")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rTCP, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rTCP.Status)
|
||||
|
||||
rG, ok := finalByID(results, "greet")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rG.Status)
|
||||
assert.NotEmpty(t, rG.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "voice-quality", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ConnectRefused(t *testing.T) {
|
||||
fp := newFakeProxy(t, "connect_refused")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rT, _ := finalByID(results, "tcp")
|
||||
assert.Equal(t, StatusPassed, rT.Status)
|
||||
rG, _ := finalByID(results, "greet")
|
||||
assert.Equal(t, StatusPassed, rG.Status)
|
||||
|
||||
rC, ok := finalByID(results, "connect")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rC.Status)
|
||||
assert.NotEmpty(t, rC.Hint)
|
||||
assert.NotEmpty(t, rC.RawHex)
|
||||
|
||||
// udp goes through a SECOND conn → unaffected; should pass.
|
||||
rU, _ := finalByID(results, "udp")
|
||||
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
|
||||
|
||||
// voice-quality depends on udp → passes too.
|
||||
rVQ, _ := finalByID(results, "voice-quality")
|
||||
assert.Equal(t, StatusPassed, rVQ.Status)
|
||||
|
||||
// api depends on connect → skipped.
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusSkipped, rA.Status)
|
||||
}
|
||||
|
||||
func TestRun_UDPUnsupported(t *testing.T) {
|
||||
fp := newFakeProxy(t, "udp_unsupported")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
for _, id := range []string{"tcp", "greet", "connect"} {
|
||||
r, _ := finalByID(results, id)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
||||
}
|
||||
|
||||
rU, _ := finalByID(results, "udp")
|
||||
require.Equal(t, StatusFailed, rU.Status)
|
||||
assert.NotEmpty(t, rU.Hint)
|
||||
|
||||
// voice-quality depends on udp → skipped.
|
||||
rVQ, _ := finalByID(results, "voice-quality")
|
||||
assert.Equal(t, StatusSkipped, rVQ.Status)
|
||||
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusPassed, rA.Status)
|
||||
}
|
||||
|
||||
func TestRun_TimeoutThenOK(t *testing.T) {
|
||||
fp := newFakeProxy(t, "timeout_then_ok")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 401)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
cfg.PerTestTimeout = 200 * time.Millisecond
|
||||
cfg.RetryBackoff = 20 * time.Millisecond
|
||||
cfg.MaxRetries = 1
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
// Find the greet results.
|
||||
var greetEvents []Result
|
||||
for _, r := range results {
|
||||
if r.ID == "greet" {
|
||||
greetEvents = append(greetEvents, r)
|
||||
}
|
||||
}
|
||||
// Expect: running(1), failed(1), running(2), passed(2). 4 events.
|
||||
require.Len(t, greetEvents, 4, "events=%+v all=%+v", greetEvents, results)
|
||||
assert.Equal(t, StatusRunning, greetEvents[0].Status)
|
||||
assert.Equal(t, 1, greetEvents[0].Attempt)
|
||||
assert.Equal(t, StatusFailed, greetEvents[1].Status)
|
||||
assert.Equal(t, 1, greetEvents[1].Attempt)
|
||||
assert.Equal(t, StatusRunning, greetEvents[2].Status)
|
||||
assert.Equal(t, 2, greetEvents[2].Attempt)
|
||||
assert.Equal(t, StatusPassed, greetEvents[3].Status)
|
||||
assert.Equal(t, 2, greetEvents[3].Attempt)
|
||||
|
||||
// All non-auth tests should ultimately pass.
|
||||
for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r)
|
||||
}
|
||||
|
||||
// API should report 401.
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, "HTTP 401", rA.Metric)
|
||||
}
|
||||
|
||||
func TestRun_CancelledMidFlight(t *testing.T) {
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch := Run(ctx, cfg)
|
||||
|
||||
var (
|
||||
results []Result
|
||||
mu sync.Mutex
|
||||
)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for r := range ch {
|
||||
mu.Lock()
|
||||
results = append(results, r)
|
||||
mu.Unlock()
|
||||
// Cancel as soon as we see tcp pass.
|
||||
if r.ID == "tcp" && r.Status == StatusPassed {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out waiting for cancelled run to finish")
|
||||
}
|
||||
|
||||
// At least one Failed/Skipped after tcp Pass.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var failed, skipped int
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case StatusFailed:
|
||||
if r.Error == "cancelled" {
|
||||
failed++
|
||||
}
|
||||
case StatusSkipped:
|
||||
if r.Error == "cancelled" {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Either: one cancelled-failed + rest cancelled-skipped, OR all
|
||||
// cancelled-skipped (if cancellation hit before next test even
|
||||
// started). Both are acceptable.
|
||||
// Without auth, 5 tests remain after tcp (greet/connect/udp/
|
||||
// voice-quality/api). Cancel may race with greet
|
||||
// completing successfully, so accept ≥3.
|
||||
assert.GreaterOrEqual(t, failed+skipped, 3, "expected at least 3 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
|
||||
}
|
||||
|
||||
func TestRun_AppliesDefaults(t *testing.T) {
|
||||
// Use a Config{} with only ProxyHost/Port populated; everything
|
||||
// else should fall back to spec defaults.
|
||||
fp := newFakeProxy(t, "happy_no_auth")
|
||||
host, port := hostPort(fp.addr)
|
||||
cfg := Config{
|
||||
ProxyHost: host,
|
||||
ProxyPort: port,
|
||||
}
|
||||
|
||||
// Verify applyDefaults produces expected values.
|
||||
out := applyDefaults(cfg)
|
||||
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
||||
assert.Equal(t, 1, out.MaxRetries)
|
||||
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
||||
assert.Equal(t, "gateway.discord.gg:443", out.DiscordGateway)
|
||||
assert.Equal(t, "https://discord.com/api/v9/gateway", out.DiscordAPI)
|
||||
assert.Equal(t, "stun.l.google.com:19302", out.StunServer)
|
||||
|
||||
// Behavioral: passing a zero Config to Run should not panic and
|
||||
// should at minimum emit a TCP result. We override defaults to
|
||||
// shorter values so the test isn't slow when the public Discord
|
||||
// targets are unreachable.
|
||||
cfg.PerTestTimeout = 200 * time.Millisecond
|
||||
cfg.RetryBackoff = 20 * time.Millisecond
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
rT, ok := finalByID(results, "tcp")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusPassed, rT.Status)
|
||||
}
|
||||
|
||||
func TestRun_NegativeRetryClamped(t *testing.T) {
|
||||
cfg := Config{MaxRetries: -5, RetryBackoff: -1 * time.Second, PerTestTimeout: -1 * time.Second}
|
||||
out := applyDefaults(cfg)
|
||||
// Spec: MaxRetries < 0 → 0. But our default for "not set" is 1.
|
||||
// We treat <0 as 0, then bump 0→1 (default for zero).
|
||||
// Either 0 or 1 is acceptable per spec wording; we settled on 1.
|
||||
assert.True(t, out.MaxRetries == 0 || out.MaxRetries == 1)
|
||||
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
||||
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
||||
}
|
||||
|
||||
// TestRun_VoiceQualityWarn drives the relay to drop ~1 in 10 packets,
|
||||
// which puts the burst into the warn band (loss in (5, 15]%, jitter and
|
||||
// p50 typically tiny on localhost). Asserts StatusWarn and that the
|
||||
// metric reports a non-zero loss.
|
||||
func TestRun_VoiceQualityWarn(t *testing.T) {
|
||||
fp := newFakeProxy(t, "voice_quality_warn")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
// Burst of 30 with 1-in-10 drop → ~3 lost ≈ 10%.
|
||||
cfg.VoiceBurstCount = 30
|
||||
cfg.VoiceBurstInterval = 5 * time.Millisecond
|
||||
cfg.PerTestTimeout = 1 * time.Second
|
||||
fp.udpDropEveryN.Store(10)
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
rVQ, ok := finalByID(results, "voice-quality")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusWarn, rVQ.Status, "got %+v", rVQ)
|
||||
assert.Contains(t, rVQ.Metric, "loss=")
|
||||
assert.NotEmpty(t, rVQ.Hint)
|
||||
}
|
||||
|
||||
// TestRun_VoiceQualityFail drives the relay to drop 4 of every 5 packets
|
||||
// (~80% loss) — well past the fail threshold.
|
||||
func TestRun_VoiceQualityFail(t *testing.T) {
|
||||
fp := newFakeProxy(t, "voice_quality_fail")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
cfg.VoiceBurstCount = 30
|
||||
cfg.VoiceBurstInterval = 3 * time.Millisecond
|
||||
cfg.PerTestTimeout = 1 * time.Second
|
||||
cfg.MaxRetries = 0
|
||||
// Drop everything: dropEveryN=1 means EVERY packet dropped → 100%.
|
||||
// Use 2 for ~50%, 1 for 100. We want fail-band — pick 1 to guarantee
|
||||
// "no replies received".
|
||||
fp.udpDropEveryN.Store(1)
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
rVQ, ok := finalByID(results, "voice-quality")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rVQ.Status, "got %+v", rVQ)
|
||||
assert.NotEmpty(t, rVQ.Hint)
|
||||
}
|
||||
|
||||
func TestExtractRawHex(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"socks5: bad version (raw=05ff)", "05ff"},
|
||||
{"socks5: bad version (raw=DEADBEEF)", "DEADBEEF"},
|
||||
{"no raw here", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.want, extractRawHex(c.in), "input=%q", c.in)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// socks5ReplyHints maps SOCKS5 REP codes to short Russian explanations
|
||||
// used by hintFor for the "connect" and "udp" steps. Codes outside this
|
||||
// table fall back to a generic "unknown REP" message.
|
||||
var socks5ReplyHints = map[byte]string{
|
||||
0x01: "общий сбой SOCKS5-сервера",
|
||||
0x02: "правила прокси запрещают это соединение",
|
||||
0x03: "сеть назначения недоступна",
|
||||
0x04: "хост назначения недоступен",
|
||||
0x05: "connection refused",
|
||||
0x06: "истёк TTL",
|
||||
0x07: "команда не поддерживается",
|
||||
0x08: "тип адреса не поддерживается",
|
||||
}
|
||||
|
||||
// tcpFriendlyName turns a testID into a Russian-friendly label for the
|
||||
// generic fallback hint.
|
||||
func tcpFriendlyName(testID string) string {
|
||||
switch testID {
|
||||
case "tcp":
|
||||
return "TCP"
|
||||
case "greet":
|
||||
return "приветствие SOCKS5"
|
||||
case "auth":
|
||||
return "авторизация SOCKS5"
|
||||
case "connect":
|
||||
return "TCP-туннель к Discord"
|
||||
case "udp":
|
||||
return "UDP ASSOCIATE"
|
||||
case "voice-quality":
|
||||
return "качество UDP-канала"
|
||||
case "api":
|
||||
return "Discord API"
|
||||
default:
|
||||
return testID
|
||||
}
|
||||
}
|
||||
|
||||
// hintFor returns a short Russian-language explanation of why a test
|
||||
// failed. Returns "" when err is nil.
|
||||
func hintFor(testID string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if isContextErr(err) {
|
||||
return "Проверка отменена."
|
||||
}
|
||||
|
||||
// Common error shapes we recognise across all testIDs.
|
||||
var ne net.Error
|
||||
isTimeout := errors.As(err, &ne) && ne.Timeout()
|
||||
|
||||
var rep ErrSocks5Reply
|
||||
hasReply := errors.As(err, &rep)
|
||||
|
||||
switch testID {
|
||||
case "tcp":
|
||||
switch {
|
||||
case isTimeout:
|
||||
return "Превышен таймаут подключения — прокси может быть выключен или брандмауэр режет порт."
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return "Прокси отклонил TCP-соединение — порт закрыт или сервис не запущен."
|
||||
}
|
||||
return fmt.Sprintf("Прокси не отвечает по TCP — проверь host и port (%s).", err.Error())
|
||||
|
||||
case "greet":
|
||||
switch {
|
||||
case errors.Is(err, ErrSocks5BadVersion):
|
||||
return "Сервер вернул не SOCKS5 — возможно, это HTTP-прокси."
|
||||
case errors.Is(err, ErrSocks5RejectedAllAuth):
|
||||
return "Прокси требует авторизацию, но мы её не предложили (или прокси не принимает наши методы)."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на приветствие."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на приветствие вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "auth":
|
||||
switch {
|
||||
case errors.Is(err, ErrAuthRejected):
|
||||
return "Логин или пароль неверны."
|
||||
case errors.Is(err, ErrCredentialTooLong):
|
||||
return "Логин или пароль длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на авторизацию."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на авторизацию вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "connect":
|
||||
if hasReply {
|
||||
return socks5ReplyHint("connect", rep.Code)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrHostTooLong):
|
||||
return "Имя хоста длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на CONNECT."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на CONNECT вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "udp":
|
||||
if hasReply {
|
||||
return socks5ReplyHint("udp", rep.Code)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrUnsupportedRelayATYP):
|
||||
return "Прокси выдал IPv6 relay для UDP — пока не поддерживается, голос работать не будет."
|
||||
case errors.Is(err, ErrShortReply):
|
||||
return "SOCKS5-сервер прислал укороченный ответ на UDP ASSOCIATE."
|
||||
case isTimeout:
|
||||
return "SOCKS5-сервер не ответил на UDP ASSOCIATE вовремя."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "voice-quality":
|
||||
switch {
|
||||
case errors.Is(err, ErrSTUNNoMappedAddress):
|
||||
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
||||
case errors.Is(err, ErrSTUNTooShort),
|
||||
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||
errors.Is(err, ErrSTUNNotSuccess),
|
||||
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return "STUN-релей возвращает мусор — голос работать не будет."
|
||||
case isTimeout:
|
||||
return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
|
||||
}
|
||||
return genericFallback(testID, err)
|
||||
|
||||
case "api":
|
||||
switch {
|
||||
case isTimeout:
|
||||
return "Discord API не ответил вовремя через прокси — таймаут."
|
||||
}
|
||||
return fmt.Sprintf("Discord API недоступен через прокси — TLS handshake упал (%s).", err.Error())
|
||||
}
|
||||
|
||||
return genericFallback(testID, err)
|
||||
}
|
||||
|
||||
// socks5ReplyHint formats a SOCKS5 REP-code hint specialised by step.
|
||||
// "connect" wording references Discord; "udp" wording references voice.
|
||||
func socks5ReplyHint(step string, code byte) string {
|
||||
desc, ok := socks5ReplyHints[code]
|
||||
if !ok {
|
||||
desc = "неизвестная REP"
|
||||
}
|
||||
switch step {
|
||||
case "udp":
|
||||
// 0x07 (cmd not supported) is the headline UDP failure mode.
|
||||
if code == 0x07 {
|
||||
return "Прокси не поддерживает UDP ASSOCIATE — голос Discord работать не будет."
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил UDP ASSOCIATE (REP=%02X, %s).", code, desc)
|
||||
case "connect":
|
||||
if code == 0x05 {
|
||||
return "Прокси не смог подключиться к Discord (REP=05, connection refused)."
|
||||
}
|
||||
if code == 0x07 {
|
||||
return "Прокси не поддерживает CONNECT (REP=07)."
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил CONNECT к Discord (REP=%02X, %s).", code, desc)
|
||||
}
|
||||
return fmt.Sprintf("Прокси отклонил запрос (REP=%02X, %s).", code, desc)
|
||||
}
|
||||
|
||||
// genericFallback is the catch-all used when we don't recognise the
|
||||
// (testID, err) shape. Keeps the user informed without exposing raw Go
|
||||
// error wrapping.
|
||||
func genericFallback(testID string, err error) string {
|
||||
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
||||
}
|
||||
|
||||
// voiceQualityWarnHint composes a warn-tier hint based on which threshold
|
||||
// was violated. Thresholds match runVoiceQuality's warn band: loss>5,
|
||||
// jitter>30, p50>250. Always returns non-empty.
|
||||
func voiceQualityWarnHint(loss, jitter, p50 float64) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос будет с заиканиями", loss))
|
||||
}
|
||||
if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("большой джиттер %.1fms — звук будет дёргаться", jitter))
|
||||
}
|
||||
if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("высокая задержка %.0fms — заметная рассинхронизация при разговоре", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// Shouldn't happen — caller only invokes us in the warn band.
|
||||
return "UDP-канал на грани приемлемого — возможны помехи в голосе."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
// voiceQualityFailHint composes a fail-tier hint. p95 is informational —
|
||||
// included only when notably worse than p50.
|
||||
func voiceQualityFailHint(loss, jitter, p50, p95 float64) string {
|
||||
_ = p95
|
||||
parts := make([]string, 0, 3)
|
||||
if loss > 15.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос работать не будет", loss))
|
||||
} else if loss > 5.0 {
|
||||
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%%", loss))
|
||||
}
|
||||
if jitter > 60.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms — звук развалится", jitter))
|
||||
} else if jitter > 30.0 {
|
||||
parts = append(parts, fmt.Sprintf("джиттер %.1fms", jitter))
|
||||
}
|
||||
if p50 > 400.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms — голос идёт со значительной паузой", p50))
|
||||
} else if p50 > 250.0 {
|
||||
parts = append(parts, fmt.Sprintf("задержка %.0fms", p50))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "UDP-канал не пригоден для голоса."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHintFor(t *testing.T) {
|
||||
t.Run("nil_err_returns_empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", hintFor("tcp", nil))
|
||||
assert.Equal(t, "", hintFor("anything", nil))
|
||||
})
|
||||
|
||||
t.Run("context_canceled_uniform", func(t *testing.T) {
|
||||
// Cancellation is always reported as «Проверка отменена.» across
|
||||
// all testIDs.
|
||||
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api", "unknown"} {
|
||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
|
||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
|
||||
}
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
testID string
|
||||
err error
|
||||
substring string
|
||||
}{
|
||||
{"tcp_timeout", "tcp", &timeoutOnlyError{}, "таймаут"},
|
||||
{"greet_bad_version_mentions_socks5", "greet", ErrSocks5BadVersion, "SOCKS5"},
|
||||
{"greet_bad_version_mentions_negation", "greet", ErrSocks5BadVersion, "не"},
|
||||
{"greet_rejected_all_auth_mentions_auth_or_methods", "greet", ErrSocks5RejectedAllAuth, "авторизаци"},
|
||||
{"auth_login", "auth", ErrAuthRejected, "Логин"},
|
||||
{"auth_password", "auth", ErrAuthRejected, "паро"},
|
||||
{"connect_refused_rep05", "connect", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||
{"connect_refused_text", "connect", ErrSocks5Reply{Code: 0x05}, "connection refused"},
|
||||
{"connect_unsupported_rep07", "connect", ErrSocks5Reply{Code: 0x07}, "REP=07"},
|
||||
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
|
||||
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
|
||||
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
|
||||
{"voice_quality_no_mapped_xor", "voice-quality", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
|
||||
{"voice_quality_timeout_mentions_stun", "voice-quality", &timeoutOnlyError{}, "STUN"},
|
||||
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
|
||||
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
|
||||
{"tcp_fallback_friendly_name", "tcp", errors.New("weird"), "TCP"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := hintFor(c.testID, c.err)
|
||||
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintFor_AllSocks5ReplyCodesCovered(t *testing.T) {
|
||||
// Every documented REP code (0x01..0x08) should produce a non-empty
|
||||
// hint when surfaced via "connect" or "udp".
|
||||
for code := byte(0x01); code <= 0x08; code++ {
|
||||
err := ErrSocks5Reply{Code: code}
|
||||
assert.NotEmpty(t, hintFor("connect", err), "connect code=%02X", code)
|
||||
assert.NotEmpty(t, hintFor("udp", err), "udp code=%02X", code)
|
||||
}
|
||||
// Unknown REP code (0xFE) still gets a sensible fallback rather than
|
||||
// an empty string.
|
||||
err := ErrSocks5Reply{Code: 0xFE}
|
||||
assert.NotEmpty(t, hintFor("connect", err))
|
||||
assert.NotEmpty(t, hintFor("udp", err))
|
||||
}
|
||||
|
||||
func TestHintFor_PerStepBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
testID string
|
||||
err error
|
||||
substring string
|
||||
}{
|
||||
// tcp: ECONNREFUSED + generic fallback
|
||||
{"tcp_econnrefused", "tcp", syscall.ECONNREFUSED, "отклонил"},
|
||||
{"tcp_generic", "tcp", errors.New("dial fail"), "TCP"},
|
||||
|
||||
// greet: short reply, timeout, fallback
|
||||
{"greet_short_reply", "greet", ErrShortReply, "укороченный"},
|
||||
{"greet_timeout", "greet", &timeoutOnlyError{}, "вовремя"},
|
||||
{"greet_fallback", "greet", errors.New("weird"), "приветствие"},
|
||||
|
||||
// auth: credential too long, short reply, timeout, fallback
|
||||
{"auth_cred_too_long", "auth", ErrCredentialTooLong, "255"},
|
||||
{"auth_short_reply", "auth", ErrShortReply, "укороченный"},
|
||||
{"auth_timeout", "auth", &timeoutOnlyError{}, "вовремя"},
|
||||
{"auth_fallback", "auth", errors.New("weird"), "авторизация"},
|
||||
|
||||
// connect: host too long, short reply, timeout, generic REP, fallback
|
||||
{"connect_host_too_long", "connect", ErrHostTooLong, "255"},
|
||||
{"connect_short_reply", "connect", ErrShortReply, "укороченный"},
|
||||
{"connect_timeout", "connect", &timeoutOnlyError{}, "вовремя"},
|
||||
{"connect_generic_rep", "connect", ErrSocks5Reply{Code: 0x03}, "REP=03"},
|
||||
{"connect_unknown_rep", "connect", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||
{"connect_fallback", "connect", errors.New("weird"), "TCP-туннель"},
|
||||
|
||||
// udp: short reply, timeout, fallback, non-7 REP
|
||||
{"udp_short_reply", "udp", ErrShortReply, "укороченный"},
|
||||
{"udp_timeout", "udp", &timeoutOnlyError{}, "вовремя"},
|
||||
{"udp_other_rep", "udp", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
|
||||
|
||||
// voice-quality: every sentinel branch (collapsed in 2026-05-01
|
||||
// rewrite into a single user-visible message rather than
|
||||
// per-error "магник cookie" / "семейство адресов" exposition)
|
||||
{"voice_quality_too_short", "voice-quality", ErrSTUNTooShort, "мусор"},
|
||||
{"voice_quality_bad_magic", "voice-quality", ErrSTUNBadMagicCookie, "мусор"},
|
||||
{"voice_quality_not_success", "voice-quality", ErrSTUNNotSuccess, "мусор"},
|
||||
{"voice_quality_txid_mismatch", "voice-quality", ErrSTUNTxIDMismatch, "мусор"},
|
||||
{"voice_quality_unsupported_family", "voice-quality", ErrSTUNUnsupportedFamily, "мусор"},
|
||||
{"voice_quality_fallback", "voice-quality", errors.New("weird"), "качество"},
|
||||
|
||||
// api: timeout vs generic
|
||||
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||
{"api_generic", "api", errors.New("tls boom"), "TLS"},
|
||||
|
||||
// socks5ReplyHint via uncategorised step (default branch)
|
||||
// — we can't reach it via hintFor with current testIDs, but the
|
||||
// default formatter still needs to be exercised.
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := hintFor(c.testID, c.err)
|
||||
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocks5ReplyHint_DefaultStep(t *testing.T) {
|
||||
// socks5ReplyHint("", code) hits the final fallback formatter.
|
||||
got := socks5ReplyHint("", 0x03)
|
||||
assert.Contains(t, got, "REP=03")
|
||||
got = socks5ReplyHint("", 0xFE)
|
||||
assert.Contains(t, got, "REP=FE")
|
||||
}
|
||||
|
||||
func TestTcpFriendlyName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"tcp": "TCP",
|
||||
"greet": "приветствие SOCKS5",
|
||||
"auth": "авторизация SOCKS5",
|
||||
"connect": "TCP-туннель к Discord",
|
||||
"udp": "UDP ASSOCIATE",
|
||||
"voice-quality": "качество UDP-канала",
|
||||
"api": "Discord API",
|
||||
"weirdo": "weirdo",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
assert.Equal(t, want, tcpFriendlyName(in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Classification is the result of classifyError. Transient errors are
|
||||
// candidates for one auto-retry (governed by Config.MaxRetries in
|
||||
// checker.go). Permanent errors are reported to the user as-is.
|
||||
type Classification int
|
||||
|
||||
const (
|
||||
// ClassificationPermanent — caller should NOT retry. Either the user
|
||||
// config is wrong (bad credentials, refused), the proxy is broken in
|
||||
// a way retry won't fix (bad SOCKS5 version, malformed STUN reply),
|
||||
// or the caller's context is already cancelled.
|
||||
ClassificationPermanent Classification = iota
|
||||
// ClassificationTransient — caller MAY retry once. Network blip,
|
||||
// timeout, RST mid-handshake, DNS temporary failure.
|
||||
ClassificationTransient
|
||||
)
|
||||
|
||||
// classifyError decides whether err is worth retrying.
|
||||
//
|
||||
// Transient (retry):
|
||||
// - net.Error.Timeout() == true
|
||||
// - errors.Is(err, syscall.ECONNRESET)
|
||||
// - net.DNSError.IsTemporary || .IsTimeout
|
||||
// - io.ErrUnexpectedEOF wrapped inside a *net.OpError on a Read (proxy
|
||||
// hung up mid-reply mid-flight; bare io.ErrUnexpectedEOF without an
|
||||
// OpError wrapper means we got a malformed reply and should not retry)
|
||||
//
|
||||
// Permanent (don't retry):
|
||||
// - context.Canceled / context.DeadlineExceeded
|
||||
// - errors.Is(err, syscall.ECONNREFUSED)
|
||||
// - any of our SOCKS5/STUN sentinels
|
||||
// - everything else we don't explicitly classify
|
||||
//
|
||||
// Returns ClassificationPermanent on nil err (defensive).
|
||||
func classifyError(err error) Classification {
|
||||
if err == nil {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Context cancellation always wins — don't retry into a cancelled
|
||||
// context, even if the chain also contains a timeout error.
|
||||
if isContextErr(err) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: explicit refused.
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: our SOCKS5 sentinels (auth refused, bad version,
|
||||
// malformed credentials, etc.). Retrying won't fix any of these.
|
||||
switch {
|
||||
case errors.Is(err, ErrSocks5BadVersion),
|
||||
errors.Is(err, ErrSocks5RejectedAllAuth),
|
||||
errors.Is(err, ErrAuthRejected),
|
||||
errors.Is(err, ErrCredentialTooLong),
|
||||
errors.Is(err, ErrHostTooLong),
|
||||
errors.Is(err, ErrUnsupportedRelayATYP),
|
||||
errors.Is(err, ErrShortReply):
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: any non-zero SOCKS5 REP code. Includes 0x05 refused,
|
||||
// 0x07 cmd unsupported, 0x02 not allowed by ruleset — none of which
|
||||
// retry will fix.
|
||||
var rep ErrSocks5Reply
|
||||
if errors.As(err, &rep) {
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Permanent: STUN sentinels (malformed responses, missing attrs).
|
||||
switch {
|
||||
case errors.Is(err, ErrSTUNTooShort),
|
||||
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||
errors.Is(err, ErrSTUNNotSuccess),
|
||||
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||
errors.Is(err, ErrSTUNNoMappedAddress),
|
||||
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Transient: ECONNRESET (peer hung up mid-stream).
|
||||
if errors.Is(err, syscall.ECONNRESET) {
|
||||
return ClassificationTransient
|
||||
}
|
||||
|
||||
// Transient: DNS temporary failure or DNS timeout.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsTemporary || dnsErr.IsTimeout {
|
||||
return ClassificationTransient
|
||||
}
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// Transient: io.ErrUnexpectedEOF wrapped inside a net.OpError. Bare
|
||||
// io.ErrUnexpectedEOF (synthesised by our SOCKS5 readers) is a
|
||||
// malformed-reply signal and stays permanent.
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if errors.Is(opErr.Err, io.ErrUnexpectedEOF) {
|
||||
return ClassificationTransient
|
||||
}
|
||||
}
|
||||
|
||||
// Transient: net.Error.Timeout(). Checked AFTER the typed sentinels
|
||||
// so that a timeout-shaped error wrapping a permanent sentinel still
|
||||
// classifies permanent.
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return ClassificationTransient
|
||||
}
|
||||
|
||||
return ClassificationPermanent
|
||||
}
|
||||
|
||||
// isContextErr returns true when err's chain contains context.Canceled
|
||||
// or context.DeadlineExceeded. Used by checker.go to label cancelled
|
||||
// tests as Error="cancelled".
|
||||
func isContextErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// timeoutOnlyError is a minimal net.Error that reports Timeout()=true.
|
||||
// Used to drive the net.Error.Timeout() branch in classifyError.
|
||||
type timeoutOnlyError struct{}
|
||||
|
||||
func (timeoutOnlyError) Error() string { return "i/o timeout" }
|
||||
func (timeoutOnlyError) Timeout() bool { return true }
|
||||
func (timeoutOnlyError) Temporary() bool { return true }
|
||||
|
||||
func TestClassifyError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want Classification
|
||||
}{
|
||||
{"nil", nil, ClassificationPermanent},
|
||||
{"context_canceled", context.Canceled, ClassificationPermanent},
|
||||
{"context_deadline", context.DeadlineExceeded, ClassificationPermanent},
|
||||
{"econnrefused", syscall.ECONNREFUSED, ClassificationPermanent},
|
||||
{"econnreset", syscall.ECONNRESET, ClassificationTransient},
|
||||
{"econnreset_wrapped", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, ClassificationTransient},
|
||||
{"net_timeout", &timeoutOnlyError{}, ClassificationTransient},
|
||||
{"dns_temporary", &net.DNSError{IsTemporary: true}, ClassificationTransient},
|
||||
{"dns_timeout", &net.DNSError{IsTimeout: true}, ClassificationTransient},
|
||||
{"dns_permanent", &net.DNSError{IsNotFound: true}, ClassificationPermanent},
|
||||
{"socks5_auth_rejected", ErrAuthRejected, ClassificationPermanent},
|
||||
{"socks5_bad_version", ErrSocks5BadVersion, ClassificationPermanent},
|
||||
{"socks5_rejected_all_auth", ErrSocks5RejectedAllAuth, ClassificationPermanent},
|
||||
{"socks5_credential_too_long", ErrCredentialTooLong, ClassificationPermanent},
|
||||
{"socks5_host_too_long", ErrHostTooLong, ClassificationPermanent},
|
||||
{"socks5_unsupported_relay_atyp", ErrUnsupportedRelayATYP, ClassificationPermanent},
|
||||
{"socks5_short_reply", ErrShortReply, ClassificationPermanent},
|
||||
{"socks5_reply_general_failure", ErrSocks5Reply{Code: 0x01}, ClassificationPermanent},
|
||||
{"socks5_reply_not_allowed", ErrSocks5Reply{Code: 0x02}, ClassificationPermanent},
|
||||
{"socks5_reply_refused", ErrSocks5Reply{Code: 0x05}, ClassificationPermanent},
|
||||
{"socks5_reply_unsupported", ErrSocks5Reply{Code: 0x07}, ClassificationPermanent},
|
||||
{"stun_too_short", ErrSTUNTooShort, ClassificationPermanent},
|
||||
{"stun_bad_magic", ErrSTUNBadMagicCookie, ClassificationPermanent},
|
||||
{"stun_not_success", ErrSTUNNotSuccess, ClassificationPermanent},
|
||||
{"stun_txid_mismatch", ErrSTUNTxIDMismatch, ClassificationPermanent},
|
||||
{"stun_no_mapped", ErrSTUNNoMappedAddress, ClassificationPermanent},
|
||||
{"stun_unsupported_family", ErrSTUNUnsupportedFamily, ClassificationPermanent},
|
||||
{"unexpected_eof", io.ErrUnexpectedEOF, ClassificationPermanent},
|
||||
{"unexpected_eof_in_op", &net.OpError{Op: "read", Err: io.ErrUnexpectedEOF}, ClassificationTransient},
|
||||
{"joined_canceled_with_timeout", errors.Join(context.Canceled, &timeoutOnlyError{}), ClassificationPermanent},
|
||||
{"joined_canceled_with_econnreset", errors.Join(context.Canceled, syscall.ECONNRESET), ClassificationPermanent},
|
||||
{"random", errors.New("ouch"), ClassificationPermanent},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := classifyError(c.err)
|
||||
assert.Equal(t, c.want, got, "err=%v", c.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContextErr(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
assert.False(t, isContextErr(nil))
|
||||
})
|
||||
t.Run("canceled", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(context.Canceled))
|
||||
})
|
||||
t.Run("deadline", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(context.DeadlineExceeded))
|
||||
})
|
||||
t.Run("joined_canceled_with_econnreset", func(t *testing.T) {
|
||||
assert.True(t, isContextErr(errors.Join(context.Canceled, syscall.ECONNRESET)))
|
||||
})
|
||||
t.Run("random", func(t *testing.T) {
|
||||
assert.False(t, isContextErr(errors.New("nope")))
|
||||
})
|
||||
t.Run("oprrror_wrapping_deadline", func(t *testing.T) {
|
||||
err := &net.OpError{Op: "read", Err: context.DeadlineExceeded}
|
||||
assert.True(t, isContextErr(err))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the SOCKS5 primitives.
|
||||
var (
|
||||
ErrSocks5BadVersion = errors.New("socks5: server returned wrong version")
|
||||
ErrSocks5RejectedAllAuth = errors.New("socks5: server rejected all offered auth methods (0xFF)")
|
||||
ErrAuthRejected = errors.New("socks5: user/pass authentication rejected")
|
||||
ErrCredentialTooLong = errors.New("socks5: login or password longer than 255 bytes")
|
||||
ErrHostTooLong = errors.New("socks5: target hostname longer than 255 bytes")
|
||||
ErrUnsupportedRelayATYP = errors.New("socks5: udp associate replied with non-IPv4 ATYP")
|
||||
ErrShortReply = errors.New("socks5: short server reply")
|
||||
)
|
||||
|
||||
// ErrSocks5Reply wraps a non-zero REP code so callers can react to specific
|
||||
// SOCKS5 reply codes (e.g. REP=0x07 = command not supported, REP=0x05 =
|
||||
// connection refused).
|
||||
type ErrSocks5Reply struct{ Code byte }
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e ErrSocks5Reply) Error() string {
|
||||
return fmt.Sprintf("socks5: server replied with non-zero REP code 0x%02X", e.Code)
|
||||
}
|
||||
|
||||
// Is reports whether target matches this reply error by Code.
|
||||
func (e ErrSocks5Reply) Is(target error) bool {
|
||||
t, ok := target.(ErrSocks5Reply)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t.Code == e.Code
|
||||
}
|
||||
|
||||
// applyDeadline applies the deadline from ctx (if any) to conn. Returns a
|
||||
// function to clear the deadline.
|
||||
func applyDeadline(ctx context.Context, conn net.Conn) {
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// joinCtxErr wraps err with ctx.Err() if ctx has been cancelled or expired,
|
||||
// so that callers see context.Canceled / context.DeadlineExceeded in the
|
||||
// error chain even when the underlying I/O reported a deadline-based error.
|
||||
func joinCtxErr(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if cerr := ctx.Err(); cerr != nil {
|
||||
return errors.Join(err, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// socks5Greeting performs the RFC 1928 client greeting on conn.
|
||||
// useAuth=true sends "05 02 00 02" (offer no-auth and user/pass);
|
||||
// useAuth=false sends "05 01 00" (offer no-auth only).
|
||||
func socks5Greeting(ctx context.Context, conn net.Conn, useAuth bool) (method byte, rawReply []byte, err error) {
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
var greet []byte
|
||||
if useAuth {
|
||||
greet = []byte{0x05, 0x02, 0x00, 0x02}
|
||||
} else {
|
||||
greet = []byte{0x05, 0x01, 0x00}
|
||||
}
|
||||
|
||||
if _, werr := conn.Write(greet); werr != nil {
|
||||
return 0, nil, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 2)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return 0, reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] == 0xFF {
|
||||
return reply[1], reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5RejectedAllAuth, reply)
|
||||
}
|
||||
return reply[1], reply, nil
|
||||
}
|
||||
|
||||
// socks5Auth performs RFC 1929 user/pass sub-negotiation on conn,
|
||||
// after greeting selected method 0x02.
|
||||
func socks5Auth(ctx context.Context, conn net.Conn, login, password string) (rawReply []byte, err error) {
|
||||
if len(login) > 255 || len(password) > 255 {
|
||||
return nil, ErrCredentialTooLong
|
||||
}
|
||||
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
buf := make([]byte, 0, 3+len(login)+len(password))
|
||||
buf = append(buf, 0x01) // VER
|
||||
buf = append(buf, byte(len(login))) // ULEN
|
||||
buf = append(buf, []byte(login)...) // UNAME
|
||||
buf = append(buf, byte(len(password)))
|
||||
buf = append(buf, []byte(password)...)
|
||||
|
||||
if _, werr := conn.Write(buf); werr != nil {
|
||||
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 auth: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 2)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x01 {
|
||||
return reply, fmt.Errorf("socks5 auth: auth subneg version mismatch: got 0x%02X want 0x01 (raw=%x)", reply[0], reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return reply, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrAuthRejected, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// socks5Connect performs SOCKS5 CONNECT (CMD=01) to host:port using
|
||||
// ATYP=03 (domain name).
|
||||
func socks5Connect(ctx context.Context, conn net.Conn, host string, port uint16) (rawReply []byte, err error) {
|
||||
if len(host) > 255 {
|
||||
return nil, ErrHostTooLong
|
||||
}
|
||||
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
// VER=05 CMD=01 RSV=00 ATYP=03 LEN host port
|
||||
req := make([]byte, 0, 7+len(host))
|
||||
req = append(req, 0x05, 0x01, 0x00, 0x03)
|
||||
req = append(req, byte(len(host)))
|
||||
req = append(req, []byte(host)...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], port)
|
||||
req = append(req, portBuf[:]...)
|
||||
|
||||
if _, werr := conn.Write(req); werr != nil {
|
||||
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 connect: write: %w", werr))
|
||||
}
|
||||
|
||||
// We always read 10 bytes (assuming ATYP=01 IPv4 reply, the most
|
||||
// common case from real proxies). Parsing variable-length BND is
|
||||
// out of scope for the diagnostic.
|
||||
reply := make([]byte, 10)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// socks5UDPAssociate performs SOCKS5 UDP ASSOCIATE (CMD=03) on conn.
|
||||
func socks5UDPAssociate(ctx context.Context, conn net.Conn) (relay *net.UDPAddr, rawReply []byte, err error) {
|
||||
applyDeadline(ctx, conn)
|
||||
|
||||
// VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0
|
||||
req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
if _, werr := conn.Write(req); werr != nil {
|
||||
return nil, nil, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: write: %w", werr))
|
||||
}
|
||||
|
||||
reply := make([]byte, 10)
|
||||
n, rerr := io.ReadFull(conn, reply)
|
||||
if rerr != nil {
|
||||
partial := reply[:n]
|
||||
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
|
||||
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrShortReply, partial))
|
||||
}
|
||||
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: read: %w (raw=%x)", rerr, partial))
|
||||
}
|
||||
|
||||
if reply[0] != 0x05 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5BadVersion, reply)
|
||||
}
|
||||
if reply[1] != 0x00 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
|
||||
}
|
||||
if reply[3] != 0x01 {
|
||||
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (atyp=0x%02X raw=%x)", ErrUnsupportedRelayATYP, reply[3], reply)
|
||||
}
|
||||
|
||||
ip := net.IPv4(reply[4], reply[5], reply[6], reply[7])
|
||||
port := binary.BigEndian.Uint16(reply[8:10])
|
||||
relay = &net.UDPAddr{IP: ip, Port: int(port)}
|
||||
return relay, reply, nil
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newFakeSocks5Server starts a TCP listener on 127.0.0.1:0. On the first
|
||||
// accepted connection it reads up to 1024 bytes (enough for any of our
|
||||
// primitives' fixed-length frames in a single Write), then writes
|
||||
// scriptedReply, then closes the connection. The listener is closed by
|
||||
// t.Cleanup.
|
||||
func newFakeSocks5Server(t *testing.T, scriptedReply []byte) (addr string) {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "listen")
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = conn.Read(buf)
|
||||
if len(scriptedReply) > 0 {
|
||||
_, _ = conn.Write(scriptedReply)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// dial connects to addr and registers t.Cleanup to close the conn.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 1*time.Second)
|
||||
require.NoError(t, err, "dial")
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func ctxShort(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSocks5Greeting(t *testing.T) {
|
||||
t.Run("happy_no_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_userpass_selected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x02})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x02), method)
|
||||
assert.Equal(t, []byte{0x05, 0x02}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_no_auth_when_offered_both", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected_all_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0xFF})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5RejectedAllAuth), "expected ErrSocks5RejectedAllAuth in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x05, 0xFF}, raw)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x04, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x04, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply in chain, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("garbage_http_response", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte("HTTP/1.1 200 OK\r\n"))
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
// First two bytes "HT" = 0x48 0x54
|
||||
assert.Equal(t, []byte{'H', 'T'}, raw)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Auth(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte{0x01, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrAuthRejected), "expected ErrAuthRejected, got: %v", err)
|
||||
assert.Equal(t, []byte{0x01, 0x01}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_subneg_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x02, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth subneg version", "want subneg version mention, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("login_too_long", func(t *testing.T) {
|
||||
// 300 chars, no I/O should occur
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("a", 300)
|
||||
_, err := socks5Auth(context.Background(), conn, long, "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrCredentialTooLong), "expected ErrCredentialTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long credential")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Connect(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
// 05 00 00 01 00000000 0000
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_connection_refused", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}), "expected ErrSocks5Reply{Code:5}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
// And it should NOT match other codes:
|
||||
assert.False(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}))
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
reply := []byte{0x04, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("host_too_long", func(t *testing.T) {
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("h", 300)
|
||||
_, err := socks5Connect(context.Background(), conn, long, 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrHostTooLong), "expected ErrHostTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long host")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5UDPAssociate(t *testing.T) {
|
||||
t.Run("happy_ipv4", func(t *testing.T) {
|
||||
// 05 00 00 01 7F000001 0539 -> 127.0.0.1:1337
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x7F, 0x00, 0x00, 0x01, 0x05, 0x39}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, relay)
|
||||
assert.True(t, relay.IP.Equal(net.IPv4(127, 0, 0, 1)), "ip=%s", relay.IP)
|
||||
assert.Equal(t, 1337, relay.Port)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("atyp_ipv6_unsupported", func(t *testing.T) {
|
||||
// REP=0x00 (success), ATYP=0x04 (IPv6) — unsupported by us. We
|
||||
// only read 10 bytes total so the trailing IPv6 bytes are
|
||||
// implicitly ignored on the wire.
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrUnsupportedRelayATYP), "expected ErrUnsupportedRelayATYP, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSocks5GreetingCtxCancel verifies that a cancelled ctx surfaces
|
||||
// context.Canceled in the error chain even if the underlying I/O fails
|
||||
// with a deadline-style error.
|
||||
func TestSocks5GreetingCtxCancel(t *testing.T) {
|
||||
// Server that accepts but never replies — read will hang until ctx
|
||||
// deadline triggers SetDeadline-induced timeout.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
defer close(accepted)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Hold the connection open without writing anything.
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
<-accepted // intentionally blocks; actually we close immediately on test end
|
||||
}()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", ln.Addr().String(), 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, _, err = socks5Greeting(ctx, conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t,
|
||||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
|
||||
"expected ctx error in chain, got: %v", err)
|
||||
}
|
||||
|
||||
// noopConn is a minimal net.Conn that records whether any I/O was
|
||||
// attempted. Used to assert that pre-I/O validation rejects oversized
|
||||
// inputs without ever touching the wire.
|
||||
type noopConn struct {
|
||||
touched bool
|
||||
}
|
||||
|
||||
func (c *noopConn) Read(b []byte) (int, error) { c.touched = true; return 0, io.EOF }
|
||||
func (c *noopConn) Write(b []byte) (int, error) { c.touched = true; return len(b), nil }
|
||||
func (c *noopConn) Close() error { return nil }
|
||||
func (c *noopConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package checker — STUN binding-request codec.
|
||||
//
|
||||
// Hand-rolled RFC 5389 binding request encoder + binding success response
|
||||
// parser. Just enough to extract XOR-MAPPED-ADDRESS — no message integrity,
|
||||
// no fingerprint, no ALTERNATE-SERVER, no STUN-USE-CANDIDATE. Used after
|
||||
// socks5UDPAssociate succeeds to verify the relay actually forwards UDP
|
||||
// to the public Internet.
|
||||
//
|
||||
// We deliberately avoid pulling in pion/stun: ~80 LOC of encoding/binary,
|
||||
// one attribute type. Adding ~50 KB of compiled code + a transitive
|
||||
// dependency for one round trip is overkill.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Magic cookie defined by RFC 5389 §6.
|
||||
const stunMagicCookie = 0x2112A442
|
||||
|
||||
// STUN message types and attribute types we care about.
|
||||
const (
|
||||
stunBindingRequest = 0x0001
|
||||
stunBindingSuccessResponse = 0x0101
|
||||
stunAttrXORMappedAddress = 0x0020
|
||||
stunAddressFamilyIPv4 = 0x01
|
||||
stunAddressFamilyIPv6 = 0x02
|
||||
)
|
||||
|
||||
// Sentinel errors so HintFor (and tests) can match specific failure modes.
|
||||
var (
|
||||
ErrSTUNTooShort = errors.New("stun: response shorter than 20-byte header")
|
||||
ErrSTUNBadMagicCookie = errors.New("stun: magic cookie mismatch")
|
||||
ErrSTUNNotSuccess = errors.New("stun: response is not a Binding Success Response")
|
||||
ErrSTUNTxIDMismatch = errors.New("stun: transaction ID mismatch")
|
||||
ErrSTUNNoMappedAddress = errors.New("stun: response has no XOR-MAPPED-ADDRESS attribute")
|
||||
ErrSTUNUnsupportedFamily = errors.New("stun: unsupported XOR-MAPPED-ADDRESS family")
|
||||
)
|
||||
|
||||
// NewTransactionID returns 12 cryptographically-random bytes suitable for
|
||||
// use as a STUN transaction ID. Errors only if rand.Reader fails — caller
|
||||
// should propagate (the runtime is in trouble at that point anyway).
|
||||
func NewTransactionID() ([12]byte, error) {
|
||||
var id [12]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return id, fmt.Errorf("stun: read random transaction id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// EncodeBindingRequest builds a 20-byte STUN Binding Request with the given
|
||||
// transaction ID. RFC 5389 allows empty request bodies.
|
||||
func EncodeBindingRequest(txID [12]byte) []byte {
|
||||
buf := make([]byte, 20)
|
||||
binary.BigEndian.PutUint16(buf[0:2], stunBindingRequest)
|
||||
binary.BigEndian.PutUint16(buf[2:4], 0) // attribute length
|
||||
binary.BigEndian.PutUint32(buf[4:8], stunMagicCookie)
|
||||
copy(buf[8:20], txID[:])
|
||||
return buf
|
||||
}
|
||||
|
||||
// ParseBindingResponse decodes a STUN Binding Success Response and returns
|
||||
// the public IP+port advertised in XOR-MAPPED-ADDRESS.
|
||||
//
|
||||
// Validates header (length, magic, message type, transaction ID), then
|
||||
// walks the TLV attribute section and extracts the first XOR-MAPPED-ADDRESS
|
||||
// attribute. Other attributes are skipped (per RFC 5389 §15 the
|
||||
// "comprehension-optional" range is everything ≥ 0x8000; we skip every
|
||||
// non-XOR-MAPPED-ADDRESS attribute regardless, since this is the only one
|
||||
// we care about).
|
||||
func ParseBindingResponse(buf []byte, expectedTxID [12]byte) (net.IP, uint16, error) {
|
||||
if len(buf) < 20 {
|
||||
return nil, 0, ErrSTUNTooShort
|
||||
}
|
||||
|
||||
msgType := binary.BigEndian.Uint16(buf[0:2])
|
||||
attrLen := binary.BigEndian.Uint16(buf[2:4])
|
||||
cookie := binary.BigEndian.Uint32(buf[4:8])
|
||||
if cookie != stunMagicCookie {
|
||||
return nil, 0, ErrSTUNBadMagicCookie
|
||||
}
|
||||
if msgType != stunBindingSuccessResponse {
|
||||
return nil, 0, fmt.Errorf("%w: type=0x%04x", ErrSTUNNotSuccess, msgType)
|
||||
}
|
||||
|
||||
var txID [12]byte
|
||||
copy(txID[:], buf[8:20])
|
||||
if txID != expectedTxID {
|
||||
return nil, 0, ErrSTUNTxIDMismatch
|
||||
}
|
||||
|
||||
// Sanity check: attrLen must not exceed buffer.
|
||||
if int(attrLen) > len(buf)-20 {
|
||||
return nil, 0, fmt.Errorf("stun: attribute section length %d exceeds buffer (%d bytes after header)", attrLen, len(buf)-20)
|
||||
}
|
||||
|
||||
attrs := buf[20 : 20+int(attrLen)]
|
||||
off := 0
|
||||
for off < len(attrs) {
|
||||
// Each attribute header is 4 bytes (type + length).
|
||||
if len(attrs)-off < 4 {
|
||||
return nil, 0, fmt.Errorf("stun: truncated attribute header at offset %d", off)
|
||||
}
|
||||
aType := binary.BigEndian.Uint16(attrs[off : off+2])
|
||||
aLen := binary.BigEndian.Uint16(attrs[off+2 : off+4])
|
||||
valStart := off + 4
|
||||
valEnd := valStart + int(aLen)
|
||||
if valEnd > len(attrs) {
|
||||
return nil, 0, fmt.Errorf("stun: attribute at offset %d claims length %d but only %d bytes remain", off, aLen, len(attrs)-valStart)
|
||||
}
|
||||
|
||||
if aType == stunAttrXORMappedAddress {
|
||||
ip, port, err := parseXORMappedAddress(attrs[valStart:valEnd], txID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
// Skip attribute including padding to next 4-byte boundary.
|
||||
paddedLen := (int(aLen) + 3) &^ 3
|
||||
next := valStart + paddedLen
|
||||
if next > len(attrs) {
|
||||
// Padding runs past end — treat as truncation.
|
||||
return nil, 0, fmt.Errorf("stun: attribute padding at offset %d runs past end", off)
|
||||
}
|
||||
off = next
|
||||
}
|
||||
|
||||
return nil, 0, ErrSTUNNoMappedAddress
|
||||
}
|
||||
|
||||
// parseXORMappedAddress decodes the value of an XOR-MAPPED-ADDRESS attribute.
|
||||
//
|
||||
// Layout (RFC 5389 §15.2):
|
||||
//
|
||||
// 0 1 2 3
|
||||
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
// |0 0 0 0 0 0 0 0| Family | X-Port |
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
// | X-Address (Variable)
|
||||
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
func parseXORMappedAddress(val []byte, txID [12]byte) (net.IP, uint16, error) {
|
||||
if len(val) < 4 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS truncated (got %d bytes, need ≥4)", len(val))
|
||||
}
|
||||
family := val[1]
|
||||
xPort := binary.BigEndian.Uint16(val[2:4])
|
||||
port := xPort ^ uint16(stunMagicCookie>>16)
|
||||
|
||||
switch family {
|
||||
case stunAddressFamilyIPv4:
|
||||
if len(val) < 8 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv4 truncated (got %d bytes, need 8)", len(val))
|
||||
}
|
||||
xAddr := binary.BigEndian.Uint32(val[4:8])
|
||||
addr := xAddr ^ stunMagicCookie
|
||||
ip := make(net.IP, 4)
|
||||
binary.BigEndian.PutUint32(ip, addr)
|
||||
return ip, port, nil
|
||||
|
||||
case stunAddressFamilyIPv6:
|
||||
if len(val) < 20 {
|
||||
return nil, 0, fmt.Errorf("stun: XOR-MAPPED-ADDRESS IPv6 truncated (got %d bytes, need 20)", len(val))
|
||||
}
|
||||
// XOR with magic_cookie || transaction_id (16 bytes total).
|
||||
var key [16]byte
|
||||
binary.BigEndian.PutUint32(key[0:4], stunMagicCookie)
|
||||
copy(key[4:16], txID[:])
|
||||
ip := make(net.IP, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
ip[i] = val[4+i] ^ key[i]
|
||||
}
|
||||
return ip, port, nil
|
||||
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("%w: family=0x%02x", ErrSTUNUnsupportedFamily, family)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mkXorMappedV4 builds a synthetic STUN binding success response carrying an
|
||||
// XOR-MAPPED-ADDRESS attribute for an IPv4 endpoint. Used by several test
|
||||
// cases to keep byte-construction DRY.
|
||||
func mkXorMappedV4(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte {
|
||||
t.Helper()
|
||||
ip4 := ip.To4()
|
||||
require.NotNil(t, ip4, "ip must be IPv4")
|
||||
|
||||
// Attribute value: 1B reserved + 1B family + 2B xPort + 4B xAddr = 8 bytes.
|
||||
attrVal := make([]byte, 8)
|
||||
attrVal[0] = 0
|
||||
attrVal[1] = stunAddressFamilyIPv4
|
||||
binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
xAddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
binary.BigEndian.PutUint32(attrVal[4:8], xAddr)
|
||||
|
||||
// Attribute header (4B) + value (8B) = 12 bytes total, no padding needed.
|
||||
attr := make([]byte, 4+len(attrVal))
|
||||
binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal)))
|
||||
copy(attr[4:], attrVal)
|
||||
|
||||
// 20B header + attrs.
|
||||
resp := make([]byte, 20+len(attr))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attr)
|
||||
return resp
|
||||
}
|
||||
|
||||
// mkXorMappedV6 builds a synthetic response with an IPv6 XOR-MAPPED-ADDRESS.
|
||||
func mkXorMappedV6(t *testing.T, ip net.IP, port uint16, txID [12]byte) []byte {
|
||||
t.Helper()
|
||||
ip6 := ip.To16()
|
||||
require.NotNil(t, ip6, "ip must be IPv6")
|
||||
require.Equal(t, net.IPv6len, len(ip6))
|
||||
|
||||
attrVal := make([]byte, 4+16)
|
||||
attrVal[0] = 0
|
||||
attrVal[1] = stunAddressFamilyIPv6
|
||||
binary.BigEndian.PutUint16(attrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
|
||||
var key [16]byte
|
||||
binary.BigEndian.PutUint32(key[0:4], stunMagicCookie)
|
||||
copy(key[4:16], txID[:])
|
||||
for i := 0; i < 16; i++ {
|
||||
attrVal[4+i] = ip6[i] ^ key[i]
|
||||
}
|
||||
|
||||
attr := make([]byte, 4+len(attrVal))
|
||||
binary.BigEndian.PutUint16(attr[0:2], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(attr[2:4], uint16(len(attrVal)))
|
||||
copy(attr[4:], attrVal)
|
||||
|
||||
resp := make([]byte, 20+len(attr))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attr)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attr)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestNewTransactionID(t *testing.T) {
|
||||
a, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
b, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, a, b, "two consecutive transaction IDs should differ (cryptographic randomness)")
|
||||
assert.Len(t, a[:], 12)
|
||||
}
|
||||
|
||||
func TestEncodeBindingRequest(t *testing.T) {
|
||||
txID := [12]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
got := EncodeBindingRequest(txID)
|
||||
|
||||
require.Len(t, got, 20)
|
||||
assert.Equal(t, byte(0x00), got[0])
|
||||
assert.Equal(t, byte(0x01), got[1], "type LSB = 0x01 (binding request)")
|
||||
assert.Equal(t, byte(0x00), got[2])
|
||||
assert.Equal(t, byte(0x00), got[3], "attribute length = 0 (empty body)")
|
||||
assert.Equal(t, []byte{0x21, 0x12, 0xA4, 0x42}, got[4:8], "magic cookie")
|
||||
assert.Equal(t, txID[:], got[8:20], "transaction id")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_HappyV4(t *testing.T) {
|
||||
txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
ip := net.IPv4(198, 51, 100, 1).To4()
|
||||
const port uint16 = 42
|
||||
resp := mkXorMappedV4(t, ip, port, txID)
|
||||
|
||||
// Sanity-check the bytes match the worked example in the task description.
|
||||
// xPort = 42 ^ 0x2112 = 0x2138
|
||||
assert.Equal(t, byte(0x21), resp[20+4+2])
|
||||
assert.Equal(t, byte(0x38), resp[20+4+3])
|
||||
// xIP = 0xC6336401 ^ 0x2112A442 = 0xE721C043
|
||||
assert.Equal(t, []byte{0xE7, 0x21, 0xC0, 0x43}, resp[20+4+4:20+4+8])
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip)
|
||||
assert.Equal(t, port, gotPort)
|
||||
assert.Len(t, gotIP, 4, "IPv4 result should be 4-byte slice")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_HappyV6(t *testing.T) {
|
||||
txID := [12]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC}
|
||||
ip := net.ParseIP("2001:db8::1")
|
||||
require.NotNil(t, ip)
|
||||
const port uint16 = 0x1234
|
||||
resp := mkXorMappedV6(t, ip, port, txID)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip), "got %s want %s", gotIP, ip)
|
||||
assert.Equal(t, port, gotPort)
|
||||
assert.Len(t, gotIP, 16, "IPv6 result should be 16-byte slice")
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_MultipleUnknownAttributesThenMapped(t *testing.T) {
|
||||
txID := [12]byte{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
|
||||
ip := net.IPv4(8, 8, 8, 8).To4()
|
||||
const port uint16 = 53
|
||||
|
||||
// Build header with three attributes:
|
||||
// 1. unknown type 0x8022 (SOFTWARE), value="abc" -> 3 bytes value + 1 byte pad
|
||||
// 2. unknown type 0x8023, value="hi" -> 2 bytes + 2 bytes pad
|
||||
// 3. real XOR-MAPPED-ADDRESS
|
||||
var attrs []byte
|
||||
|
||||
addAttr := func(t uint16, val []byte) {
|
||||
hdr := make([]byte, 4)
|
||||
binary.BigEndian.PutUint16(hdr[0:2], t)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], uint16(len(val)))
|
||||
attrs = append(attrs, hdr...)
|
||||
attrs = append(attrs, val...)
|
||||
// pad to 4-byte boundary
|
||||
for len(attrs)%4 != 0 {
|
||||
attrs = append(attrs, 0)
|
||||
}
|
||||
}
|
||||
addAttr(0x8022, []byte("abc"))
|
||||
addAttr(0x8023, []byte("hi"))
|
||||
|
||||
// XOR-MAPPED-ADDRESS attribute
|
||||
xmAttrVal := make([]byte, 8)
|
||||
xmAttrVal[1] = stunAddressFamilyIPv4
|
||||
binary.BigEndian.PutUint16(xmAttrVal[2:4], port^uint16(stunMagicCookie>>16))
|
||||
xAddr := binary.BigEndian.Uint32(ip) ^ stunMagicCookie
|
||||
binary.BigEndian.PutUint32(xmAttrVal[4:8], xAddr)
|
||||
addAttr(stunAttrXORMappedAddress, xmAttrVal)
|
||||
|
||||
resp := make([]byte, 20+len(attrs))
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], uint16(len(attrs)))
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
copy(resp[20:], attrs)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(resp, txID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gotIP.Equal(ip))
|
||||
assert.Equal(t, port, gotPort)
|
||||
}
|
||||
|
||||
func TestParseBindingResponse_Errors(t *testing.T) {
|
||||
txID := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
otherTxID := [12]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
|
||||
t.Run("truncated", func(t *testing.T) {
|
||||
buf := make([]byte, 10)
|
||||
_, _, err := ParseBindingResponse(buf, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNTooShort)
|
||||
})
|
||||
|
||||
t.Run("bad_magic_cookie", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
copy(resp[4:8], []byte{0xAA, 0xBB, 0xCC, 0xDD})
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNBadMagicCookie)
|
||||
})
|
||||
|
||||
t.Run("not_success_request_type", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingRequest) // 0x0001
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNotSuccess)
|
||||
})
|
||||
|
||||
t.Run("not_success_error_response_type", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
binary.BigEndian.PutUint16(resp[0:2], 0x0111) // binding error response
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNotSuccess)
|
||||
})
|
||||
|
||||
t.Run("no_xor_mapped_address", func(t *testing.T) {
|
||||
// 20-byte header + zero attributes
|
||||
resp := make([]byte, 20)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 0)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNNoMappedAddress)
|
||||
})
|
||||
|
||||
t.Run("unsupported_family", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
// Flip family byte (offset 20 + 4 + 1 = 25) to 0x03.
|
||||
resp[25] = 0x03
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
assert.ErrorIs(t, err, ErrSTUNUnsupportedFamily)
|
||||
})
|
||||
|
||||
t.Run("attribute_length_overflow", func(t *testing.T) {
|
||||
// Build a header claiming 24 bytes of attrs, but only put one bogus
|
||||
// attribute of declared length 100 inside.
|
||||
resp := make([]byte, 20+24)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 24)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
// attribute: type=0x0020, length=100 (lies — only 20 bytes of value follow)
|
||||
binary.BigEndian.PutUint16(resp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(resp[22:24], 100)
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claims length 100")
|
||||
})
|
||||
|
||||
t.Run("attribute_section_length_overflow", func(t *testing.T) {
|
||||
// Header says attrLen=200 but buffer only has 20 bytes after header.
|
||||
resp := make([]byte, 20+20)
|
||||
binary.BigEndian.PutUint16(resp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(resp[2:4], 200)
|
||||
binary.BigEndian.PutUint32(resp[4:8], stunMagicCookie)
|
||||
copy(resp[8:20], txID[:])
|
||||
_, _, err := ParseBindingResponse(resp, txID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeds buffer")
|
||||
})
|
||||
|
||||
t.Run("tx_id_mismatch", func(t *testing.T) {
|
||||
resp := mkXorMappedV4(t, net.IPv4(1, 2, 3, 4), 80, txID)
|
||||
_, _, err := ParseBindingResponse(resp, otherTxID)
|
||||
assert.ErrorIs(t, err, ErrSTUNTxIDMismatch)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRoundTripLocalhost stands up a tiny STUN server on loopback that
|
||||
// handles exactly one binding request and replies with the client's own
|
||||
// address as XOR-MAPPED-ADDRESS. Verifies the encode/parse pair end-to-end
|
||||
// against a real UDP socket.
|
||||
func TestRoundTripLocalhost(t *testing.T) {
|
||||
server, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "net.ListenPacket must succeed for round-trip test (real-network requirement)")
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
// Server goroutine: read one request, parse minimally, reply.
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
buf := make([]byte, 1500)
|
||||
_ = server.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
n, from, rerr := server.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
if n < 20 {
|
||||
return
|
||||
}
|
||||
// Verify it's a binding request with right magic.
|
||||
if binary.BigEndian.Uint16(buf[0:2]) != stunBindingRequest {
|
||||
return
|
||||
}
|
||||
if binary.BigEndian.Uint32(buf[4:8]) != stunMagicCookie {
|
||||
return
|
||||
}
|
||||
var txID [12]byte
|
||||
copy(txID[:], buf[8:20])
|
||||
|
||||
udpFrom, ok := from.(*net.UDPAddr)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reply := mkXorMappedV4(t, udpFrom.IP.To4(), uint16(udpFrom.Port), txID)
|
||||
_, _ = server.WriteTo(reply, from)
|
||||
}()
|
||||
|
||||
// Client side.
|
||||
serverAddr := server.LocalAddr().(*net.UDPAddr)
|
||||
conn, err := net.DialUDP("udp", nil, serverAddr)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
txID, err := NewTransactionID()
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
_, err = conn.Write(EncodeBindingRequest(txID))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second)))
|
||||
respBuf := make([]byte, 1500)
|
||||
n, err := conn.Read(respBuf)
|
||||
require.NoError(t, err)
|
||||
rtt := time.Since(start)
|
||||
|
||||
gotIP, gotPort, err := ParseBindingResponse(respBuf[:n], txID)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientLocal := conn.LocalAddr().(*net.UDPAddr)
|
||||
assert.True(t, gotIP.Equal(net.IPv4(127, 0, 0, 1)), "got %s want 127.0.0.1", gotIP)
|
||||
assert.Equal(t, uint16(clientLocal.Port), gotPort, "port should match client local port")
|
||||
assert.Less(t, rtt, 200*time.Millisecond, "loopback RTT should be under 200ms (got %s)", rtt)
|
||||
|
||||
// Make sure the server goroutine exits cleanly.
|
||||
select {
|
||||
case <-serverDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server goroutine did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: errors.Is chain works for wrapped sentinels.
|
||||
func TestSentinelsAreUnique(t *testing.T) {
|
||||
all := []error{
|
||||
ErrSTUNTooShort,
|
||||
ErrSTUNBadMagicCookie,
|
||||
ErrSTUNNotSuccess,
|
||||
ErrSTUNTxIDMismatch,
|
||||
ErrSTUNNoMappedAddress,
|
||||
ErrSTUNUnsupportedFamily,
|
||||
}
|
||||
for i, a := range all {
|
||||
for j, b := range all {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
assert.False(t, errors.Is(a, b), "sentinel %d should not match sentinel %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package checker
|
||||
|
||||
// voice.go — predictive voice diagnostics.
|
||||
//
|
||||
// runVoiceQualityBurst fires a burst of STUN binding requests through
|
||||
// an open SOCKS5 UDP relay, then derives packet-loss / jitter /
|
||||
// percentile-RTT from the replies. A single round-trip says the relay
|
||||
// accepts UDP; a 30-packet burst tells you whether voice will actually
|
||||
// hold together.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5
|
||||
// relay. All fields are zero on a hard failure (no replies at all).
|
||||
type VoiceQualityResult struct {
|
||||
Sent int
|
||||
Received int
|
||||
LossPct float64 // 0..100
|
||||
JitterMS float64 // mean abs of inter-arrival deltas in ms
|
||||
P50RTTMS float64 // median round-trip in ms
|
||||
P95RTTMS float64 // 95th percentile (informational, not gated)
|
||||
}
|
||||
|
||||
// runVoiceQualityBurst sends `count` STUN binding requests through the
|
||||
// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort,
|
||||
// spaced `interval` apart. It listens on udpConn until
|
||||
// `time.Now() + max(interval, 200ms)` after the last send, then returns
|
||||
// the aggregate result.
|
||||
//
|
||||
// Each outbound datagram has the SOCKS5 UDP header
|
||||
// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by
|
||||
// a 20-byte STUN binding request. We track each request by its
|
||||
// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP
|
||||
// header before being handed to ParseBindingResponse.
|
||||
//
|
||||
// Returns an error only when ctx is cancelled or stunHost can't be
|
||||
// resolved to IPv4. A 100% loss is NOT an error — the caller decides
|
||||
// what status to assign; we just report Sent=count, Received=0.
|
||||
func runVoiceQualityBurst(
|
||||
ctx context.Context,
|
||||
udpConn net.PacketConn,
|
||||
relayAddr *net.UDPAddr,
|
||||
stunHost string,
|
||||
stunPort uint16,
|
||||
count int,
|
||||
interval time.Duration,
|
||||
) (VoiceQualityResult, error) {
|
||||
if count <= 0 {
|
||||
return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0")
|
||||
}
|
||||
|
||||
// Resolve stunHost to IPv4.
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost)
|
||||
if err != nil {
|
||||
return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err)
|
||||
}
|
||||
var stunIP4 net.IP
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
stunIP4 = v4
|
||||
break
|
||||
}
|
||||
}
|
||||
if stunIP4 == nil {
|
||||
return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost)
|
||||
}
|
||||
|
||||
// Per-tx state: send-time + arrival-time.
|
||||
type entry struct {
|
||||
sentAt time.Time
|
||||
arrivedAt time.Time
|
||||
received bool
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries = make(map[[12]byte]*entry, count)
|
||||
arrivals = make([]time.Time, 0, count) // for jitter (in arrival order)
|
||||
rtts = make([]float64, 0, count) // milliseconds
|
||||
)
|
||||
|
||||
// Reader goroutine: loops on ReadFrom until deadline expires.
|
||||
doneRead := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneRead)
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, _, rerr := udpConn.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
// Deadline expired or conn closed — exit.
|
||||
return
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
// Validate SOCKS5 UDP wrapper, derive header length.
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReply := buf[hdrLen:n]
|
||||
// Pull the transaction ID out of the STUN header so we
|
||||
// can look up the matching send-time. ParseBindingResponse
|
||||
// rejects mismatched txIDs, so we feed it the *expected*
|
||||
// id from the entries map.
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReply[8:20])
|
||||
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
ent, ok := entries[txID]
|
||||
if !ok || ent.received {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
ent.arrivedAt = now
|
||||
ent.received = true
|
||||
arrivals = append(arrivals, now)
|
||||
rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body
|
||||
// is per-packet (fresh tx id each).
|
||||
hdr := make([]byte, 0, 10)
|
||||
hdr = append(hdr, 0x00, 0x00, 0x00, 0x01)
|
||||
hdr = append(hdr, stunIP4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], stunPort)
|
||||
hdr = append(hdr, portBuf[:]...)
|
||||
|
||||
// Send burst.
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sent := 0
|
||||
sendLoop:
|
||||
for sent < count {
|
||||
// Make a fresh tx id and STUN request.
|
||||
txID, terr := NewTransactionID()
|
||||
if terr != nil {
|
||||
break
|
||||
}
|
||||
stunReq := EncodeBindingRequest(txID)
|
||||
dgram := make([]byte, 0, len(hdr)+len(stunReq))
|
||||
dgram = append(dgram, hdr...)
|
||||
dgram = append(dgram, stunReq...)
|
||||
|
||||
// Record send-time *before* the write. Note: we register the
|
||||
// entry into the map BEFORE Write so the reader can never get a
|
||||
// reply for an unknown tx (would happen on a very fast localhost
|
||||
// echo).
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
entries[txID] = &entry{sentAt: now}
|
||||
mu.Unlock()
|
||||
|
||||
if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil {
|
||||
// Write failure aborts the burst — but we still wait for
|
||||
// any in-flight replies. Treat as "sent so far".
|
||||
break
|
||||
}
|
||||
sent++
|
||||
|
||||
if sent >= count {
|
||||
break sendLoop
|
||||
}
|
||||
|
||||
// Wait for next tick OR ctx cancel.
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
break sendLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait window for stragglers — at least 200ms past last send.
|
||||
wait := interval
|
||||
if wait < 200*time.Millisecond {
|
||||
wait = 200 * time.Millisecond
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
_ = udpConn.SetReadDeadline(deadline)
|
||||
|
||||
// Wait for reader to exit. ctx cancel still races: bound by deadline.
|
||||
select {
|
||||
case <-doneRead:
|
||||
case <-ctx.Done():
|
||||
// Force the reader to exit ASAP by setting a past deadline.
|
||||
_ = udpConn.SetReadDeadline(time.Unix(0, 1))
|
||||
<-doneRead
|
||||
}
|
||||
// Reset deadline so subsequent users of the conn aren't surprised.
|
||||
_ = udpConn.SetReadDeadline(time.Time{})
|
||||
|
||||
// Compute aggregates.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
received := len(rtts)
|
||||
res := VoiceQualityResult{
|
||||
Sent: sent,
|
||||
Received: received,
|
||||
}
|
||||
if sent > 0 {
|
||||
res.LossPct = float64(sent-received) / float64(sent) * 100.0
|
||||
}
|
||||
if received >= 2 {
|
||||
// Sort arrivals to compute inter-arrival jitter in chronological order.
|
||||
// arrivals is already chronological (appended as packets came in).
|
||||
var diffs []float64
|
||||
for i := 1; i < len(arrivals); i++ {
|
||||
d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0
|
||||
diffs = append(diffs, d)
|
||||
}
|
||||
// mean abs of consecutive deltas of inter-arrival diffs.
|
||||
if len(diffs) >= 2 {
|
||||
var sum float64
|
||||
for i := 1; i < len(diffs); i++ {
|
||||
sum += math.Abs(diffs[i] - diffs[i-1])
|
||||
}
|
||||
res.JitterMS = sum / float64(len(diffs)-1)
|
||||
} else if len(diffs) == 1 {
|
||||
// Only two arrivals — single delta, no second-order jitter.
|
||||
res.JitterMS = 0
|
||||
}
|
||||
}
|
||||
if received > 0 {
|
||||
// percentile.
|
||||
sorted := make([]float64, len(rtts))
|
||||
copy(sorted, rtts)
|
||||
sort.Float64s(sorted)
|
||||
p50idx := len(sorted) / 2
|
||||
if p50idx >= len(sorted) {
|
||||
p50idx = len(sorted) - 1
|
||||
}
|
||||
res.P50RTTMS = sorted[p50idx]
|
||||
p95idx := int(0.95 * float64(len(sorted)))
|
||||
if p95idx >= len(sorted) {
|
||||
p95idx = len(sorted) - 1
|
||||
}
|
||||
res.P95RTTMS = sorted[p95idx]
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeUDPRelay listens on a UDP socket and echoes SOCKS5-wrapped STUN
|
||||
// binding requests as a synthetic Binding Success Response, just like
|
||||
// fakeProxy.runRelay in checker_test.go but standalone (no SOCKS5 TCP
|
||||
// control channel needed). dropEveryN > 0 drops every Nth packet.
|
||||
type fakeUDPRelay struct {
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
dropEveryN atomic.Int32
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeUDPRelay(t *testing.T) *fakeUDPRelay {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
uconn := pc.(*net.UDPConn)
|
||||
r := &fakeUDPRelay{
|
||||
conn: uconn,
|
||||
addr: uconn.LocalAddr().(*net.UDPAddr),
|
||||
}
|
||||
t.Cleanup(func() { _ = uconn.Close() })
|
||||
go r.serve()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *fakeUDPRelay) serve() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := r.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dropN := r.dropEveryN.Load(); dropN > 0 {
|
||||
c := r.count.Add(1)
|
||||
if c%dropN == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
r.count.Add(1)
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||
continue
|
||||
}
|
||||
var hdrLen int
|
||||
switch buf[3] {
|
||||
case 0x01:
|
||||
hdrLen = 10
|
||||
case 0x04:
|
||||
hdrLen = 22
|
||||
case 0x03:
|
||||
if n < 5 {
|
||||
continue
|
||||
}
|
||||
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if n < hdrLen+20 {
|
||||
continue
|
||||
}
|
||||
stunReq := buf[hdrLen:n]
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReq[8:20])
|
||||
|
||||
ip4 := src.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
|
||||
stunResp := make([]byte, 20+12)
|
||||
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(stunResp[2:4], 12)
|
||||
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||
copy(stunResp[8:20], txID[:])
|
||||
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||
stunResp[24] = 0
|
||||
stunResp[25] = 0x01
|
||||
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||
|
||||
out := make([]byte, 0, 10+len(stunResp))
|
||||
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||
out = append(out, ip4...)
|
||||
var portBuf [2]byte
|
||||
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
||||
out = append(out, portBuf[:]...)
|
||||
out = append(out, stunResp...)
|
||||
|
||||
_, _ = r.conn.WriteToUDP(out, src)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_Math: full 30-of-30 reception on localhost, all
|
||||
// RTTs in single-digit milliseconds.
|
||||
func TestVoiceQualityBurst_Math(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 30, 5*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 30, res.Sent)
|
||||
assert.Equal(t, 30, res.Received)
|
||||
assert.InDelta(t, 0.0, res.LossPct, 0.001)
|
||||
assert.Less(t, res.P50RTTMS, 50.0, "loopback p50 should be tiny")
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_HalfLoss verifies the loss-percentage math when
|
||||
// the relay drops half the packets.
|
||||
func TestVoiceQualityBurst_HalfLoss(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(2) // every other packet → 50% loss
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 20, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 20, res.Sent)
|
||||
assert.InDelta(t, 50.0, res.LossPct, 5.0, "expected ~50%% loss got %+v", res)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_AllDropped: dropEveryN=1 → 100% loss. Should NOT
|
||||
// return an error; should report Sent=N, Received=0.
|
||||
func TestVoiceQualityBurst_AllDropped(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(1)
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 10, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 10, res.Sent)
|
||||
assert.Equal(t, 0, res.Received)
|
||||
assert.InDelta(t, 100.0, res.LossPct, 0.001)
|
||||
assert.Equal(t, 0.0, res.P50RTTMS)
|
||||
assert.Equal(t, 0.0, res.JitterMS)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_ZeroCount: count=0 → error (defensive).
|
||||
func TestVoiceQualityBurst_ZeroCount(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 0, 5*time.Millisecond)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package gui hosts the Wails app: the App struct (whose exported methods
|
||||
// become the JS API for the frontend) and the Run() helper invoked from
|
||||
// cmd/drover/main.go when the user double-clicks the binary.
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.okcu.io/root/drover-go/internal/checker"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// App is the Wails-bound struct. Every exported method is callable from JS
|
||||
// via the auto-generated wailsjs/go/main/App.* bindings.
|
||||
//
|
||||
// Right now everything except the proxy form is a deterministic stub —
|
||||
// the real WinDivert + SOCKS5 engine arrives in Phase 1. The stubs are
|
||||
// sufficient for the UI to feel alive: Check fakes a 7-step diagnostic,
|
||||
// Start/Stop toggles a phase, GetStats emits realistic-looking numbers.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
startedAt time.Time
|
||||
|
||||
// muCheck guards cancelCheck and checkDone.
|
||||
// cancelCheck is the cancel func of the in-flight checker.Run context (nil
|
||||
// when no check is running). checkDone is closed by the runner goroutine
|
||||
// once it has drained the result channel — RunCheck waits on it before
|
||||
// starting a new run, so we never have two emitter goroutines alive.
|
||||
muCheck sync.Mutex
|
||||
cancelCheck context.CancelFunc
|
||||
checkDone chan struct{}
|
||||
}
|
||||
|
||||
// NewApp returns a fresh App stamped with the binary's build version
|
||||
// (so the GUI can display it in the title bar).
|
||||
func NewApp(version string) *App { return &App{version: version} }
|
||||
|
||||
// Version returns the build version (e.g. "0.2.0", "test-local", or
|
||||
// "dev"). Frontend reads it on mount to populate the custom title bar.
|
||||
func (a *App) Version() string { return a.version }
|
||||
|
||||
// Startup is called by Wails right after the window is created and the
|
||||
// JS runtime is ready. We grab the context for runtime.EventsEmit calls
|
||||
// from any subsequent method.
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
go a.statsLoop()
|
||||
}
|
||||
|
||||
// Config is the proxy/auth payload the frontend sends back from the form.
|
||||
type Config struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Auth bool `json:"auth"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CheckResult is one row in the diagnostic table; the frontend listens
|
||||
// for them on the "check:result" event. Mirrors checker.Result but with
|
||||
// Duration converted to milliseconds (int) for the JS side.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"` // tcp / greet / auth / connect / udp / voice-quality / api
|
||||
Status string `json:"status"` // running | passed | warn | failed | skipped
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
Duration int64 `json:"duration_ms,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
}
|
||||
|
||||
// RunCheck runs a real 7-step SOCKS5 diagnostic via internal/checker. Each
|
||||
// Result from the checker channel is forwarded to the frontend as a
|
||||
// "check:result" event; when the channel closes (run finished, or context
|
||||
// cancelled) we emit "check:done" with the {total, passed, failed} summary.
|
||||
//
|
||||
// If a previous check is still in flight, its context is cancelled and we
|
||||
// wait for the previous goroutine to finish before launching the new one
|
||||
// — this guarantees event ordering (no two emitters alive simultaneously).
|
||||
func (a *App) RunCheck(cfg Config) {
|
||||
// Cancel any in-flight check and wait for its goroutine to drain.
|
||||
a.muCheck.Lock()
|
||||
prevCancel := a.cancelCheck
|
||||
prevDone := a.checkDone
|
||||
a.muCheck.Unlock()
|
||||
if prevCancel != nil {
|
||||
prevCancel()
|
||||
}
|
||||
if prevDone != nil {
|
||||
<-prevDone
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
done := make(chan struct{})
|
||||
|
||||
a.muCheck.Lock()
|
||||
a.cancelCheck = cancel
|
||||
a.checkDone = done
|
||||
a.muCheck.Unlock()
|
||||
|
||||
ckCfg := checker.Config{
|
||||
ProxyHost: cfg.Host,
|
||||
ProxyPort: cfg.Port,
|
||||
UseAuth: cfg.Auth,
|
||||
ProxyLogin: cfg.Login,
|
||||
ProxyPassword: cfg.Password,
|
||||
// Leave PerTestTimeout / MaxRetries / RetryBackoff /
|
||||
// DiscordGateway / DiscordAPI / StunServer at zero so the
|
||||
// checker package applies its own defaults.
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
var passed, failed int
|
||||
for r := range checker.Run(ctx, ckCfg) {
|
||||
// Always emit on a.ctx, never on the per-check ctx — the
|
||||
// per-check ctx may already be cancelled when the final
|
||||
// "cancelled" result arrives, which would silently drop it.
|
||||
runtime.EventsEmit(a.ctx, "check:result", CheckResult{
|
||||
ID: r.ID,
|
||||
Status: string(r.Status),
|
||||
Metric: r.Metric,
|
||||
Error: r.Error,
|
||||
Hint: r.Hint,
|
||||
RawHex: r.RawHex,
|
||||
Duration: r.Duration.Milliseconds(),
|
||||
Attempt: r.Attempt,
|
||||
})
|
||||
switch r.Status {
|
||||
case checker.StatusPassed, checker.StatusWarn:
|
||||
// Warn is a "soft pass" — counted as passed for the
|
||||
// final summary, but the row still surfaces the hint.
|
||||
passed++
|
||||
case checker.StatusFailed:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "check:done", map[string]int{
|
||||
"total": passed + failed,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
})
|
||||
|
||||
// Clear cancel/done if we're still the current run (RunCheck may
|
||||
// have already replaced them with a newer run by the time we get
|
||||
// here, in which case leave those alone).
|
||||
a.muCheck.Lock()
|
||||
if a.checkDone == done {
|
||||
a.cancelCheck = nil
|
||||
a.checkDone = nil
|
||||
}
|
||||
a.muCheck.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// CancelCheck cancels the currently-running diagnostic, if any. Safe to
|
||||
// call when no check is running (no-op).
|
||||
func (a *App) CancelCheck() {
|
||||
a.muCheck.Lock()
|
||||
defer a.muCheck.Unlock()
|
||||
if a.cancelCheck != nil {
|
||||
a.cancelCheck()
|
||||
}
|
||||
}
|
||||
|
||||
// StartEngine flips the proxy on. In the stub we just toggle the flag and
|
||||
// note the start time so GetStats can produce a believable uptime.
|
||||
func (a *App) StartEngine() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.running = true
|
||||
a.startedAt = time.Now()
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopEngine turns the proxy off.
|
||||
func (a *App) StopEngine() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.running = false
|
||||
runtime.EventsEmit(a.ctx, "engine:status", map[string]any{"running": false})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus is read by the frontend on first paint to know whether to
|
||||
// show "Idle" or "Active".
|
||||
func (a *App) GetStatus() map[string]any {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return map[string]any{
|
||||
"running": a.running,
|
||||
"uptimeS": int(time.Since(a.startedAt).Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
// statsLoop emits a stats event every second when the engine is running.
|
||||
// Numbers are random but stable enough to look real.
|
||||
func (a *App) statsLoop() {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
a.mu.Lock()
|
||||
if !a.running || a.ctx == nil {
|
||||
a.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
uptime := int(time.Since(a.startedAt).Seconds())
|
||||
a.mu.Unlock()
|
||||
|
||||
runtime.EventsEmit(a.ctx, "stats:update", map[string]any{
|
||||
"up": r.Intn(50_000) + 5_000, // bytes/sec out
|
||||
"down": r.Intn(500_000) + 50_000, // bytes/sec in
|
||||
"tcp": r.Intn(8) + 1,
|
||||
"udp": r.Intn(5) + 1,
|
||||
"uptimeS": uptime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Greet remains as a smoke check that the bindings pipeline survived
|
||||
// the transition. Frontend can call it from a debug button if needed.
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s — Drover-Go GUI is alive.", name)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Build Directory
|
||||
|
||||
The build directory is used to house all the build files and assets for your application.
|
||||
|
||||
The structure is:
|
||||
|
||||
* bin - Output directory
|
||||
* darwin - macOS specific files
|
||||
* windows - Windows specific files
|
||||
|
||||
## Mac
|
||||
|
||||
The `darwin` directory holds files specific to Mac builds.
|
||||
These may be customised and used as part of the build. To return these files to the default state, simply delete them
|
||||
and
|
||||
build with `wails build`.
|
||||
|
||||
The directory contains the following files:
|
||||
|
||||
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
|
||||
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
|
||||
|
||||
## Windows
|
||||
|
||||
The `windows` directory contains the manifest and rc files used when building with `wails build`.
|
||||
These may be customised for your application. To return these files to the default state, simply delete them and
|
||||
build with `wails build`.
|
||||
|
||||
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
|
||||
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
|
||||
will be created using the `appicon.png` file in the build directory.
|
||||
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
|
||||
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
|
||||
as well as the application itself (right click the exe -> properties -> details)
|
||||
- `wails.exe.manifest` - The main application manifest file.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -0,0 +1,249 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "{{.Name}}"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
File "..\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||
|
||||
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||
{{end}}
|
||||
!macroend
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,15 @@
|
||||
package gui
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets embeds the built React frontend (frontend/dist/) into the
|
||||
// Go binary so a single drover.exe ships with no external files.
|
||||
// Build the frontend before `go build`:
|
||||
//
|
||||
// cd internal/gui/frontend && npm install && npm run build
|
||||
//
|
||||
// Then `go build ./cmd/drover` will pick up the fresh dist/ via this
|
||||
// directive.
|
||||
//
|
||||
//go:embed all:frontend/dist
|
||||
var Assets embed.FS
|
||||
@@ -0,0 +1,54 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbed verifies that frontend/dist actually got embedded — easy to
|
||||
// silently miss this and end up with a Wails window that 404s on every
|
||||
// asset.
|
||||
func TestEmbed(t *testing.T) {
|
||||
sub, err := fs.Sub(Assets, "frontend/dist")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub: %v", err)
|
||||
}
|
||||
var files []string
|
||||
fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
files = append(files, p)
|
||||
return nil
|
||||
})
|
||||
if len(files) == 0 {
|
||||
t.Fatal("frontend/dist embed is empty — did you forget `npm run build`?")
|
||||
}
|
||||
if !sliceContains(files, "index.html") {
|
||||
t.Fatalf("no index.html in embed; got %v", files)
|
||||
}
|
||||
hasJS := false
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, ".js") {
|
||||
hasJS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasJS {
|
||||
t.Fatalf("no .js bundle in embed; got %v", files)
|
||||
}
|
||||
t.Logf("embed contains %d files (looks healthy):", len(files))
|
||||
for _, f := range files {
|
||||
t.Logf(" %s", f)
|
||||
}
|
||||
}
|
||||
|
||||
func sliceContains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>drover-gui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.jsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import ClassicWindow from './components/Classic.jsx'
|
||||
|
||||
// Wails sizes the host window itself (internal/gui/run.go). Classic renders
|
||||
// 100% of that surface; we own the mode state here so the title-bar toggle
|
||||
// in Classic can flip between dark and light without re-mounting.
|
||||
//
|
||||
// onToggleMode receives the click event so we can plant a circle-reveal
|
||||
// origin at the cursor position. The View Transitions API (Chromium 111+,
|
||||
// Edge / WebView2 included) snapshots the old DOM, swaps to the new one
|
||||
// after setMode commits, and animates between them. Fallback path just
|
||||
// flips the mode synchronously when the API is missing.
|
||||
export default function App() {
|
||||
const [mode, setMode] = React.useState('dark')
|
||||
|
||||
function onToggleMode(e) {
|
||||
const x = e?.clientX ?? window.innerWidth - 24
|
||||
const y = e?.clientY ?? 16
|
||||
document.documentElement.style.setProperty('--reveal-x', x + 'px')
|
||||
document.documentElement.style.setProperty('--reveal-y', y + 'px')
|
||||
|
||||
const flip = () => setMode(m => (m === 'dark' ? 'light' : 'dark'))
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(flip)
|
||||
} else {
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
return <ClassicWindow mode={mode} onToggleMode={onToggleMode} />
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,510 @@
|
||||
// Classic.jsx — Variant 1: Classic devtool.
|
||||
// Information-dense. Mono metrics. Plain rectangles, hairline borders.
|
||||
// Sober palette: neutral grays + one teal accent for primary action / success.
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useDrover,
|
||||
BrandMark, IconGear, IconMin, IconClose, IconChevron, IconCopy,
|
||||
IconArrowUp, IconArrowDown, IconSun, IconMoon, StatusDot,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
} from './shared.jsx'
|
||||
import { WindowMinimise, Quit } from '../../wailsjs/runtime/runtime'
|
||||
import { Version as GoVersion } from '../../wailsjs/go/gui/App'
|
||||
|
||||
const ClassicTheme = {
|
||||
// dark
|
||||
d: {
|
||||
bg: '#1c1d20',
|
||||
chrome: '#15161a',
|
||||
panel: '#22242a',
|
||||
panelAlt: '#1a1c20',
|
||||
border: '#34373d',
|
||||
borderSoft:'#2a2c32',
|
||||
text: '#dde0e6',
|
||||
dim: '#8a8f99',
|
||||
dimmer: '#5b6068',
|
||||
accent: '#3ea99f', // teal
|
||||
accentDim: '#2a7d76',
|
||||
danger: '#d96565',
|
||||
warn: '#d9a155',
|
||||
pass: '#5cba8b',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#15161a',
|
||||
btnBg: '#2c2f36',
|
||||
btnBgH: '#373b43',
|
||||
primaryBg: '#3ea99f',
|
||||
primaryFg: '#0c1a18',
|
||||
},
|
||||
l: {
|
||||
bg: '#f3f4f6',
|
||||
chrome: '#e8eaef',
|
||||
panel: '#ffffff',
|
||||
panelAlt: '#f8f9fb',
|
||||
border: '#d8dbe1',
|
||||
borderSoft:'#e6e8ec',
|
||||
text: '#1c1f24',
|
||||
dim: '#5c6168',
|
||||
dimmer: '#8a8f97',
|
||||
accent: '#2a7d76',
|
||||
accentDim: '#bdded9',
|
||||
danger: '#c0463f',
|
||||
warn: '#a8731e',
|
||||
pass: '#2f8c5a',
|
||||
skip: '#7c8088',
|
||||
inputBg: '#ffffff',
|
||||
btnBg: '#ffffff',
|
||||
btnBgH: '#f1f2f5',
|
||||
primaryBg: '#2a7d76',
|
||||
primaryFg: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export function ClassicWindow({ mode = 'dark', initial, onToggleMode }) {
|
||||
const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
|
||||
const D = useDrover(initial);
|
||||
const [version, setVersion] = React.useState('');
|
||||
React.useEffect(() => { GoVersion().then(setVersion).catch(() => {}); }, []);
|
||||
const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip, warn: t.warn };
|
||||
const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
|
||||
const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
|
||||
const isActive = D.phase === 'active';
|
||||
const allChecked = D.phase === 'checked' || D.phase === 'active';
|
||||
const failed = D.lastSummary?.failed ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100vw', height: '100vh', background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
|
||||
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
|
||||
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
|
||||
}}>
|
||||
{/* ─── title bar ─── */}
|
||||
<ClassicTitleBar t={t} version={version} mode={mode} onToggleMode={onToggleMode} />
|
||||
|
||||
{/* ─── content ─── */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
|
||||
|
||||
{/* Form */}
|
||||
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Field t={t} label="Host" style={{ flex: 1 }}>
|
||||
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
|
||||
placeholder="95.165.72.59 или example.com"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
<Field t={t} label="Port" style={{ width: 92 }}>
|
||||
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
|
||||
placeholder="12334" inputMode="numeric"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Checkbox t={t} checked={D.form.auth}
|
||||
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
|
||||
Authentication
|
||||
</Checkbox>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
|
||||
<Field t={t} label="Login" style={{ flex: 1 }}>
|
||||
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
|
||||
onChange={e => D.update({ login: e.target.value })} placeholder="user"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
<Field t={t} label="Password" style={{ flex: 1 }}>
|
||||
<input disabled={!D.form.auth} type="password" value={D.form.password}
|
||||
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
|
||||
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
|
||||
style={inputStyle(t, fontMono, !D.form.auth)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{D.phase === 'checking' ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled style={{ flex: 1 }}>
|
||||
Checking…
|
||||
</PrimaryBtn>
|
||||
<ClassicCancelBtn t={t} onClick={D.cancelCheck} />
|
||||
</div>
|
||||
) : (
|
||||
<PrimaryBtn t={t} onClick={D.runCheck} disabled={isActive}>
|
||||
Check connection
|
||||
</PrimaryBtn>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ height: 18 }} />
|
||||
<SectionLabel t={t}>Status</SectionLabel>
|
||||
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ height: 14 }} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
|
||||
<ClassicStopBtn t={t} D={D} />
|
||||
</div>
|
||||
|
||||
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
|
||||
|
||||
<div style={{ height: 14 }} />
|
||||
</div>
|
||||
|
||||
{/* Logs collapsible */}
|
||||
<ClassicLogs t={t} D={D} fontMono={fontMono} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── pieces ─────────────────────────────────────────────────────────────────
|
||||
function ClassicTitleBar({ t, version, mode, onToggleMode }) {
|
||||
// Cell height = full title-bar height so the hover background fills
|
||||
// edge-to-edge (no thin sliver of chrome above the red close highlight).
|
||||
// alignSelf:'stretch' on the wrapper keeps it pinned to the top/bottom
|
||||
// of the flex row even though parent uses alignItems:'center' for text.
|
||||
const cellStyle = {
|
||||
width: 38, height: '100%', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color: t.dim, cursor:'pointer',
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', alignItems:'stretch',
|
||||
// CSS Wails recognises for the OS title-bar drag region. The
|
||||
// close/min cells below override it with --wails-draggable: no-drag
|
||||
// so clicks land on the buttons, not the drag handler.
|
||||
['--wails-draggable']: 'drag',
|
||||
userSelect:'none',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
|
||||
<BrandMark size={14} color={t.accent}/>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
|
||||
{version && <span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v{version}</span>}
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'stretch', ['--wails-draggable']: 'no-drag' }}>
|
||||
<div style={cellStyle}
|
||||
title={mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
onClick={(e) => onToggleMode && onToggleMode(e)}>
|
||||
{mode === 'dark' ? <IconSun color={t.dim}/> : <IconMoon color={t.dim}/>}
|
||||
</div>
|
||||
<div style={cellStyle} title="Minimize" onClick={() => WindowMinimise()}>
|
||||
<IconMin color={t.dim}/>
|
||||
</div>
|
||||
<div style={cellStyle} title="Close"
|
||||
onClick={() => Quit()}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<IconClose color={t.dim}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children, t }) {
|
||||
return <div style={{
|
||||
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
color: t.dim, marginBottom: 8,
|
||||
}}>{children}</div>;
|
||||
}
|
||||
|
||||
function Field({ children, label, t, style }) {
|
||||
return (
|
||||
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
|
||||
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function inputStyle(t, fontMono, disabled) {
|
||||
return {
|
||||
background: t.inputBg, color: disabled ? t.dimmer : t.text,
|
||||
border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
|
||||
fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
transition: 'border-color .12s, box-shadow .12s',
|
||||
};
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, children, t }) {
|
||||
return (
|
||||
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
|
||||
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition: 'background .12s, border-color .12s',
|
||||
}}>
|
||||
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</span>
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryBtn({ t, onClick, disabled, children, style }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
style={{
|
||||
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
|
||||
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
|
||||
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
|
||||
transition: 'background .12s', ...style,
|
||||
}}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── status panel ──────────────────────────────────────────────────────────
|
||||
function ClassicStatus({ t, D, palette, fontMono }) {
|
||||
const idle = D.phase === 'idle';
|
||||
if (idle) {
|
||||
return (
|
||||
<div style={{
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
|
||||
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
|
||||
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
|
||||
{/* header */}
|
||||
<div style={{
|
||||
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
|
||||
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
|
||||
}}>
|
||||
{D.phase === 'checking'
|
||||
? <>
|
||||
<StatusDot state="running" palette={palette} size={12}/>
|
||||
<span>Running diagnostics…</span>
|
||||
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{Object.keys(D.results).length}/{D.tests.length}
|
||||
</span>
|
||||
</>
|
||||
: D.lastSummary?.failed === 0
|
||||
? (D.lastSummary?.warnings > 0
|
||||
? <span style={{ color: t.warn, fontWeight: 600 }}>All checks passed (with warnings).</span>
|
||||
: <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>)
|
||||
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
|
||||
</div>
|
||||
{/* tests */}
|
||||
<div>
|
||||
{D.tests.map((test, i) => {
|
||||
const r = D.results[test.id];
|
||||
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
|
||||
const isLast = i === D.tests.length - 1;
|
||||
return (
|
||||
<div key={test.id} style={{
|
||||
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
|
||||
padding: '6px 12px',
|
||||
}}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
|
||||
<StatusDot state={state} palette={palette} size={12}/>
|
||||
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
|
||||
{test.label}
|
||||
</span>
|
||||
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
|
||||
color: state === 'failed' ? t.danger : state === 'warn' ? t.warn : state === 'skipped' ? t.skip : t.dim }}>
|
||||
{r?.metric || (state === 'running' ? '...' : '')}
|
||||
</span>
|
||||
{(r?.result === 'failed' || r?.result === 'warn') && (
|
||||
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
|
||||
<IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(r?.result === 'failed' || r?.result === 'warn') && r.expanded && (
|
||||
<div className="drv-fadein" style={{
|
||||
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
|
||||
background: mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.9),
|
||||
border: `1px solid ${mode_mix(r.result === 'warn' ? t.warn : t.danger, t.panel, 0.78)}`,
|
||||
fontSize: 11.5, color: t.text,
|
||||
}}>
|
||||
{r.error
|
||||
? <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
|
||||
: (r.hint && <div style={{ color: r.result === 'warn' ? t.warn : t.danger, fontWeight: 600, marginBottom: 2 }}>{r.hint}</div>)}
|
||||
{r.error && <div style={{ color: t.dim }}>{r.hint}</div>}
|
||||
{r.rawHex && (
|
||||
<div style={{
|
||||
fontFamily: fontMono, fontSize: 10.5, color: t.dimmer,
|
||||
marginTop: 4, padding: '4px 6px',
|
||||
background: t.panelAlt, borderRadius: 2,
|
||||
overflowX: 'auto', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{r.rawHex.length > 64 ? r.rawHex.slice(0, 64) + '…' : r.rawHex}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
|
||||
<button onClick={() => navigator.clipboard?.writeText(
|
||||
`[${test.label}] ${r.error} — ${r.metric}` + (r.rawHex ? ` — raw=${r.rawHex}` : ''))}
|
||||
style={smallBtn(t, fontMono)}>
|
||||
<IconCopy color={t.dim}/> copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconBtnStyle(t) {
|
||||
return {
|
||||
width: 20, height: 20, padding: 0, border:'none', background:'transparent',
|
||||
cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
|
||||
borderRadius: 2,
|
||||
};
|
||||
}
|
||||
function smallBtn(t, fontMono) {
|
||||
return {
|
||||
display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
|
||||
background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
|
||||
borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
|
||||
function mode_mix(fg, bg, amt) {
|
||||
const a = hexToRgb(fg), b = hexToRgb(bg);
|
||||
return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
|
||||
}
|
||||
function hexToRgb(h) {
|
||||
const v = h.replace('#','');
|
||||
return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
|
||||
}
|
||||
|
||||
// ─── start/stop ────────────────────────────────────────────────────────────
|
||||
function ClassicStartBtn({ t, D, fontMono }) {
|
||||
const phase = D.phase;
|
||||
const summary = D.lastSummary;
|
||||
const allFailed = summary && summary.failed === D.tests.length;
|
||||
const checkedOk = phase === 'checked' && !allFailed;
|
||||
const active = phase === 'active';
|
||||
const warning = active && (summary?.failed || 0) > 0;
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
|
||||
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
|
||||
border: `1px solid ${warning ? t.warn : t.pass}`,
|
||||
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
|
||||
}}>
|
||||
<span className="drv-pulsedot" style={{
|
||||
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
|
||||
}}/>
|
||||
Active{warning ? ' · UDP fallback' : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
|
||||
Start proxying
|
||||
</PrimaryBtn>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicCancelBtn({ t, onClick }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 92, padding: '9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: hover ? t.danger : t.text,
|
||||
border: `1px solid ${hover ? t.danger : t.border}`, cursor: 'pointer',
|
||||
transition: 'color .12s, border-color .12s',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStopBtn({ t, D }) {
|
||||
const enabled = D.phase === 'active';
|
||||
return (
|
||||
<button onClick={D.stopProxy} disabled={!enabled}
|
||||
style={{
|
||||
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
|
||||
background: t.btnBg, color: enabled ? t.text : t.dimmer,
|
||||
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
|
||||
}}>
|
||||
Stop
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicLiveStats({ t, stats, fontMono }) {
|
||||
const cell = (icon, val) => (
|
||||
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
|
||||
{icon}<span>{val}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 8, padding: '6px 10px', borderRadius: 3,
|
||||
background: t.panel, border: `1px solid ${t.borderSoft}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
}}>
|
||||
{cell(<IconArrowUp color={t.dim}/>, fmtBytes(stats.up))}
|
||||
{cell(<IconArrowDown color={t.dim}/>, fmtBytes(stats.down))}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
|
||||
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, fmtUptime(stats.uptimeS))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── logs ──────────────────────────────────────────────────────────────────
|
||||
function ClassicLogs({ t, D, fontMono }) {
|
||||
return (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
|
||||
<button onClick={() => D.setLogsOpen(!D.logsOpen)} style={{
|
||||
width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
|
||||
background:'transparent', border:'none', color: t.dim, cursor:'pointer',
|
||||
fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
|
||||
}}>
|
||||
<IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
|
||||
<span style={{ textTransform:'uppercase' }}>Logs</span>
|
||||
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
|
||||
</button>
|
||||
{D.logsOpen && (
|
||||
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
|
||||
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
|
||||
<button style={smallBtn(t, fontMono)}
|
||||
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
|
||||
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
|
||||
<button style={smallBtn(t, fontMono)}>open log file</button>
|
||||
</div>
|
||||
<div className="drv-log" style={{
|
||||
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
|
||||
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
|
||||
background: t.panelAlt,
|
||||
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
|
||||
{D.logs.map((l, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ color: t.dimmer }}>{fmtTime(l.t)}</span>
|
||||
{' '}
|
||||
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
|
||||
{' '}
|
||||
<span>{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default export so App.jsx can `import ClassicWindow from './components/Classic'`.
|
||||
export default ClassicWindow;
|
||||
@@ -0,0 +1,384 @@
|
||||
// shared.jsx — state machine + shared icons/utilities for all Drover-Go variants.
|
||||
//
|
||||
// Original prototype loaded everything via window globals (babel script-tag
|
||||
// build). For Wails + Vite we use real ESM imports/exports — additions:
|
||||
// - `import * as React from 'react'` so `React.useState/useMemo/useEffect`
|
||||
// keep working unchanged.
|
||||
// - `export` on everything the variant components need.
|
||||
// - `useDrover` no longer simulates with `SCENARIOS`; it calls the Wails
|
||||
// bindings on `window.go.main.App` and listens for the events the Go
|
||||
// side emits (`check:result`, `check:done`, `stats:update`, ...).
|
||||
//
|
||||
// The state surface (form/phase/results/stats/logs) is unchanged, so the
|
||||
// UI components don't need to be rewritten — only their imports.
|
||||
|
||||
import * as React from 'react'
|
||||
import { RunCheck, CancelCheck, StartEngine, StopEngine, GetStatus } from '../../wailsjs/go/gui/App'
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
|
||||
|
||||
// ─── Test catalog ──────────────────────────────────────────────────────────
|
||||
export const ALL_TESTS = [
|
||||
{ id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' },
|
||||
{ id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' },
|
||||
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
|
||||
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
|
||||
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
|
||||
{ id: 'voice-quality', label: 'UDP voice quality', desc: 'Бёрст 30 STUN-пакетов через релей: потери, джиттер, латентность' },
|
||||
{ id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
|
||||
];
|
||||
|
||||
// Pre-baked scenarios so the prototype feels alive. Each entry per test:
|
||||
// { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' }
|
||||
const SCENARIOS = {
|
||||
// Default happy path (no auth)
|
||||
happy: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// With auth
|
||||
happyAuth: {
|
||||
tcp: { result: 'passed', metric: '14 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
auth: { result: 'passed', metric: 'user/pass · ok' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
|
||||
stun: { result: 'passed', metric: '38 ms RTT' },
|
||||
api: { result: 'passed', metric: '204 OK · 89 ms' },
|
||||
},
|
||||
// UDP fails — common Discord scenario
|
||||
udpFail: {
|
||||
tcp: { result: 'passed', metric: '17 ms' },
|
||||
greet: { result: 'passed', metric: 'SOCKS5/0x05' },
|
||||
connect: { result: 'passed', metric: 'gateway.discord.gg' },
|
||||
udp: { result: 'failed', metric: 'X\'07 cmd not supported',
|
||||
error: 'Прокси не поддерживает UDP ASSOCIATE.',
|
||||
hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' },
|
||||
stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' },
|
||||
api: { result: 'passed', metric: '204 OK · 92 ms' },
|
||||
},
|
||||
};
|
||||
|
||||
export function getTests(authEnabled) {
|
||||
return ALL_TESTS.filter(t => !t.authOnly || authEnabled);
|
||||
}
|
||||
|
||||
// ─── Drover state hook ─────────────────────────────────────────────────────
|
||||
// Owns: form values, diagnostic phase, per-test results, drover-active state,
|
||||
// live stats counter, log buffer.
|
||||
// phase: 'idle' | 'checking' | 'checked' | 'active'
|
||||
export function useDrover(initial = {}) {
|
||||
const [form, setForm] = React.useState({
|
||||
host: '95.165.72.59',
|
||||
port: '12334',
|
||||
auth: false,
|
||||
login: '',
|
||||
password: '',
|
||||
...initial,
|
||||
});
|
||||
const [phase, setPhase] = React.useState('idle');
|
||||
const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded}
|
||||
const [running, setRunning] = React.useState(null); // currently-running test id
|
||||
const [scenario, setScenario] = React.useState('happy'); // kept for compat with prototype, unused with real backend
|
||||
const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
const [logs, setLogs] = React.useState(() => seedLogs());
|
||||
const [logsOpen, setLogsOpen] = React.useState(false);
|
||||
const tests = getTests(form.auth);
|
||||
const lastSummary = React.useMemo(() => {
|
||||
if (phase !== 'checked' && phase !== 'active') return null;
|
||||
const ids = tests.map(t => t.id);
|
||||
const failed = ids.filter(id => results[id]?.result === 'failed').length;
|
||||
const warnings = ids.filter(id => results[id]?.result === 'warn').length;
|
||||
return { total: ids.length, failed, warnings };
|
||||
}, [phase, results, tests]);
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────
|
||||
function update(patch) { setForm(f => ({ ...f, ...patch })); }
|
||||
|
||||
function pushLog(level, msg) {
|
||||
setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]);
|
||||
}
|
||||
|
||||
// Subscribe to backend events once. The Go side emits:
|
||||
// check:result → one test result (id, status, metric, error, hint)
|
||||
// check:done → diagnostic finished, summary {total, passed, failed}
|
||||
// engine:status → {running: bool}
|
||||
// stats:update → {up, down, tcp, udp, uptimeS}
|
||||
React.useEffect(() => {
|
||||
const offResult = EventsOn('check:result', (r) => {
|
||||
if (r.status === 'running') {
|
||||
setRunning(r.id);
|
||||
return;
|
||||
}
|
||||
// Convert backend "status" field to the frontend's "result" field used
|
||||
// by the Classic/Fluent/etc components.
|
||||
setResults(prev => ({
|
||||
...prev,
|
||||
[r.id]: {
|
||||
result: r.status,
|
||||
metric: r.metric,
|
||||
error: r.error,
|
||||
hint: r.hint,
|
||||
rawHex: r.rawHex,
|
||||
attempt: r.attempt,
|
||||
expanded: r.status === 'failed' || r.status === 'warn',
|
||||
},
|
||||
}));
|
||||
pushLog(r.status === 'failed' ? 'ERROR' : (r.status === 'skipped' || r.status === 'warn') ? 'WARN' : 'INFO',
|
||||
`${r.id}: ${r.status}${r.metric ? ' · ' + r.metric : ''}`);
|
||||
});
|
||||
const offDone = EventsOn('check:done', (s) => {
|
||||
setRunning(null);
|
||||
setPhase('checked');
|
||||
pushLog('INFO', `check finished — ${s.passed}/${s.total} passed`);
|
||||
});
|
||||
const offStatus = EventsOn('engine:status', (s) => {
|
||||
setPhase(s.running ? 'active' : 'checked');
|
||||
pushLog('INFO', s.running ? 'engine: started' : 'engine: stopped');
|
||||
});
|
||||
const offStats = EventsOn('stats:update', (s) => setStats(s));
|
||||
|
||||
return () => {
|
||||
offResult();
|
||||
offDone();
|
||||
offStatus();
|
||||
offStats();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function runCheck() {
|
||||
if (phase === 'checking') return;
|
||||
setPhase('checking');
|
||||
setResults({});
|
||||
setRunning(null);
|
||||
pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
|
||||
await RunCheck({
|
||||
host: form.host,
|
||||
port: parseInt(form.port, 10) || 0,
|
||||
auth: form.auth,
|
||||
login: form.login,
|
||||
password: form.password,
|
||||
});
|
||||
// The rest is event-driven (check:result, check:done) — see useEffect above.
|
||||
}
|
||||
|
||||
function cancelCheck() {
|
||||
CancelCheck();
|
||||
pushLog('WARN', 'check cancelled by user');
|
||||
}
|
||||
|
||||
async function startProxy() {
|
||||
if (phase !== 'checked') return;
|
||||
if (lastSummary?.failed === tests.length) return;
|
||||
await StartEngine();
|
||||
// engine:status event will flip phase to 'active'.
|
||||
}
|
||||
|
||||
async function stopProxy() {
|
||||
if (phase !== 'active') return;
|
||||
await StopEngine();
|
||||
setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
|
||||
}
|
||||
|
||||
// Reflect initial backend state (in case the engine was already running
|
||||
// when the GUI was opened — e.g. via service mode).
|
||||
React.useEffect(() => {
|
||||
GetStatus().then((s) => {
|
||||
if (s?.running) setPhase('active');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Toggle a test's expanded explanation
|
||||
function toggleExpand(id) {
|
||||
setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } }));
|
||||
}
|
||||
|
||||
return {
|
||||
form, update,
|
||||
phase, setPhase,
|
||||
tests, results, running,
|
||||
scenario, setScenario,
|
||||
stats,
|
||||
logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
|
||||
lastSummary,
|
||||
runCheck, cancelCheck, startProxy, stopProxy,
|
||||
toggleExpand,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// Sun + moon icons for the theme-toggle button in the title bar. Style
|
||||
// matches the rest (1.2 stroke, 14px square viewBox).
|
||||
export function IconSun({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="3" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v1.5M8 13v1.5M14.5 8H13M3 8H1.5M12.6 3.4l-1 1M4.4 11.6l-1 1M12.6 12.6l-1-1M4.4 4.4l-1-1"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconMoon({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13.5 9.5A5.5 5.5 0 1 1 6.5 2.5a4 4 0 0 0 7 7z"
|
||||
stroke={color} strokeWidth="1.2" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function seedLogs() {
|
||||
const t = Date.now();
|
||||
return [
|
||||
{ t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' },
|
||||
{ t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' },
|
||||
{ t: t-9000, level: 'INFO', msg: 'no active session' },
|
||||
];
|
||||
}
|
||||
|
||||
export function fmtBytes(n) {
|
||||
if (n < 1024) return n.toFixed(0) + ' B/s';
|
||||
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s';
|
||||
return (n/1024/1024).toFixed(2) + ' MB/s';
|
||||
}
|
||||
export function fmtUptime(s) {
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${ss}s`;
|
||||
return `${ss}s`;
|
||||
}
|
||||
export function fmtTime(t) {
|
||||
const d = new Date(t);
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
|
||||
}
|
||||
|
||||
// ─── Shared icons (small, original) ────────────────────────────────────────
|
||||
// Drover-Go mark: a downward chevron through a ring — "tunneled traffic".
|
||||
export function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) {
|
||||
const s = size;
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
|
||||
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear({ size=14, color='currentColor' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
|
||||
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconMin({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
export function IconClose({ size=14, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
|
||||
}
|
||||
export function IconChevron({ size=12, color='currentColor', dir='down' }) {
|
||||
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
|
||||
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconCopy({ size=12, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
|
||||
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconArrowUp({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
export function IconArrowDown({ size=10, color='currentColor' }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
|
||||
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// ─── Test row state icons (per visual variant supplies its own colors) ─────
|
||||
export function StatusDot({ state, palette, size = 12 }) {
|
||||
// state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
const c = palette[state] || palette.pending;
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
|
||||
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
|
||||
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === 'passed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'warn') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill={c}/>
|
||||
<path d="M8 4v5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
|
||||
<circle cx="8" cy="11.5" r="0.9" fill="white"/>
|
||||
</svg>;
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
|
||||
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
|
||||
</svg>;
|
||||
}
|
||||
// pending
|
||||
return <svg width={size} height={size} viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// CSS for the spinner — injected once.
|
||||
if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'drv-shared-css';
|
||||
s.textContent = `
|
||||
@keyframes drv-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} }
|
||||
@keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} }
|
||||
@keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} }
|
||||
.drv-fadein { animation: drv-fadein .18s ease-out; }
|
||||
.drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; }
|
||||
.drv-shimmer::after {
|
||||
content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent);
|
||||
transform:translateX(-100%); animation: drv-shim 1.6s linear infinite;
|
||||
}
|
||||
@keyframes drv-shim { to { transform: translateX(100%); } }
|
||||
/* Hide scrollbars for log panes inside artboards */
|
||||
.drv-log::-webkit-scrollbar { width:6px; }
|
||||
.drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
Object.assign(window, {
|
||||
useDrover, getTests, ALL_TESTS, SCENARIOS,
|
||||
fmtBytes, fmtUptime, fmtTime,
|
||||
BrandMark, StatusDot,
|
||||
IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Reset everything Wails react-template ships by default — Classic component
|
||||
* draws the entire surface, including the title bar. */
|
||||
|
||||
html, body, #app, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1c1d20;
|
||||
color: #dde0e6;
|
||||
font-family:
|
||||
"Inter", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system,
|
||||
BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13.5px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Theme switch: circle reveal from the cursor ──────────────────────── */
|
||||
/* The title-bar sun/moon button calls document.startViewTransition() before
|
||||
* flipping the mode state; the API snapshots the old DOM, runs the React
|
||||
* update, and gives us pseudo-elements `::view-transition-old(root)` and
|
||||
* `::view-transition-new(root)` to animate between. We override the default
|
||||
* cross-fade with a circular clip-path expanding from --reveal-x/y, which
|
||||
* is set to the click coordinates by App.jsx right before the transition. */
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* The new state expands from a tiny circle at the cursor into the whole
|
||||
* window. The old state stays put underneath. 0.45s feels lively without
|
||||
* dragging — long-form circle reveals (>700ms) start to feel laggy. */
|
||||
::view-transition-new(root) {
|
||||
animation: theme-reveal 0.45s ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes theme-reveal {
|
||||
from { clip-path: circle(0% at var(--reveal-x, 50%) var(--reveal-y, 50%)); }
|
||||
to { clip-path: circle(150% at var(--reveal-x, 50%) var(--reveal-y, 50%)); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// Manually-written JS bindings for the App struct in package
|
||||
// git.okcu.io/root/drover-go/internal/gui.
|
||||
//
|
||||
// Wails Bind() exposes the app's methods at runtime under
|
||||
// window.go.<package>.App.<Method>, where <package> is the Go package
|
||||
// where App is defined (here: "gui"). These wrappers give us a stable
|
||||
// import path from the React side and are the equivalent of what
|
||||
// `wails generate module` would have produced if we used the standard
|
||||
// flat layout.
|
||||
//
|
||||
// Whenever a new App method is added in internal/gui/app.go, mirror it here.
|
||||
|
||||
export function RunCheck(cfg) { return window['go']['gui']['App']['RunCheck'](cfg) }
|
||||
export function CancelCheck() { return window['go']['gui']['App']['CancelCheck']() }
|
||||
export function StartEngine() { return window['go']['gui']['App']['StartEngine']() }
|
||||
export function StopEngine() { return window['go']['gui']['App']['StopEngine']() }
|
||||
export function GetStatus() { return window['go']['gui']['App']['GetStatus']() }
|
||||
export function Version() { return window['go']['gui']['App']['Version']() }
|
||||
export function Greet(name) { return window['go']['gui']['App']['Greet'](name) }
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all event listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName) {
|
||||
return window.runtime.EventsOff(eventName);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
)
|
||||
|
||||
// Run launches the Wails GUI. It blocks until the window is closed.
|
||||
//
|
||||
// Window size matches the React design (480×640) but is resizable so
|
||||
// users on smaller displays can shrink it. Title shows the version.
|
||||
func Run(version string) error {
|
||||
app := NewApp(version)
|
||||
|
||||
// Frameless = no native Windows chrome; the React Classic component
|
||||
// renders its own title bar (brand mark, version, theme toggle,
|
||||
// min/close icons) so we deliberately suppress the OS chrome to
|
||||
// avoid stacking two title bars.
|
||||
// The Classic React component renders a fixed 480×640 surface, so we
|
||||
// pin the host window to exactly the same. Allowing resize would
|
||||
// expose the bare Wails background colour around the React canvas
|
||||
// (the "blue strip on the side" issue from early testing).
|
||||
const w, h = 480, 640
|
||||
return wails.Run(&options.App{
|
||||
Title: "Drover-Go " + version,
|
||||
Width: w,
|
||||
Height: h,
|
||||
MinWidth: w,
|
||||
MinHeight: h,
|
||||
MaxWidth: w,
|
||||
MaxHeight: h,
|
||||
DisableResize: true,
|
||||
Frameless: true,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: Assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 28, G: 29, B: 32, A: 1}, // matches Classic dark bg
|
||||
OnStartup: app.Startup,
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
},
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
echo "[$(date +%H:%M:%S)] frontend..."
|
||||
(cd internal/gui/frontend && npm run build 2>&1 | tail -3)
|
||||
echo "[$(date +%H:%M:%S)] go build..."
|
||||
CGO_ENABLED=0 go build -trimpath -tags "desktop,production" \
|
||||
-ldflags "-s -w -H=windowsgui -X main.Version=99.99.99-test" \
|
||||
-o drover-test.exe ./cmd/drover
|
||||
echo "[$(date +%H:%M:%S)] OK $(stat -c %s drover-test.exe) bytes"
|
||||
Reference in New Issue
Block a user