@@ -3,10 +3,15 @@ package cmd
33import (
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
6661func 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 , "\n Warning: failed to refresh token: %v\n " , err )
380+ continue
381+ }
382+ federatedToken = newToken
383+ writePlayerHTML (htmlPath , cameraName , cameraUUID , federatedToken )
384+ fmt .Fprintf (os .Stderr , "\n Token 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