1// ===== main.go =====
  2//go:build js && wasm
  3
  4// Package main wasm/desk/main.go — the site as a desktop. The page opens
  5// with the tabbed terminal behind and the netscrape browser in front: the
  6// browser's first tab shows this origin as a real page (direct mode), and a
  7// second tab joins on the tab's own virtual loopback once the `serve`
  8// command — already running in the terminal's first tab — brings the site
  9// up there. The plain site window and the single-shell store window stay in
 10// the launcher.
 11//
 12// One discipline holds this page together: after the first terminal mounts,
 13// nothing here writes to the console. The websh console capture mirrors
 14// console lines into the shell, so a chatty desk would echo through its own
 15// terminal; boot-time messages happen before the first Mount or not at all.
 16//
 17// Served by the /desk page; compiled at server startup as a WASMSRC drop-in
 18// ('wasm/desk').
 19package main
 20
 21import (
 22	"fmt"
 23	"strings"
 24	"syscall/js"
 25	"time"
 26
 27	"github.com/0magnet/desk"
 28	"github.com/0magnet/netscrape"
 29	"github.com/0magnet/winbox-go/jsapi"
 30
 31	"github.com/0magnet/m2/pkg/storepane"
 32)
 33
 34// wasmName is stamped by the server's wasm build (-X main.wasmName=…) so log
 35// lines identify which binary they came from.
 36var wasmName string
 37
 38// sitePane is the website itself, in a frame. The desk hands a pane an
 39// element and expects it to fill it; an iframe is the honest way to put a
 40// page inside a window, and it keeps the site's own wasm — the globe on the
 41// front page — running in its own document rather than in this one.
 42type sitePane struct {
 43	path  string
 44	frame js.Value
 45}
 46
 47func (s *sitePane) Mount(el js.Value) error {
 48	f := js.Global().Get("document").Call("createElement", "iframe")
 49	f.Set("src", s.path)
 50	st := f.Get("style")
 51	st.Set("width", "100%")
 52	st.Set("height", "100%")
 53	st.Set("border", "0")
 54	st.Set("display", "block")
 55	st.Set("background", "#000")
 56	el.Call("appendChild", f)
 57	s.frame = f
 58	return nil
 59}
 60
 61func (s *sitePane) Close() {
 62	if s.frame.Truthy() {
 63		s.frame.Call("remove")
 64		s.frame = js.Undefined()
 65	}
 66}
 67
 68// termTabs is the terminal window's body: a tab strip over any number of
 69// shell sessions, each a storepane.Pane. The first tab opens running `serve`
 70// — the desk boots with the vnet site already coming up, its access log
 71// scrolling in plain sight — and the + button adds ordinary shells. Tabs
 72// mount lazily on first activation so a terminal never measures its font
 73// inside a hidden element.
 74type termTabs struct {
 75	store *storepane.Store
 76	wrap  js.Value
 77	strip js.Value
 78	body  js.Value
 79	plus  js.Value
 80	tabs  []*termTab
 81	n     int
 82}
 83
 84type termTab struct {
 85	btn     js.Value
 86	lbl     js.Value
 87	holder  js.Value
 88	pane    *storepane.Pane
 89	mounted bool
 90}
 91
 92func (t *termTabs) Mount(el js.Value) error {
 93	doc := js.Global().Get("document")
 94	t.wrap = doc.Call("createElement", "div")
 95	t.wrap.Get("style").Set("cssText", "position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;background:#000")
 96	t.strip = doc.Call("createElement", "div")
 97	t.strip.Get("style").Set("cssText", "display:flex;gap:2px;align-items:stretch;background:#100d18;border-bottom:1px solid #2a2342;padding:3px 3px 0;min-height:25px;overflow-x:auto;overflow-y:hidden")
 98	t.body = doc.Call("createElement", "div")
 99	t.body.Get("style").Set("cssText", "position:relative;flex:1;min-height:0")
100	t.plus = doc.Call("createElement", "div")
101	t.plus.Set("textContent", "+")
102	t.plus.Set("title", "new shell")
103	t.plus.Get("style").Set("cssText", "display:flex;align-items:center;padding:.2em .55em;cursor:pointer;color:#9aa0a6;font:13px monospace;user-select:none")
104	t.plus.Set("onclick", js.FuncOf(func(js.Value, []js.Value) interface{} {
105		t.n++
106		t.addTab(fmt.Sprintf("shell %d", t.n), "", true)
107		return nil
108	}))
109	t.strip.Call("appendChild", t.plus)
110	t.wrap.Call("appendChild", t.strip)
111	t.wrap.Call("appendChild", t.body)
112	el.Call("appendChild", t.wrap)
113
114	// The server first and active — the whole point of this window — and a
115	// plain shell ready behind it.
116	t.addTab("server", "serve", true)
117	t.addTab("shell", "", false)
118	return nil
119}
120
121func (t *termTabs) addTab(label, initCmd string, activate bool) {
122	doc := js.Global().Get("document")
123	tab := &termTab{}
124	tab.btn = doc.Call("createElement", "div")
125	tab.btn.Get("style").Set("cssText", "display:flex;align-items:center;gap:.4em;max-width:12em;padding:.25em .6em;cursor:pointer;font:11px monospace;border:1px solid #2a2342;border-bottom:0;border-radius:5px 5px 0 0;user-select:none;white-space:nowrap;color:#9aa0a6")
126	tab.lbl = doc.Call("createElement", "span")
127	tab.lbl.Set("textContent", label)
128	tab.btn.Call("appendChild", tab.lbl)
129	tab.holder = doc.Call("createElement", "div")
130	tab.holder.Get("style").Set("cssText", "position:absolute;inset:0;display:none")
131	if initCmd != "" {
132		tab.pane = t.store.PaneCmd(initCmd)
133	} else {
134		tab.pane = t.store.Pane()
135	}
136	i := len(t.tabs)
137	tab.btn.Set("onclick", js.FuncOf(func(js.Value, []js.Value) interface{} {
138		t.activate(i)
139		return nil
140	}))
141	t.strip.Call("insertBefore", tab.btn, t.plus)
142	t.body.Call("appendChild", tab.holder)
143	t.tabs = append(t.tabs, tab)
144	if activate {
145		t.activate(i)
146	}
147}
148
149func (t *termTabs) activate(i int) {
150	for j, tab := range t.tabs {
151		if j == i {
152			tab.holder.Get("style").Set("display", "block")
153			tab.btn.Get("style").Set("background", "#2a2342")
154			tab.btn.Get("style").Set("color", "#fff")
155		} else {
156			tab.holder.Get("style").Set("display", "none")
157			tab.btn.Get("style").Set("background", "transparent")
158			tab.btn.Get("style").Set("color", "#9aa0a6")
159		}
160	}
161	// Mount on first activation, while the holder is visible, so the
162	// terminal measures a real cell. Mount errors land in the holder itself
163	// rather than the console — see the package comment.
164	tab := t.tabs[i]
165	if !tab.mounted {
166		tab.mounted = true
167		if err := tab.pane.Mount(tab.holder); err != nil {
168			tab.holder.Set("textContent", "terminal failed: "+err.Error())
169		}
170	}
171}
172
173func (t *termTabs) Close() {
174	for _, tab := range t.tabs {
175		if tab.mounted {
176			tab.pane.Close()
177		}
178	}
179	t.tabs = nil
180	if t.wrap.Truthy() {
181		t.wrap.Call("remove")
182		t.wrap = js.Undefined()
183	}
184}
185
186// browserPane is netscrape in a desk window.
187//
188// netscrape used to be a JS engine served at /netscrape.js and driven through
189// globalThis.SkywireBrowse.createWindow. It is now a Go browser, so the desk
190// imports it and calls it directly: one wasm binary, one Go runtime, and the
191// window comes from the same desk that owns every other window here rather
192// than from the browser reaching back to build its own.
193type browserPane struct {
194	el     js.Value
195	origin string
196}
197
198func (p *browserPane) Mount(el js.Value) error {
199	// netscrape fills its mount element absolutely — right for a page, wrong
200	// for a window: handed the window's own body it escapes the box and lands
201	// over the title bar. It gets a child to fill instead, inside a wrapper
202	// that provides the containing block. The body itself must not be made
203	// position:relative — its only child would then be absolutely positioned,
204	// contribute no height, and the window would measure 0 tall.
205	doc := js.Global().Get("document")
206	box := doc.Call("createElement", "div")
207	box.Get("style").Set("cssText", "position:relative;width:100%;height:100%;overflow:hidden")
208	inner := doc.Call("createElement", "div")
209	box.Call("appendChild", inner)
210	el.Call("appendChild", box)
211	p.el = box
212
213	// Where the first tab starts, said BEFORE Open: Open finishes by opening a
214	// tab itself, so anything set afterwards in the same turn is replaced a
215	// line later. This origin is a page the browser can simply fetch, which is
216	// what the old directOrigins list amounted to.
217	js.Global().Set("__netscrapeStart", p.origin)
218	netscrape.Open(inner)
219	return nil
220}
221
222func (p *browserPane) Close() {
223	if p.el.Truthy() {
224		p.el.Set("innerHTML", "")
225	}
226}
227
228// openBrowser opens the netscrape tabbed browser: the first tab straight
229// onto this origin as a real page (the browser being a browser for an origin
230// it already speaks), and a background tab onto the vnet loopback site once
231// the in-tab server answers there.
232func openBrowser(origin string) { //nolint:unparam
233	if js.Global().Get("location").Get("search").String() != "" &&
234		strings.Contains(js.Global().Get("location").Get("search").String(), "nonet") {
235		return // ?nonet: the desk without the browser, for debugging the rest
236	}
237	if _, err := desk.Launch("browser"); err != nil {
238		js.Global().Get("console").Call("error", err.Error())
239		return
240	}
241
242	// The vnet tab waits for the server the terminal window is starting; a
243	// tab opened onto a dead port would greet the visitor with an error.
244	go func() {
245		vnet := js.Global().Get("vnet")
246		if !vnet.Truthy() {
247			return
248		}
249		for i := 0; i < 120; i++ {
250			ok := make(chan bool, 1)
251			p := vnet.Call("httpFetch", 8080, "GET", "/", js.Null())
252			then := js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
253				st := 0
254				if len(args) > 0 && args[0].Truthy() {
255					st = args[0].Get("status").Int()
256				}
257				ok <- st >= 200 && st < 500
258				return nil
259			})
260			catch := js.FuncOf(func(js.Value, []js.Value) interface{} {
261				ok <- false
262				return nil
263			})
264			p.Call("then", then).Call("catch", catch)
265			up := <-ok
266			then.Release()
267			catch.Release()
268			if up {
269				// Background: the visitor stays on the tab they are looking
270				// at, and finds the vnet site already loaded behind it.
271				netscrape.NewTab("http://vnet:8080/", true)
272				return
273			}
274			time.Sleep(time.Second)
275		}
276	}()
277}
278
279func main() {
280	storepane.SetLogName(wasmName)
281
282	ready := make(chan struct{})
283	document := js.Global().Get("document")
284	if rs := document.Get("readyState").String(); rs == "interactive" || rs == "complete" {
285		close(ready)
286	} else {
287		cb := js.FuncOf(func(js.Value, []js.Value) interface{} {
288			close(ready)
289			return nil
290		})
291		defer cb.Release()
292		document.Call("addEventListener", "DOMContentLoaded", cb)
293	}
294	<-ready
295
296	// The terminal windows measure their cell from this, and they can be
297	// opened at any point, so the face has to be there before the desk is.
298	storepane.WaitForFont(document)
299
300	if el := document.Call("getElementById", "desktop"); el.Truthy() {
301		desk.SetRoot(el)
302	}
303
304	// One catalog for every store window that gets opened. This fetches, so
305	// it happens here, on a goroutine, and never inside a launcher click.
306	store := storepane.New()
307	domain := store.Domain()
308
309	desk.Register(desk.App{
310		Name:      "site",
311		Title:     domain,
312		Help:      "the website",
313		Maximized: true,
314		Width:     1100,
315		Height:    720,
316		Open: func(args []string) (desk.Pane, error) {
317			path := "/"
318			if len(args) > 0 && args[0] != "" {
319				path = args[0]
320			}
321			return &sitePane{path: path}, nil
322		},
323	})
324
325	desk.Register(desk.App{
326		Name:   "store",
327		Title:  domain + " — terminal",
328		Help:   "the storefront as a terminal",
329		Width:  900,
330		Height: 560,
331		Open: func([]string) (desk.Pane, error) {
332			// A window of its own gets a shell of its own; the catalog and
333			// the site identity are already fetched and are shared. This
334			// must not fetch: it is called from the launcher's click, and
335			// a fetch there waits on the event loop that is waiting on it.
336			return store.Pane(), nil
337		},
338	})
339
340	desk.Register(desk.App{
341		Name:   "terminal",
342		Title:  domain + " — terminal",
343		Help:   "tabbed shells; the first runs the vnet site server",
344		Width:  980,
345		Height: 600,
346		Open: func([]string) (desk.Pane, error) {
347			return &termTabs{store: store}, nil
348		},
349	})
350
351	desk.Register(desk.App{
352		Name:   "browser",
353		Title:  domain + " — browser",
354		Help:   "netscrape: this origin, and the vnet site once it answers",
355		Width:  1000,
356		Height: 620,
357		Open: func([]string) (desk.Pane, error) {
358			return &browserPane{origin: store.Origin() + "/"}, nil
359		},
360	})
361
362	desk.NewPanel()
363
364	// netscrape's windows ride the same winbox this desk's own windows use;
365	// installing the JS constructor is what lets its createWindow join in.
366	jsapi.InstallGlobal()
367
368	// The desk opens as the conversation piece: the tabbed terminal behind,
369	// its first tab already starting the site's own server on the vnet
370	// loopback — and the browser in front, tab one on this origin as a real
371	// page, tab two joining once the vnet site answers.
372	if _, err := desk.Launch("terminal"); err != nil {
373		js.Global().Get("console").Call("error", err.Error())
374	}
375	openBrowser(store.Origin())
376	select {}
377}
378
379