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