diff --git a/client/canvas.go b/client/canvas.go index eedfb607..ef779d07 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -119,6 +119,10 @@ func (c *Client) RenderCanvas(p proto.CanvasRenderParams) (proto.CanvasRendered, return out, err } +// ServeCanvasRenders has this connection render frames for the daemon, serving no +// Browsers. An older daemon answers an unknown method, and asks nothing of it. +func (c *Client) ServeCanvasRenders() error { return c.Call(proto.MethodCanvasServe, nil, nil) } + // CanvasRenderReply answers the daemon's request to render. func (c *Client) CanvasRenderReply(r proto.CanvasRenderReply) error { return c.Call(proto.MethodCanvasRenderReply, r, nil) diff --git a/core/canvas/raster.go b/core/canvas/raster.go index d0703ccc..5a6e0835 100644 --- a/core/canvas/raster.go +++ b/core/canvas/raster.go @@ -29,7 +29,7 @@ import ( // a web engine, the app's. // ErrNeedsBrowser is a frame with HTML, which only a web engine renders. -var ErrNeedsBrowser = errors.New("canvas: the frame has HTML, which the enowX app renders: open the app connected to this host") +var ErrNeedsBrowser = errors.New("canvas: the frame has HTML, which the enowX app renders, and no app is connected to this host: open the app, and check it reaches this host") // PNG draws frame f's shapes as a PNG, f's size times scale. func PNG(f proto.CanvasFrame, assets Assets, scale float64) ([]byte, error) { diff --git a/daemon/canvas.go b/daemon/canvas.go index aa97f522..0b340f69 100644 --- a/daemon/canvas.go +++ b/daemon/canvas.go @@ -9,6 +9,7 @@ import ( "fmt" "image" "image/png" + "slices" "strings" "sync" "time" @@ -25,6 +26,40 @@ type canvasState struct { mu sync.Mutex paused map[string]bool pending map[string]chan proto.CanvasRenderReply + // renderers are the apps that render but serve no Browsers, the latest last. + renderers []*conn +} + +// serveRenders has c render frames. +func (cs *canvasState) serveRenders(c *conn) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.renderers = append(slices.DeleteFunc(cs.renderers, func(o *conn) bool { return o == c }), c) +} + +// dropRenderer forgets c, which closed. +func (cs *canvasState) dropRenderer(c *conn) { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.renderers = slices.DeleteFunc(cs.renderers, func(o *conn) bool { return o == c }) +} + +// renderer is the app to render a frame: the latest showing this host, else the latest +// only watching it; nil with no app connected. +func (s *Server) renderer() *conn { + b := &s.browsers + b.mu.Lock() + defer b.mu.Unlock() + if n := len(b.serving); n > 0 { + return b.serving[n-1] + } + cs := &s.canvasState + cs.mu.Lock() + defer cs.mu.Unlock() + if n := len(cs.renderers); n > 0 { + return cs.renderers[n-1] + } + return nil } func (cs *canvasState) isPaused(id string) bool { @@ -239,16 +274,12 @@ func encodePNG(img image.Image) ([]byte, error) { // canvasRenderTimeout bounds a render by the app: a page loading. const canvasRenderTimeout = 40 * time.Second -// appRender has the latest app serving Browsers render req. +// appRender has the app that renders frames render req. func (s *Server) appRender(req proto.CanvasRenderRequest) (proto.CanvasRenderReply, error) { - b := &s.browsers - b.mu.Lock() - if len(b.serving) == 0 { - b.mu.Unlock() + app := s.renderer() + if app == nil { return proto.CanvasRenderReply{}, canvas.ErrNeedsBrowser } - app := b.serving[len(b.serving)-1] - b.mu.Unlock() req.ID = newRenderID() ch := make(chan proto.CanvasRenderReply, 1) cs := &s.canvasState diff --git a/daemon/canvas_render_watch_test.go b/daemon/canvas_render_watch_test.go new file mode 100644 index 00000000..51449ece --- /dev/null +++ b/daemon/canvas_render_watch_test.go @@ -0,0 +1,84 @@ +package daemon + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/core/canvas" + "github.com/enowx/enowx/proto" +) + +// htmlDesign is a canvas with a mockup, which only an app renders. +func htmlDesign(t *testing.T, c *client.Client) proto.Canvas { + t.Helper() + cv, err := c.CreateCanvas(proto.CanvasCreateParams{Title: "Page", Kind: proto.CanvasMockup}) + if err != nil { + t.Fatal(err) + } + cv, err = c.EditCanvas(proto.CanvasEdit{ID: cv.ID, Op: proto.CanvasOpHTMLPatch, HTML: `

hi

`}) + if err != nil { + t.Fatal(err) + } + return cv +} + +// An app only watching this host renders its frames, though it serves no Browsers: +// what an agent on a server hit with the app open on another host. An app showing the +// host goes first. With none connected, the error says that. +func TestCanvasRendersThroughAnAppWatching(t *testing.T) { + srv := startServer(t) + caller := connect(t, srv, nil) + cv := htmlDesign(t, caller) + render := func() (proto.CanvasRendered, error) { + return caller.RenderCanvas(proto.CanvasRenderParams{ID: cv.ID, Frame: cv.Frames[0].ID, Format: proto.RenderPNG}) + } + if _, err := render(); err == nil || !strings.Contains(err.Error(), "no app is connected") { + t.Fatalf("no app: %v", err) + } + app := func(picture string) *client.Client { + var c *client.Client + c = connect(t, srv, func(e proto.Event) { + var req proto.CanvasRenderRequest + if e.Name == proto.EventCanvasRender && json.Unmarshal(e.Data, &req) == nil { + go func() { _ = c.CanvasRenderReply(proto.CanvasRenderReply{ID: req.ID, Data: []byte(picture)}) }() + } + }) + return c + } + watching := app("from the app watching") + if err := watching.ServeCanvasRenders(); err != nil { + t.Fatal(err) + } + got, err := render() + if err != nil || string(got.Data) != "from the app watching" { + t.Fatalf("watching: %q, %v", got.Data, err) + } + // It serves no Browsers: a Browser's action has to find an app showing the host. + srv.browsers.mu.Lock() + browsers := len(srv.browsers.serving) + srv.browsers.mu.Unlock() + if browsers != 0 { + t.Fatalf("%d apps serve Browsers", browsers) + } + showing := app("from the app showing") + if err := showing.ServeBrowsers(); err != nil { + t.Fatal(err) + } + if got, err := render(); err != nil || string(got.Data) != "from the app showing" { + t.Fatalf("showing: %q, %v", got.Data, err) + } + showing.Close() + <-showing.Done() + waitFor(t, "the app showing dropped", func() bool { + got, err := render() + return err == nil && string(got.Data) == "from the app watching" + }) + watching.Close() + <-watching.Done() + waitFor(t, "no app left", func() bool { + _, err := render() + return err != nil && strings.Contains(err.Error(), canvas.ErrNeedsBrowser.Error()) + }) +} diff --git a/daemon/rpc_canvas.go b/daemon/rpc_canvas.go index 9face4ba..3de48b95 100644 --- a/daemon/rpc_canvas.go +++ b/daemon/rpc_canvas.go @@ -130,6 +130,10 @@ var canvasHandlers = map[string]handler{ c.srv.videos.reply(p) return ok, nil }), + proto.MethodCanvasServe: bare(func(c *conn) (any, error) { + c.srv.canvasState.serveRenders(c) + return ok, nil + }), proto.MethodCanvasRenderReply: with(func(c *conn, p proto.CanvasRenderReply) (any, error) { c.srv.canvasState.reply(p) return ok, nil diff --git a/daemon/server.go b/daemon/server.go index 1aaf7945..04f403a7 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -314,6 +314,7 @@ func (s *Server) serveConn(nc net.Conn, remote bool) { delete(s.conns, c) s.mu.Unlock() s.browsers.drop(c) + s.canvasState.dropRenderer(c) s.projWatches.drop(c) s.appReqs.drop(c) s.vault.drop(c) diff --git a/docs/canvas.md b/docs/canvas.md index f478ece4..87bfc553 100644 --- a/docs/canvas.md +++ b/docs/canvas.md @@ -141,7 +141,7 @@ Agents learn how to design from the canvas guide (`enowx-app canvas guide`), whi The daemon draws a frame of shapes itself (PNG, PDF and SVG). Agents on a server see what they made without a screen or an app. -A frame with HTML needs a web engine. The daemon asks the app connected to the host and serving Browsers (`canvas.render` event), which loads the page in a web view behind its window, captures it, and answers (`canvas.renderReply`). Without such an app, rendering a mockup says to open it. +A frame with HTML needs a web engine. The daemon asks an app connected to the host (`canvas.render` event): the latest showing this host, which serves Browsers, else the latest only watching it, which registers with `canvas.serve` and renders on the device the app runs on. It loads the page in a web view behind its window, captures it, and answers (`canvas.renderReply`). An older app or daemon doesn't do the watching part, so it needs the app to show the host. Exporting a motion as a video still needs the app showing the host. Without such an app, rendering a mockup says to open it. ## Where they are kept diff --git a/proto/canvas.go b/proto/canvas.go index 1675e876..89f9b479 100644 --- a/proto/canvas.go +++ b/proto/canvas.go @@ -26,11 +26,14 @@ const ( MethodCanvasAsset = "canvas.asset" // CanvasAsset → CanvasAsset (its Name, Data left out) MethodCanvasAssetGet = "canvas.assetGet" // CanvasAsset (ID, Name) → CanvasAsset with its Data // MethodCanvasRender renders a frame as a PNG or a PDF: a frame of - // shapes alone by the daemon, one with HTML by the app serving - // Browsers (EventCanvasRender), which answers with - // MethodCanvasRenderReply. + // shapes alone by the daemon, one with HTML by an app connected to the + // host (EventCanvasRender), which answers with MethodCanvasRenderReply. MethodCanvasRender = "canvas.render" // CanvasRenderParams → CanvasRendered MethodCanvasRenderReply = "canvas.renderReply" // CanvasRenderReply + // MethodCanvasServe tells the daemon this connection renders frames but serves no + // Browsers: an app only watching this host, showing another (the event's sent to + // it when no app here serves Browsers). + MethodCanvasServe = "canvas.serve" ) // Canvas events. diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index 1393d616..4ba7d71b 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -2793,6 +2793,7 @@ method canvas.redo method canvas.render method canvas.renderReply method canvas.restore +method canvas.serve method canvas.share method canvas.shares method canvas.undo diff --git a/ui/canvas.go b/ui/canvas.go index 13be0ab4..ff99ba71 100644 --- a/ui/canvas.go +++ b/ui/canvas.go @@ -97,7 +97,7 @@ func (a *App) canvasEvent(ev proto.Event) func() { if json.Unmarshal(ev.Data, &req) != nil { return nil } - return func() { a.renderCanvas(req) } + return func() { a.renderCanvas(a.client, req) } } return nil } diff --git a/ui/canvas_watch_test.go b/ui/canvas_watch_test.go new file mode 100644 index 00000000..dc82babf --- /dev/null +++ b/ui/canvas_watch_test.go @@ -0,0 +1,56 @@ +package ui + +import ( + "net" + "strings" + "testing" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/core/hosts" + "github.com/enowx/enowx/daemon" + "github.com/enowx/enowx/proto" +) + +// A watched host's render request is answered through that host's connection, not the +// one of the host shown: here, with no web view, by saying so. +func TestWatchedHostsRenderRequestGoesBackToIt(t *testing.T) { + srv, err := daemon.NewServer(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(srv.Close) + dial := func(on func(proto.Event)) *client.Client { + a, b := net.Pipe() + go srv.ServeConn(a) + c, err := client.New(b, on) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close() }) + return c + } + app := New(Options{}) + app.onMain = func(fn func()) { fn() } + w := &hostWatch{host: hosts.Host{ID: "server", Name: "server"}} + w.cl = dial(func(e proto.Event) { + if e.Name == proto.EventCanvasRender { + app.watchEvent(w, e) + } + }) + if err := w.cl.ServeCanvasRenders(); err != nil { + t.Fatal(err) + } + caller := dial(nil) + cv, err := caller.CreateCanvas(proto.CanvasCreateParams{Title: "Page", Kind: proto.CanvasMockup}) + if err != nil { + t.Fatal(err) + } + cv, err = caller.EditCanvas(proto.CanvasEdit{ID: cv.ID, Op: proto.CanvasOpHTMLPatch, HTML: `

hi

`}) + if err != nil { + t.Fatal(err) + } + _, err = caller.RenderCanvas(proto.CanvasRenderParams{ID: cv.ID, Frame: cv.Frames[0].ID, Format: proto.RenderPNG}) + if err == nil || !strings.Contains(err.Error(), "no web view") { + t.Fatalf("the app's answer: %v", err) + } +} diff --git a/ui/canvas_web.go b/ui/canvas_web.go index 335412da..e4e4edab 100644 --- a/ui/canvas_web.go +++ b/ui/canvas_web.go @@ -8,6 +8,7 @@ import ( "github.com/egoist/mygo" "github.com/egoist/mygo/ui" + "github.com/enowx/enowx/client" "github.com/enowx/enowx/core/canvas" "github.com/enowx/enowx/core/diag" "github.com/enowx/enowx/proto" @@ -70,9 +71,9 @@ const motionPaint = 100 * time.Millisecond // renders runs one render at a time. var renders sync.Mutex -// renderCanvas renders req's page and replies to the daemon. -func (a *App) renderCanvas(req proto.CanvasRenderRequest) { - cl, win := a.client, a.win +// renderCanvas renders req's page and replies to the daemon that asked, through cl. +func (a *App) renderCanvas(cl *client.Client, req proto.CanvasRenderRequest) { + win := a.win if cl == nil { return } diff --git a/ui/watch.go b/ui/watch.go index 171d68f5..035ecb22 100644 --- a/ui/watch.go +++ b/ui/watch.go @@ -136,7 +136,8 @@ func (a *App) watch(w *hostWatch) { a.onMain(func() { w.cl = cl a.syncSoon() - a.vaultConnected(cl) // the vault answers its requests too + a.vaultConnected(cl) // the vault answers its requests too + go func() { _ = cl.ServeCanvasRenders() }() // an agent there sees its canvases too }) go func() { _ = cl.ServeApp() }() // what agents there ask across hosts comes here a.poll(w, cl) @@ -240,6 +241,13 @@ func (a *App) watchEvent(w *hostWatch, ev proto.Event) { } return } + if ev.Name == proto.EventCanvasRender { + var req proto.CanvasRenderRequest + if json.Unmarshal(ev.Data, &req) == nil { + a.onMain(func() { a.renderCanvas(w.cl, req) }) + } + return + } if ev.Name != proto.EventSessionUpdated { return }