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
4 changes: 4 additions & 0 deletions client/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion core/canvas/raster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 38 additions & 7 deletions daemon/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"image"
"image/png"
"slices"
"strings"
"sync"
"time"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions daemon/canvas_render_watch_test.go
Original file line number Diff line number Diff line change
@@ -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: `<p id="a">hi</p>`})
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())
})
}
4 changes: 4 additions & 0 deletions daemon/rpc_canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions proto/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions proto/testdata/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions ui/canvas_watch_test.go
Original file line number Diff line number Diff line change
@@ -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: `<p id="a">hi</p>`})
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)
}
}
7 changes: 4 additions & 3 deletions ui/canvas_web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 9 additions & 1 deletion ui/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
Loading