Skip to content

Commit a89c8d5

Browse files
Brandon Salzbergclaude
andcommitted
Serve API player via local HTTP server with auto-refreshing tokens
- Local HTTP server serves cached assets and proxies missing ones from S3 - Federated token generated with correct localhost domain for CORS - Token auto-refreshes every 50 minutes - Assets cached to ~/.rhombus/player/ on first use - Both `rhombus live` and `rhombus alert play` use the new server Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 871f699 commit a89c8d5

2 files changed

Lines changed: 169 additions & 56 deletions

File tree

cmd/alert.go

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -222,21 +222,16 @@ func runAlertPlay(cmd *cobra.Command, args []string) error {
222222
fmt.Printf("Playing alert: %s at %s (%.0fs)\n", camName,
223223
time.UnixMilli(int64(tsMs)).Format("Jan 2 3:04:05 PM"), durSec)
224224

225-
fedResp, err := client.APICall(cfg, "/api/org/generateFederatedSessionToken", map[string]any{
226-
"durationSec": 3600,
227-
})
225+
serverURL, _, err := startPlayerServer(deviceUuid, camName, cfg, 3600)
228226
if err != nil {
229-
return fmt.Errorf("generating federated token: %w", err)
227+
return fmt.Errorf("starting player: %w", err)
230228
}
231-
federatedToken, _ := fedResp["federatedSessionToken"].(string)
232229

233-
htmlPath, err := generateApiPlayerHTML(deviceUuid, camName, federatedToken)
234-
if err != nil {
235-
return fmt.Errorf("generating player: %w", err)
236-
}
237-
238-
openInBrowser("file://" + htmlPath)
230+
openInBrowser(serverURL)
239231
fmt.Println("Alert clip opened in browser.")
232+
fmt.Println("Press Ctrl+C to stop.")
233+
234+
select {}
240235
return nil
241236
}
242237

cmd/live.go

Lines changed: 163 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ package cmd
33
import (
44
"encoding/json"
55
"fmt"
6+
"io"
7+
"net"
8+
"net/http"
69
"os"
710
"os/exec"
811
"path/filepath"
912
"runtime"
13+
"strings"
14+
"time"
1015

1116
"github.com/RhombusSystems/rhombus-cli/internal/client"
1217
"github.com/RhombusSystems/rhombus-cli/internal/config"
@@ -38,29 +43,19 @@ func runLive(cmd *cobra.Command, args []string) error {
3843

3944
fmt.Printf("Opening live stream for %s...\n", cameraName)
4045

41-
// Generate federated session token
42-
fedResp, err := client.APICall(cfg, "/api/org/generateFederatedSessionToken", map[string]any{
43-
"durationSec": duration,
44-
})
45-
if err != nil {
46-
return fmt.Errorf("generating federated token: %w", err)
47-
}
48-
federatedToken, _ := fedResp["federatedSessionToken"].(string)
49-
if federatedToken == "" {
50-
return fmt.Errorf("no federated token returned")
51-
}
52-
53-
// Generate local HTML that loads the hosted API player assets
54-
htmlPath, err := generateApiPlayerHTML(cameraUUID, cameraName, federatedToken)
46+
// Start the local server first so we know the port
47+
serverURL, _, err := startPlayerServer(cameraUUID, cameraName, cfg, duration)
5548
if err != nil {
56-
return fmt.Errorf("generating player: %w", err)
49+
return fmt.Errorf("starting player server: %w", err)
5750
}
5851

59-
openInBrowser("file://" + htmlPath)
52+
openInBrowser(serverURL)
6053

6154
fmt.Printf("Live stream opened in browser.\n")
62-
fmt.Printf("Token expires in %d seconds.\n", duration)
63-
return nil
55+
fmt.Println("Press Ctrl+C to stop.")
56+
57+
// Keep the process alive so the local HTTP server stays running
58+
select {}
6459
}
6560

6661
func resolveCamera(cfg config.Config, cameraArg string) (uuid string, name string, err error) {
@@ -301,49 +296,172 @@ func openInBrowser(url string) {
301296
_ = cmd.Start()
302297
}
303298

304-
const apiPlayerAssetsBase = "https://bs-api-player.console.itg.rhombussystems.com/api"
299+
const (
300+
apiPlayerAssetsURL = "https://public-bucket-itg.s3.us-west-2.amazonaws.com/rhombus-cli"
301+
apiPlayerJSFile = "index-BtGEYTAQ.js"
302+
apiPlayerCSSFile = "index-CE1zZXB9.css"
303+
)
305304

306-
func generateApiPlayerHTML(cameraUUID, cameraName, federatedToken string) (string, error) {
307-
tmpDir := filepath.Join(os.TempDir(), "rhombus-live")
308-
if err := os.MkdirAll(tmpDir, 0755); err != nil {
309-
return "", err
305+
// ensureApiPlayerAssets downloads the player JS/CSS to ~/.rhombus/player/ if not already cached.
306+
func ensureApiPlayerAssets() (string, error) {
307+
playerDir := filepath.Join(rhombusDir(), "player", "assets")
308+
if err := os.MkdirAll(playerDir, 0755); err != nil {
309+
return "", fmt.Errorf("creating player dir: %w", err)
310+
}
311+
312+
for _, file := range []string{apiPlayerJSFile, apiPlayerCSSFile} {
313+
localPath := filepath.Join(playerDir, file)
314+
if _, err := os.Stat(localPath); err == nil {
315+
continue // already cached
316+
}
317+
318+
url := apiPlayerAssetsURL + "/assets/" + file
319+
fmt.Printf("Downloading player asset: %s\n", file)
320+
321+
resp, err := http.Get(url)
322+
if err != nil {
323+
return "", fmt.Errorf("downloading %s: %w", file, err)
324+
}
325+
326+
if resp.StatusCode != 200 {
327+
resp.Body.Close()
328+
return "", fmt.Errorf("downloading %s: HTTP %d", file, resp.StatusCode)
329+
}
330+
331+
f, err := os.Create(localPath)
332+
if err != nil {
333+
resp.Body.Close()
334+
return "", fmt.Errorf("creating %s: %w", file, err)
335+
}
336+
io.Copy(f, resp.Body)
337+
f.Close()
338+
resp.Body.Close()
310339
}
311340

312-
htmlPath := filepath.Join(tmpDir, "player.html")
341+
return playerDir, nil
342+
}
343+
344+
func startPlayerServer(cameraUUID, cameraName string, cfg config.Config, duration int) (string, int, error) {
345+
assetsDir, err := ensureApiPlayerAssets()
346+
if err != nil {
347+
return "", 0, err
348+
}
313349

350+
playerDir := filepath.Dir(assetsDir)
351+
352+
// Start local HTTP server
353+
listener, err := net.Listen("tcp", "127.0.0.1:0")
354+
if err != nil {
355+
return "", 0, fmt.Errorf("starting local server: %w", err)
356+
}
357+
port := listener.Addr().(*net.TCPAddr).Port
358+
origin := fmt.Sprintf("http://localhost:%d", port)
359+
360+
// Generate initial federated token with the correct domain
361+
federatedToken, err := generateFederatedToken(cfg, duration, origin)
362+
if err != nil {
363+
return "", 0, err
364+
}
365+
366+
// Write the HTML
367+
htmlPath := filepath.Join(playerDir, "player.html")
368+
if err := writePlayerHTML(htmlPath, cameraName, cameraUUID, federatedToken); err != nil {
369+
return "", 0, err
370+
}
371+
372+
// Auto-refresh the token every 50 minutes (before the 60min expiry)
373+
go func() {
374+
ticker := time.NewTicker(50 * time.Minute)
375+
defer ticker.Stop()
376+
for range ticker.C {
377+
newToken, err := generateFederatedToken(cfg, duration, origin)
378+
if err != nil {
379+
fmt.Fprintf(os.Stderr, "\nWarning: failed to refresh token: %v\n", err)
380+
continue
381+
}
382+
federatedToken = newToken
383+
writePlayerHTML(htmlPath, cameraName, cameraUUID, federatedToken)
384+
fmt.Fprintf(os.Stderr, "\nToken refreshed.\n")
385+
}
386+
}()
387+
388+
// Serve local assets, proxy missing from remote, SPA fallback
389+
remoteBase := apiPlayerAssetsURL
390+
go func() {
391+
mux := http.NewServeMux()
392+
fs := http.FileServer(http.Dir(playerDir))
393+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
394+
localPath := filepath.Join(playerDir, r.URL.Path)
395+
if info, statErr := os.Stat(localPath); statErr == nil && !info.IsDir() {
396+
fs.ServeHTTP(w, r)
397+
return
398+
}
399+
400+
if strings.HasPrefix(r.URL.Path, "/assets/") ||
401+
strings.HasSuffix(r.URL.Path, ".js") ||
402+
strings.HasSuffix(r.URL.Path, ".css") ||
403+
strings.HasSuffix(r.URL.Path, ".wasm") ||
404+
strings.HasSuffix(r.URL.Path, ".png") ||
405+
strings.HasSuffix(r.URL.Path, ".svg") ||
406+
strings.HasSuffix(r.URL.Path, ".woff") ||
407+
strings.HasSuffix(r.URL.Path, ".woff2") {
408+
remoteURL := remoteBase + r.URL.Path
409+
proxyResp, proxyErr := http.Get(remoteURL)
410+
if proxyErr == nil && proxyResp.StatusCode == 200 {
411+
for k, v := range proxyResp.Header {
412+
w.Header()[k] = v
413+
}
414+
io.Copy(w, proxyResp.Body)
415+
proxyResp.Body.Close()
416+
return
417+
}
418+
if proxyResp != nil {
419+
proxyResp.Body.Close()
420+
}
421+
}
422+
423+
http.ServeFile(w, r, htmlPath)
424+
})
425+
http.Serve(listener, mux)
426+
}()
427+
428+
playerURL := fmt.Sprintf("%s/api/player/%s?ft=%s&name=%s",
429+
origin, cameraUUID, federatedToken, cameraName)
430+
return playerURL, port, nil
431+
}
432+
433+
func generateFederatedToken(cfg config.Config, duration int, domain string) (string, error) {
434+
fedResp, err := client.APICall(cfg, "/api/org/generateFederatedSessionToken", map[string]any{
435+
"durationSec": duration,
436+
"domain": domain,
437+
})
438+
if err != nil {
439+
return "", fmt.Errorf("generating federated token: %w", err)
440+
}
441+
token, _ := fedResp["federatedSessionToken"].(string)
442+
if token == "" {
443+
return "", fmt.Errorf("no federated token returned")
444+
}
445+
return token, nil
446+
}
447+
448+
func writePlayerHTML(htmlPath, cameraName, cameraUUID, federatedToken string) error {
314449
html := fmt.Sprintf(`<!DOCTYPE html>
315450
<html lang="en">
316451
<head>
317452
<meta charset="UTF-8" />
318453
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
319454
<title>%s — Rhombus Player</title>
320-
<link rel="stylesheet" href="%s/assets/index-D66MeRQc.css" />
455+
<link rel="stylesheet" href="/assets/%s" />
321456
<style>
322457
html, body, #root { height: 100%%; margin: 0; }
323458
</style>
324459
</head>
325460
<body>
326461
<div id="root" style="height: 100%%"></div>
327-
<script>
328-
// Inject player config before the app loads
329-
window.__RHOMBUS_API_PLAYER__ = {
330-
cameraUuid: "%s",
331-
cameraName: "%s",
332-
federatedToken: "%s",
333-
};
334-
// Rewrite location so the app's router matches /api/player/:cameraUuid
335-
history.replaceState(null, "", "/api/player/%s?ft=%s&name=%s");
336-
</script>
337-
<script type="module" src="%s/assets/index-CW-Rip9v.js"></script>
462+
<script type="module" src="/assets/%s"></script>
338463
</body>
339-
</html>`, cameraName, apiPlayerAssetsBase,
340-
cameraUUID, cameraName, federatedToken,
341-
cameraUUID, federatedToken, cameraName,
342-
apiPlayerAssetsBase)
343-
344-
if err := os.WriteFile(htmlPath, []byte(html), 0644); err != nil {
345-
return "", err
346-
}
464+
</html>`, cameraName, apiPlayerCSSFile, apiPlayerJSFile)
347465

348-
return htmlPath, nil
466+
return os.WriteFile(htmlPath, []byte(html), 0644)
349467
}

0 commit comments

Comments
 (0)