Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ ui/dist/
# Go bridge binary
bridge/wavgang-bridge
bridge/wavgang-bridge.exe
bridge/friendnet-server
bridge/friendnet-server.exe
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ include(CPM)
include(PamplejuceMacOS)
include(JUCEDefaults)
include(Sanitizers)
include(BridgeHelpers)

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include(BridgeHelpers) / wvg_enable_local_helpers() are introduced, but there is no BridgeHelpers.cmake (or wvg_enable_local_helpers definition) in the repo checkout outside the cmake submodule. Ensure the cmake submodule pointer is updated in this PR to a revision that provides these modules/functions; otherwise CMake configure will fail for consumers who check out this commit.

Suggested change
include(BridgeHelpers)
include(BridgeHelpers OPTIONAL RESULT_VARIABLE BRIDGEHELPERS_MODULE)
if(COMMAND wvg_enable_local_helpers)
wvg_enable_local_helpers()
elseif(BRIDGEHELPERS_MODULE STREQUAL "NOTFOUND")
message(STATUS "BridgeHelpers.cmake not found; skipping wvg_enable_local_helpers().")
endif()

Copilot uses AI. Check for mistakes.

# ── Read project identity from project.toml ──────────────────────
include(ReadProjectConfig)
Expand Down Expand Up @@ -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}")

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wvg_enable_local_helpers("${PROJ_NAME}") is called here, but the function isn’t defined anywhere in the repo checkout (outside the cmake submodule). If the submodule revision isn’t updated to include this helper, this will be a hard CMake configure error. Please ensure the required cmake-includes submodule update is included with this change.

Suggested change
wvg_enable_local_helpers("${PROJ_NAME}")
if(COMMAND wvg_enable_local_helpers)
wvg_enable_local_helpers("${PROJ_NAME}")
endif()

Copilot uses AI. Check for mistakes.

if(MSVC)
target_compile_definitions(SharedCode INTERFACE JUCE_USE_WIN_WEBVIEW2=1)
Expand Down
8 changes: 8 additions & 0 deletions bridge/go.mod
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions bridge/go.sum
Original file line number Diff line number Diff line change
@@ -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=
165 changes: 165 additions & 0 deletions bridge/launcher.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +138 to +142

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bridge autostarts friendnet-server as a child process here, but there’s no corresponding shutdown/cleanup on bridge exit. This can leave friendnet-server orphaned after wavgang-bridge stops. Consider tying the child lifecycle to the bridge’s signal context (terminate on shutdown) or otherwise ensuring the subprocess is cleaned up.

Copilot uses AI. Check for mistakes.
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)
}
26 changes: 17 additions & 9 deletions bridge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "*")
Expand All @@ -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)
Comment on lines +42 to +46

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal.NotifyContext() intercepts SIGINT/SIGTERM, but the HTTP server is started with http.ListenAndServe and never shut down on <-ctx.Done(). This can prevent Ctrl+C/SIGTERM from stopping the process cleanly. Consider using an http.Server and calling Shutdown() when the context is canceled.

Copilot uses AI. Check for mistakes.

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")
Expand All @@ -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)
}
Expand Down
Loading
Loading