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, + }; });