From c1209d7389dd79c408bfc4f6879268b18c730e68 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:10:29 +0100 Subject: [PATCH] Add producer-friendly FriendNet bridge status and startup. This wires the bridge to poll FriendNet, surfaces readable connection state in the UI, and builds the helper binaries needed to make local tester installs behave like one app. Made-with: Cursor --- .github/workflows/build_and_test.yml | 22 +++- .gitignore | 2 + CMakeLists.txt | 2 + bridge/go.mod | 8 ++ bridge/go.sum | 6 + bridge/launcher.go | 165 +++++++++++++++++++++++ bridge/main.go | 26 ++-- bridge/status.go | 187 +++++++++++++++++++++++++++ docs/BRIDGE_API.md | 52 +++++++- scripts/build-go-binaries.sh | 20 +++ source/HostLauncher.cpp | 43 +++++- source/HostLauncher.h | 3 + ui/src/App.vue | 93 +++++++++++-- ui/src/stores/bridge.ts | 74 ++++++++++- 14 files changed, 667 insertions(+), 36 deletions(-) create mode 100644 bridge/go.sum create mode 100644 bridge/launcher.go create mode 100644 bridge/status.go create mode 100755 scripts/build-go-binaries.sh diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ffb4f26..6fb4d61 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -56,10 +56,23 @@ jobs: libxcursor-dev libxrandr-dev libxcomposite-dev libxrender-dev \ libcurl4-openssl-dev - - name: Build wavgang-bridge + - name: Setup Node (FriendNet admin UI embed) + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build Go AGPL binaries (bridge + friendnet-server) run: | - cd bridge - go build -o wavgang-bridge . + set -euo pipefail + ext="" + if [[ "${RUNNER_OS}" == "Windows" ]]; then ext=".exe"; fi + cd third_party/friendnet/adminui + if [[ -f package-lock.json ]]; then npm ci; else npm install; fi + npm run build + cd "${GITHUB_WORKSPACE}/bridge" + go build -o "wavgang-bridge${ext}" . + cd "${GITHUB_WORKSPACE}/third_party/friendnet/server" + go build -o "${GITHUB_WORKSPACE}/bridge/friendnet-server${ext}" ./cmd/server # JUCE FindWebView2.cmake only searches a NuGet packages folder; GH Windows images don't have it. - name: Fetch WebView2 NuGet package for JUCE @@ -101,6 +114,9 @@ jobs: build/**/*.component build/**/*.clap bridge/wavgang-bridge + bridge/wavgang-bridge.exe + bridge/friendnet-server + bridge/friendnet-server.exe if-no-files-found: ignore - name: Upload AGPL packaging hints diff --git a/.gitignore b/.gitignore index e2dc9e2..49d521e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ ui/dist/ # Go bridge binary bridge/wavgang-bridge bridge/wavgang-bridge.exe +bridge/friendnet-server +bridge/friendnet-server.exe diff --git a/CMakeLists.txt b/CMakeLists.txt index a897bbc..faf138b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,7 @@ include(CPM) include(PamplejuceMacOS) include(JUCEDefaults) include(Sanitizers) +include(BridgeHelpers) # ── Read project identity from project.toml ────────────────────── include(ReadProjectConfig) @@ -150,6 +151,7 @@ else() target_link_libraries(SharedCode INTERFACE ${_LINK_LIBS}) target_link_libraries("${PROJ_NAME}" PRIVATE SharedCode) add_dependencies("${PROJ_NAME}" wavgang_webui) + wvg_enable_local_helpers("${PROJ_NAME}") if(MSVC) target_compile_definitions(SharedCode INTERFACE JUCE_USE_WIN_WEBVIEW2=1) diff --git a/bridge/go.mod b/bridge/go.mod index 5827ac7..be0d785 100644 --- a/bridge/go.mod +++ b/bridge/go.mod @@ -1,3 +1,11 @@ module github.com/DirektDSP/WavGang/bridge go 1.26.2 + +require connectrpc.com/connect v1.19.1 + +require friendnet.org/protocol v0.0.0 + +require google.golang.org/protobuf v1.36.11 // indirect + +replace friendnet.org/protocol => ../third_party/friendnet/protocol diff --git a/bridge/go.sum b/bridge/go.sum new file mode 100644 index 0000000..6dfa443 --- /dev/null +++ b/bridge/go.sum @@ -0,0 +1,6 @@ +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/bridge/launcher.go b/bridge/launcher.go new file mode 100644 index 0000000..d66bb78 --- /dev/null +++ b/bridge/launcher.go @@ -0,0 +1,165 @@ +package main + +import ( + "log" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +const defaultServerJSON = `{ + "listen": ["127.0.0.1:20038"], + "db_path": "server.db", + "pem_path": "server.pem", + "disable_update_checker": true, + "rpc": { + "https_pem_path": "rpc.pem", + "interfaces": [ + { + "address": "http://127.0.0.1:18081", + "allowed_methods": ["GetServerInfo"], + "cors_allow_all_origins": true + } + ] + } +} +` + +var friendnetProc struct { + mu sync.Mutex + cmd *exec.Cmd +} + +func friendnetExeName() string { + if runtime.GOOS == "windows" { + return "friendnet-server.exe" + } + return "friendnet-server" +} + +func findFriendNetServerBinary() string { + self, err := os.Executable() + if err != nil { + return "" + } + selfDir := filepath.Dir(self) + name := friendnetExeName() + candidates := []string{ + filepath.Join(selfDir, name), + filepath.Join(selfDir, "..", name), + } + for _, p := range candidates { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + } + return "" +} + +func ensureFriendNetLayout(dataDir string) error { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return err + } + cfgPath := filepath.Join(dataDir, "server.json") + if _, err := os.Stat(cfgPath); err == nil { + return nil + } + return os.WriteFile(cfgPath, []byte(defaultServerJSON), 0o600) +} + +func friendnetRPCDialAddr(rpcURL string) string { + u, err := url.Parse(rpcURL) + if err != nil || u.Host == "" { + return "" + } + host := u.Hostname() + port := u.Port() + if port == "" { + if strings.EqualFold(u.Scheme, "https") { + port = "443" + } else { + port = "80" + } + } + return net.JoinHostPort(host, port) +} + +func friendnetRPCReachable(rpcURL string) bool { + addr := friendnetRPCDialAddr(rpcURL) + if addr == "" { + return false + } + c, err := net.DialTimeout("tcp", addr, 400*time.Millisecond) + if err != nil { + return false + } + _ = c.Close() + return true +} + +// startBundledFriendNet spawns friendnet-server once if a binary is present. +func startBundledFriendNet() { + if strings.EqualFold(os.Getenv("WAVGANG_FRIENDNET_AUTOSTART"), "0") { + return + } + rpc := defaultFriendnetRPCURL() + if friendnetRPCReachable(rpc) { + log.Printf("friendnet RPC already reachable at %s; skipping autostart", rpc) + return + } + exe := findFriendNetServerBinary() + if exe == "" { + log.Printf("friendnet-server not found beside wavgang-bridge; skipping autostart (set WAVGANG_FRIENDNET_AUTOSTART=0 to silence)") + return + } + dataDir, err := friendnetDataDir() + if err != nil { + log.Printf("friendnet data dir: %v", err) + return + } + if err := ensureFriendNetLayout(dataDir); err != nil { + log.Printf("friendnet layout: %v", err) + return + } + + friendnetProc.mu.Lock() + if friendnetProc.cmd != nil && friendnetProc.cmd.Process != nil { + friendnetProc.mu.Unlock() + return + } + + cfgPath := filepath.Join(dataDir, "server.json") + cmd := exec.Command(exe, "-config", cfgPath, "-nocli") + cmd.Dir = dataDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + friendnetProc.mu.Unlock() + log.Printf("start friendnet-server: %v", err) + return + } + friendnetProc.cmd = cmd + friendnetProc.mu.Unlock() + + log.Printf("started friendnet-server pid=%d workdir=%s", cmd.Process.Pid, dataDir) + + go func(c *exec.Cmd) { + waitErr := c.Wait() + friendnetProc.mu.Lock() + if friendnetProc.cmd == c { + friendnetProc.cmd = nil + } + friendnetProc.mu.Unlock() + if waitErr != nil && !strings.Contains(waitErr.Error(), "signal") { + log.Printf("friendnet-server exited: %v", waitErr) + } + }(cmd) + + time.Sleep(800 * time.Millisecond) +} diff --git a/bridge/main.go b/bridge/main.go index 240f372..410b709 100644 --- a/bridge/main.go +++ b/bridge/main.go @@ -2,15 +2,16 @@ package main import ( + "context" "encoding/json" "log" "net/http" "os" + "os/signal" + "syscall" ) // withCORS allows the JUCE WebView (Origin juce://juce.backend) to call this loopback API. -// WebKit often rejects Access-Control-Allow-Origin when echoing a custom-scheme origin; "*" works -// for simple fetch() without credentials (see ui/src/stores/bridge.ts). func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") @@ -27,19 +28,26 @@ func withCORS(next http.Handler) http.Handler { } func main() { + log.SetPrefix("wavgang-bridge: ") + log.SetFlags(0) + addr := ":17890" if v := os.Getenv("WAVGANG_BRIDGE_ADDR"); v != "" { addr = v } + rpcURL := defaultFriendnetRPCURL() + eng := newStatusEngine(rpcURL) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + startBundledFriendNet() + go eng.runPoller(ctx) + mux := http.NewServeMux() mux.HandleFunc("/v1/status", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "service": "wavgang-bridge", - "friendnet": "not_connected", - }) + writeStatusJSON(w, eng) }) mux.HandleFunc("/v1/version", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -48,7 +56,7 @@ func main() { }) }) - log.Printf("wavgang-bridge listening on %s", addr) + log.Printf("wavgang-bridge listening on %s (friendnet RPC %s)", addr, rpcURL) if err := http.ListenAndServe(addr, withCORS(mux)); err != nil { log.Fatal(err) } diff --git a/bridge/status.go b/bridge/status.go new file mode 100644 index 0000000..8912e52 --- /dev/null +++ b/bridge/status.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "connectrpc.com/connect" + v1 "friendnet.org/protocol/pb/serverrpc/v1" + "friendnet.org/protocol/pb/serverrpc/v1/serverrpcv1connect" +) + +// StatusSnapshot is returned from GET /v1/status (stable for UI and tools). +type StatusSnapshot struct { + OK bool `json:"ok"` + Service string `json:"service"` + BridgeOK bool `json:"bridge_ok"` + Friendnet FriendnetStatus `json:"friendnet"` + FriendnetState string `json:"friendnet_state"` // same as friendnet.state (easy grep / scripts) + FriendnetSummary string `json:"friendnet_summary,omitempty"` // legacy-style: not_connected | connected (old clients) + BridgeAddr string `json:"bridge_listen_addr,omitempty"` + RPCAddr string `json:"friendnet_rpc_addr,omitempty"` +} + +// FriendnetStatus is producer-oriented state for the FriendNet server RPC. +type FriendnetStatus struct { + State string `json:"state"` // stopped | starting | reachable | unreachable | misconfigured + Detail string `json:"detail,omitempty"` + Version string `json:"version,omitempty"` // from GetServerInfo when reachable +} + +type statusEngine struct { + mu sync.RWMutex + snap StatusSnapshot + rpcURL string + interval time.Duration +} + +func newStatusEngine(rpcURL string) *statusEngine { + if rpcURL == "" { + rpcURL = defaultFriendnetRPCURL() + } + return &statusEngine{ + snap: StatusSnapshot{ + Service: "wavgang-bridge", + BridgeOK: true, + Friendnet: FriendnetStatus{ + State: "unknown", + Detail: "initializing", + }, + RPCAddr: rpcURL, + }, + rpcURL: rpcURL, + interval: 3 * time.Second, + } +} + +func defaultFriendnetRPCURL() string { + if v := os.Getenv("WAVGANG_FRIENDNET_RPC"); v != "" { + return v + } + return "http://127.0.0.1:18081" +} + +func (e *statusEngine) current() StatusSnapshot { + e.mu.RLock() + defer e.mu.RUnlock() + out := e.snap + out.BridgeAddr = listenAddrDisplay() + out.RPCAddr = e.rpcURL + out.FriendnetState = out.Friendnet.State + out.FriendnetSummary = legacyFriendnetSummary(out.Friendnet.State) + return out +} + +func legacyFriendnetSummary(state string) string { + if state == "reachable" { + return "connected" + } + return "not_connected" +} + +func listenAddrDisplay() string { + if v := os.Getenv("WAVGANG_BRIDGE_ADDR"); v != "" { + return v + } + return ":17890" +} + +func (e *statusEngine) refreshFriendnet(ctx context.Context) { + rpc := e.rpcURL + cli := newRPCClient(rpc) + fnCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + resp, err := cli.GetServerInfo(fnCtx, &v1.GetServerInfoRequest{}) + e.mu.Lock() + defer e.mu.Unlock() + + if err != nil { + msg := err.Error() + st := "unreachable" + switch connect.CodeOf(err) { + case connect.CodeUnauthenticated: + st = "auth_required" + case connect.CodePermissionDenied: + st = "auth_required" + case connect.CodeInvalidArgument: + st = "misconfigured" + } + if st == "unreachable" { + if strings.Contains(msg, "connection refused") { + st = "stopped" + } else if strings.Contains(msg, "tls:") || strings.Contains(msg, "x509") || strings.Contains(msg, "certificate") { + st = "misconfigured" + } + } + e.snap.Friendnet = FriendnetStatus{ + State: st, + Detail: trimErr(msg), + } + e.snap.OK = e.snap.BridgeOK + return + } + + e.snap.Friendnet = FriendnetStatus{ + State: "reachable", + Detail: "", + Version: resp.GetVersion(), + } + e.snap.OK = e.snap.BridgeOK +} + +func trimErr(s string) string { + const max = 200 + if len(s) <= max { + return s + } + return s[:max] + "…" +} + +func newRPCClient(baseURL string) serverrpcv1connect.ServerRpcServiceClient { + hc := &http.Client{Timeout: 5 * time.Second} + return serverrpcv1connect.NewServerRpcServiceClient(hc, baseURL, connect.WithGRPCWeb()) +} + +func (e *statusEngine) runPoller(ctx context.Context) { + e.refreshFriendnet(ctx) + t := time.NewTicker(e.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + e.refreshFriendnet(ctx) + } + } +} + +func writeStatusJSON(w http.ResponseWriter, eng *statusEngine) { + w.Header().Set("Content-Type", "application/json") + s := eng.current() + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(s); err != nil { + log.Printf("status encode: %v", err) + } +} + +// friendnetDataDir returns a per-user directory for bundled FriendNet server files. +func friendnetDataDir() (string, error) { + if v := os.Getenv("WAVGANG_FRIENDNET_DATA_DIR"); v != "" { + return filepath.Abs(v) + } + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "WavGang", "friendnet"), nil +} diff --git a/docs/BRIDGE_API.md b/docs/BRIDGE_API.md index d0a198d..78d66d7 100644 --- a/docs/BRIDGE_API.md +++ b/docs/BRIDGE_API.md @@ -1,22 +1,58 @@ # wavgang-bridge HTTP API (v1) -Base URL: `http://127.0.0.1:17890` (override with `WAVGANG_BRIDGE_ADDR` when starting the bridge). +Local REST shim between the WavGang WebView and FriendNet (AGPL). Source: repository `bridge/` directory. + +## Listening address + +Default: `http://127.0.0.1:17890` + +Override when starting the process: environment variable `WAVGANG_BRIDGE_ADDR` (for example `:17890` or `127.0.0.1:17890`). + +## FriendNet RPC (bridge → server) + +The bridge polls FriendNet’s HTTP Connect RPC (`GetServerInfo`) for status. + +- Default RPC base URL: `http://127.0.0.1:18081` +- Override: `WAVGANG_FRIENDNET_RPC` + +Bundled `friendnet-server` (optional): if a binary named `friendnet-server` / `friendnet-server.exe` sits next to `wavgang-bridge`, the bridge can start it unless `WAVGANG_FRIENDNET_AUTOSTART=0`. Data and generated `server.json` live under `WAVGANG_FRIENDNET_DATA_DIR`, or the OS user config path `…/WavGang/friendnet/` when unset. ## GET /v1/status -Returns JSON: +JSON snapshot for the UI and support. Fields are stable; new fields may be added. ```json { "ok": true, "service": "wavgang-bridge", - "friendnet": "not_connected" + "bridge_ok": true, + "friendnet": { + "state": "reachable", + "detail": "", + "version": "1.2.3" + }, + "friendnet_state": "reachable", + "friendnet_summary": "connected", + "bridge_listen_addr": ":17890", + "friendnet_rpc_addr": "http://127.0.0.1:18081" } ``` -## GET /v1/version +### Field notes + +| Field | Meaning | +| --- | --- | +| `ok` | Overall “bridge is healthy”; same as `bridge_ok` today (FriendNet errors do not flip this off). | +| `bridge_ok` | HTTP handler is running and serving `/v1/status`. | +| `friendnet.state` | `reachable` · `stopped` · `unreachable` · `auth_required` · `misconfigured` · `unknown` (before first poll). | +| `friendnet.detail` | Short error text when not reachable (truncated). | +| `friendnet.version` | From `GetServerInfo` when `state` is `reachable`. | +| `friendnet_state` | Duplicate of `friendnet.state` for simple scripts. | +| `friendnet_summary` | Legacy-style hint: `connected` or `not_connected` (older docs used a string `friendnet` field). | +| `bridge_listen_addr` | Effective listen address (from `WAVGANG_BRIDGE_ADDR` or default). | +| `friendnet_rpc_addr` | RPC base URL in use. | -Returns JSON: +## GET /v1/version ```json { @@ -24,4 +60,8 @@ Returns JSON: } ``` -Future endpoints (search, downloads, shares) will be added without breaking the above. +Future endpoints (search, shares, etc.) will be added without removing the above. + +## Security + +Intended for loopback use. The bridge sets permissive CORS headers for the embedded JUCE WebView; do not expose `WAVGANG_BRIDGE_ADDR` on untrusted networks without a dedicated review. diff --git a/scripts/build-go-binaries.sh b/scripts/build-go-binaries.sh new file mode 100755 index 0000000..5dcbcd5 --- /dev/null +++ b/scripts/build-go-binaries.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build wavgang-bridge and friendnet-server into bridge/ for local packaging or testing. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ext="" +if [[ "${OSTYPE:-}" == msys* ]] || [[ "${OS:-}" == Windows_NT ]]; then + ext=".exe" +fi +cd "${ROOT}/third_party/friendnet/adminui" +if [[ -f package-lock.json ]]; then + npm ci +else + npm install +fi +npm run build +cd "${ROOT}/bridge" +go build -o "wavgang-bridge${ext}" . +cd "${ROOT}/third_party/friendnet/server" +go build -o "${ROOT}/bridge/friendnet-server${ext}" ./cmd/server +echo "Built ${ROOT}/bridge/wavgang-bridge${ext} and ${ROOT}/bridge/friendnet-server${ext}" diff --git a/source/HostLauncher.cpp b/source/HostLauncher.cpp index 0bfa205..5adca6a 100644 --- a/source/HostLauncher.cpp +++ b/source/HostLauncher.cpp @@ -38,14 +38,53 @@ juce::File HostLauncher::findBridgeExecutable() return {}; } +juce::File HostLauncher::findFriendNetExecutable() +{ +#if JUCE_WINDOWS + const auto name = "friendnet-server.exe"; +#else + const auto name = "friendnet-server"; +#endif + + juce::File app { juce::File::getSpecialLocation (juce::File::currentApplicationFile) }; + juce::Array candidates; + + candidates.add (app.getSiblingFile (name)); + candidates.add (app.getParentDirectory().getChildFile (name)); + +#if JUCE_MAC + auto contents = app.getParentDirectory().getParentDirectory(); + candidates.add (contents.getChildFile ("MacOS").getChildFile (name)); +#endif + + for (auto f : candidates) + if (f.existsAsFile()) + return f; + + return {}; +} + void HostLauncher::ensureBridgeRunning (const juce::String& baseUrl) { - if (isReachable (baseUrl)) - return; + for (int i = 0; i < 5; ++i) + { + if (isReachable (baseUrl)) + return; + + if (i > 0) + juce::Thread::sleep (150); + } auto exe = findBridgeExecutable(); if (!exe.existsAsFile()) return; (void) exe.startAsProcess(); + + for (int i = 0; i < 8; ++i) + { + juce::Thread::sleep (150); + if (isReachable (baseUrl)) + return; + } } diff --git a/source/HostLauncher.h b/source/HostLauncher.h index f023ec0..ccf1673 100644 --- a/source/HostLauncher.h +++ b/source/HostLauncher.h @@ -9,6 +9,9 @@ class HostLauncher /** If bridge is not already listening, try to start it. Safe to call from message thread. */ static void ensureBridgeRunning (const juce::String& baseUrl = "http://127.0.0.1:17890"); + /** Locate bundled friendnet-server next to the host (same layout as the bridge). FriendNet is normally started by the bridge. */ + static juce::File findFriendNetExecutable(); + private: static juce::File findBridgeExecutable(); }; diff --git a/ui/src/App.vue b/ui/src/App.vue index 3e686cc..ab25e5e 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -4,7 +4,7 @@ import { storeToRefs } from "pinia"; import { useBridgeStore } from "./stores/bridge"; const bridge = useBridgeStore(); -const { statusJson, error } = storeToRefs(bridge); +const { error, loading, bridgeLine, friendnetLine, friendnetDetail } = storeToRefs(bridge); onMounted(() => { void bridge.refreshStatus(); @@ -18,11 +18,41 @@ onMounted(() => {

P2P sample hub — Web UI

-

Bridge

- -

{{ error }}

-
{{ statusJson }}
-

No response yet.

+

Connection

+
+ +
+

+ Could not reach the bridge. + {{ error }} +

+ +

+ The bridge runs on your computer and talks to FriendNet. If FriendNet stays offline, ensure + friendnet-server + is installed next to WavGang or set + WAVGANG_FRIENDNET_AUTOSTART=0 + if you use a remote server. +

@@ -56,6 +86,9 @@ onMounted(() => { margin-top: 0; font-size: 1.1rem; } +.actions { + margin-bottom: 0.75rem; +} .btn { background: #7aa2f7; color: #1a1b26; @@ -65,20 +98,58 @@ onMounted(() => { font-weight: 600; cursor: pointer; } -.btn:hover { +.btn:hover:not(:disabled) { filter: brightness(1.05); } +.btn:disabled { + opacity: 0.65; + cursor: default; +} .err { color: #f7768e; } -.out { - background: #1a1b26; - padding: 0.75rem; +.err .label { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; +} +.status-list { + list-style: none; + padding: 0; + margin: 0 0 0.5rem; +} +.status-list li { + padding: 0.45rem 0.65rem; border-radius: 6px; - overflow: auto; + margin-bottom: 0.35rem; + background: #1a1b26; + border-left: 3px solid #565f89; +} +.status-list li.ok { + border-left-color: #9ece6a; +} +.status-list li.bad { + border-left-color: #f7768e; +} +.status-list li.wait { + border-left-color: #e0af68; +} +.detail { font-size: 0.85rem; + color: #a9b1d6; + margin: 0 0 0.75rem; + word-break: break-word; +} +.hint { + font-size: 0.8rem; + line-height: 1.45; } .muted { color: #565f89; } +.code { + font-family: ui-monospace, monospace; + font-size: 0.78rem; + color: #7dcfff; +} diff --git a/ui/src/stores/bridge.ts b/ui/src/stores/bridge.ts index 70a3a2f..2a2288f 100644 --- a/ui/src/stores/bridge.ts +++ b/ui/src/stores/bridge.ts @@ -1,23 +1,87 @@ import { defineStore } from "pinia"; -import { ref } from "vue"; +import { computed, ref } from "vue"; import { getBridgeBaseUrl } from "../nativeBridge"; +export interface FriendnetStatus { + state: string; + detail?: string; + version?: string; +} + +export interface BridgeStatus { + ok: boolean; + service: string; + bridge_ok: boolean; + friendnet: FriendnetStatus; + friendnet_state: string; + friendnet_summary?: string; + bridge_listen_addr?: string; + friendnet_rpc_addr?: string; +} + +function friendnetHeadline(s: FriendnetStatus): string { + switch (s.state) { + case "reachable": + return s.version ? `FriendNet is connected (server ${s.version})` : "FriendNet is connected"; + case "stopped": + return "FriendNet is not running on this machine"; + case "unreachable": + return "FriendNet did not respond"; + case "auth_required": + return "FriendNet needs sign-in or a valid token"; + case "misconfigured": + return "FriendNet address or TLS setup looks wrong"; + case "unknown": + return "Checking FriendNet…"; + default: + return `FriendNet: ${s.state}`; + } +} + export const useBridgeStore = defineStore("bridge", () => { - const statusJson = ref(""); + const status = ref(null); const error = ref(""); + const loading = ref(false); + + const bridgeLine = computed(() => { + if (error.value) return "Bridge: not reachable from this window"; + if (!status.value) return "Bridge: …"; + return status.value.bridge_ok ? "Bridge: OK" : "Bridge: problem"; + }); + + const friendnetLine = computed(() => { + if (error.value || !status.value) return ""; + return friendnetHeadline(status.value.friendnet); + }); + + const friendnetDetail = computed(() => { + if (error.value || !status.value?.friendnet.detail) return ""; + return status.value.friendnet.detail; + }); async function refreshStatus() { + loading.value = true; error.value = ""; try { const base = await getBridgeBaseUrl(); const r = await fetch(`${base}/v1/status`); if (!r.ok) throw new Error(`HTTP ${r.status}`); - statusJson.value = await r.text(); + status.value = (await r.json()) as BridgeStatus; } catch (e) { error.value = e instanceof Error ? e.message : String(e); - statusJson.value = ""; + status.value = null; + } finally { + loading.value = false; } } - return { statusJson, error, refreshStatus }; + return { + status, + error, + loading, + bridgeLine, + friendnetLine, + friendnetDetail, + refreshStatus, + }; });