diff --git a/.gitattributes b/.gitattributes index 212566614..94f480de9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -* text=auto \ No newline at end of file +* text=auto eol=lf \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 70667d90b..ead74e104 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -159,7 +159,7 @@ tasks: vars: GOOS: darwin GOARCH: arm64 - - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/.waveterm-dev/bin/wsh + - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh build:wsh:internal: vars: @@ -185,8 +185,8 @@ tasks: generate: desc: Generate Typescript bindings for the Go backend. cmds: - - go run cmd/generatets/main-generatets.go - - go run cmd/generatego/main-generatego.go + - NO_PANIC=1 go run cmd/generatets/main-generatets.go + - NO_PANIC=1 go run cmd/generatego/main-generatego.go sources: - "cmd/generatego/*.go" - "cmd/generatets/*.go" diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index a409b7e31..a656f0cb6 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -29,6 +29,7 @@ func GenerateWshClient() error { "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", + "github.com/wavetermdev/waveterm/pkg/vdom", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 6f149ff0c..07c2a66d3 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -159,11 +159,11 @@ func shutdownActivityUpdate() { func createMainWshClient() { rpc := wshserver.GetMainRpcClient() - wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc) + wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc, true) wps.Broker.SetClient(wshutil.DefaultRouter) localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{}) go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) - wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh, true) } func main() { @@ -182,7 +182,7 @@ func main() { log.Printf("error validating service map: %v\n", err) return } - err = wavebase.EnsureWaveHomeDir() + err = wavebase.EnsureWaveDataDir() if err != nil { log.Printf("error ensuring wave home dir: %v\n", err) return @@ -197,6 +197,13 @@ func main() { log.Printf("error ensuring wave config dir: %v\n", err) return } + + // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save + err = wavebase.EnsureWavePresetsDir() + if err != nil { + log.Printf("error ensuring wave presets dir: %v\n", err) + return + } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) @@ -209,7 +216,8 @@ func main() { } }() log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) - log.Printf("wave home dir: %s\n", wavebase.GetWaveHomeDir()) + log.Printf("wave data dir: %s\n", wavebase.GetWaveDataDir()) + log.Printf("wave config dir: %s\n", wavebase.GetWaveConfigDir()) err = filestore.InitFilestore() if err != nil { log.Printf("error initializing filestore: %v\n", err) diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go index 10d1933fe..aaac013d7 100644 --- a/cmd/test/test-main.go +++ b/cmd/test/test-main.go @@ -14,7 +14,7 @@ import ( func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := vdom.UseState(ctx, false) - var clickedDiv *vdom.Elem + var clickedDiv *vdom.VDomElem if clicked { clickedDiv = vdom.Bind(`
clicked
`, nil) } @@ -35,7 +35,7 @@ func Page(ctx context.Context, props map[string]any) any { } func Button(ctx context.Context, props map[string]any) any { - ref := vdom.UseRef(ctx, nil) + ref := vdom.UseVDomRef(ctx) clName, setClName := vdom.UseState(ctx, "button") vdom.UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go index c7f991056..73d3ee565 100644 --- a/cmd/wsh/cmd/wshcmd-conn.go +++ b/cmd/wsh/cmd/wshcmd-conn.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/remote" @@ -25,17 +26,24 @@ func init() { } func connStatus() error { - resp, err := wshclient.ConnStatusCommand(RpcClient, nil) + var allResp []wshrpc.ConnStatus + sshResp, err := wshclient.ConnStatusCommand(RpcClient, nil) if err != nil { - return fmt.Errorf("getting connection status: %w", err) + return fmt.Errorf("getting ssh connection status: %w", err) } - if len(resp) == 0 { + allResp = append(allResp, sshResp...) + wslResp, err := wshclient.WslStatusCommand(RpcClient, nil) + if err != nil { + return fmt.Errorf("getting wsl connection status: %w", err) + } + allResp = append(allResp, wslResp...) + if len(allResp) == 0 { WriteStdout("no connections\n") return nil } WriteStdout("%-30s %-12s\n", "connection", "status") WriteStdout("----------------------------------------------\n") - for _, conn := range resp { + for _, conn := range allResp { str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) if conn.Error != "" { str += fmt.Sprintf(" (%s)", conn.Error) @@ -110,7 +118,7 @@ func connRun(cmd *cobra.Command, args []string) error { } connName = args[1] _, err := remote.ParseOpts(connName) - if err != nil { + if err != nil && !strings.HasPrefix(connName, "wsl://") { return fmt.Errorf("cannot parse connection name: %w", err) } } diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index cc00a694e..4f82c1067 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -4,29 +4,186 @@ package cmd import ( + "encoding/json" + "fmt" + "io" + "log" + "net" "os" + "sync/atomic" + "time" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/packetparser" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" + "github.com/wavetermdev/waveterm/pkg/wshutil" ) var serverCmd = &cobra.Command{ - Use: "connserver", - Hidden: true, - Short: "remote server to power wave blocks", - Args: cobra.NoArgs, - Run: serverRun, - PreRunE: preRunSetupRpcClient, + Use: "connserver", + Hidden: true, + Short: "remote server to power wave blocks", + Args: cobra.NoArgs, + RunE: serverRun, } +var connServerRouter bool + func init() { + serverCmd.Flags().BoolVar(&connServerRouter, "router", false, "run in local router mode") rootCmd.AddCommand(serverCmd) } -func serverRun(cmd *cobra.Command, args []string) { +func MakeRemoteUnixListener() (net.Listener, error) { + serverAddr := wavebase.GetRemoteDomainSocketName() + os.Remove(serverAddr) // ignore error + rtn, err := net.Listen("unix", serverAddr) + if err != nil { + return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) + } + os.Chmod(serverAddr, 0700) + log.Printf("Server [unix-domain] listening on %s\n", serverAddr) + return rtn, nil +} + +func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) { + var routeIdContainer atomic.Pointer[string] + proxy := wshutil.MakeRpcProxy() + go func() { + writeErr := wshutil.AdaptOutputChToStream(proxy.ToRemoteCh, conn) + if writeErr != nil { + log.Printf("error writing to domain socket: %v\n", writeErr) + } + }() + go func() { + // when input is closed, close the connection + defer func() { + conn.Close() + routeIdPtr := routeIdContainer.Load() + if routeIdPtr != nil && *routeIdPtr != "" { + router.UnregisterRoute(*routeIdPtr) + disposeMsg := &wshutil.RpcMessage{ + Command: wshrpc.Command_Dispose, + Data: wshrpc.CommandDisposeData{ + RouteId: *routeIdPtr, + }, + Source: *routeIdPtr, + AuthToken: proxy.GetAuthToken(), + } + disposeBytes, _ := json.Marshal(disposeMsg) + router.InjectMessage(disposeBytes, *routeIdPtr) + } + }() + wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh) + }() + routeId, err := proxy.HandleClientProxyAuth(router) + if err != nil { + log.Printf("error handling client proxy auth: %v\n", err) + conn.Close() + return + } + router.RegisterRoute(routeId, proxy, false) + routeIdContainer.Store(&routeId) +} + +func runListener(listener net.Listener, router *wshutil.WshRouter) { + defer func() { + log.Printf("listener closed, exiting\n") + time.Sleep(500 * time.Millisecond) + wshutil.DoShutdown("", 1, true) + }() + for { + conn, err := listener.Accept() + if err == io.EOF { + break + } + if err != nil { + log.Printf("error accepting connection: %v\n", err) + continue + } + go handleNewListenerConn(conn, router) + } +} + +func setupConnServerRpcClientWithRouter(router *wshutil.WshRouter) (*wshutil.WshRpc, error) { + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return nil, fmt.Errorf("no jwt token found for connserver") + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + authRtn, err := router.HandleProxyAuth(jwtToken) + if err != nil { + return nil, fmt.Errorf("error handling proxy auth: %v", err) + } + inputCh := make(chan []byte, wshutil.DefaultInputChSize) + outputCh := make(chan []byte, wshutil.DefaultOutputChSize) + connServerClient := wshutil.MakeWshRpc(inputCh, outputCh, *rpcCtx, &wshremote.ServerImpl{LogWriter: os.Stdout}) + connServerClient.SetAuthToken(authRtn.AuthToken) + router.RegisterRoute(authRtn.RouteId, connServerClient, false) + wshclient.RouteAnnounceCommand(connServerClient, nil) + return connServerClient, nil +} + +func serverRunRouter() error { + router := wshutil.NewWshRouter() + termProxy := wshutil.MakeRpcProxy() + rawCh := make(chan []byte, wshutil.DefaultOutputChSize) + go packetparser.Parse(os.Stdin, termProxy.FromRemoteCh, rawCh) + go func() { + for msg := range termProxy.ToRemoteCh { + packetparser.WritePacket(os.Stdout, msg) + } + }() + go func() { + // just ignore and drain the rawCh (stdin) + // when stdin is closed, shutdown + defer wshutil.DoShutdown("", 0, true) + for range rawCh { + // ignore + } + }() + go func() { + for msg := range termProxy.FromRemoteCh { + // send this to the router + router.InjectMessage(msg, wshutil.UpstreamRoute) + } + }() + router.SetUpstreamClient(termProxy) + // now set up the domain socket + unixListener, err := MakeRemoteUnixListener() + if err != nil { + return fmt.Errorf("cannot create unix listener: %v", err) + } + client, err := setupConnServerRpcClientWithRouter(router) + if err != nil { + return fmt.Errorf("error setting up connserver rpc client: %v", err) + } + go runListener(unixListener, router) + // run the sysinfo loop + wshremote.RunSysInfoLoop(client, client.GetRpcContext().Conn) + select {} +} + +func serverRunNormal() error { + err := setupRpcClient(&wshremote.ServerImpl{LogWriter: os.Stdout}) + if err != nil { + return err + } WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) - RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout}) - select {} // run forever } + +func serverRun(cmd *cobra.Command, args []string) error { + if connServerRouter { + return serverRunRouter() + } else { + return serverRunNormal() + } +} diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go new file mode 100644 index 000000000..48379d3e4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -0,0 +1,47 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "debug commands", + PersistentPreRunE: preRunSetupRpcClient, + Hidden: true, +} + +var debugBlockIdsCmd = &cobra.Command{ + Use: "block", + Short: "list sub-blockids for block", + RunE: debugBlockIdsRun, + Hidden: true, +} + +func init() { + debugCmd.AddCommand(debugBlockIdsCmd) + rootCmd.AddCommand(debugCmd) +} + +func debugBlockIdsRun(cmd *cobra.Command, args []string) error { + oref, err := resolveBlockArg() + if err != nil { + return err + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return err + } + barr, err := json.MarshalIndent(blockInfo, "", " ") + if err != nil { + return err + } + WriteStdout("%s\n", string(barr)) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go index 0a32af9fd..16a346591 100644 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) { return } doneCh := make(chan bool) - RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { if event.HasScope(blockRef.String()) { close(doneCh) } }) - wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil) + wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) <-doneCh } diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 6bffc992e..6c8345128 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -4,40 +4,191 @@ package cmd import ( - "fmt" + "context" + "log" + "time" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) +var htmlCmdNewBlock bool +var GlobalVDomClient *vdomclient.Client + func init() { + htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block") rootCmd.AddCommand(htmlCmd) } var htmlCmd = &cobra.Command{ - Use: "html", - Hidden: true, - Short: "Launch a demo html-mode terminal", - Run: htmlRun, - PreRunE: preRunSetupRpcClient, + Use: "html", + Hidden: true, + Short: "launch demo vdom application", + RunE: htmlRun, } -func htmlRun(cmd *cobra.Command, args []string) { - defer wshutil.DoShutdown("normal exit", 0, true) - setTermHtmlMode() - for { - var buf [1]byte - _, err := WrappedStdin.Read(buf[:]) +func StyleTag(ctx context.Context, props map[string]any) any { + return vdom.Bind(` + + `, nil) +} + +func BgItemTag(ctx context.Context, props map[string]any) any { + clickFn := func() { + log.Printf("bg item clicked %q\n", props["bg"]) + blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil) if err != nil { - wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) + log.Printf("error getting block info: %v\n", err) + return } - if buf[0] == 0x03 { - wshutil.DoShutdown("read Ctrl-C from stdin", 1, true) - break - } - if buf[0] == 'x' { - wshutil.DoShutdown("read 'x' from stdin", 0, true) - break + log.Printf("block info: tabid=%q\n", blockInfo.TabId) + err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{ + ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId}, + Meta: map[string]any{"bg": props["bg"]}, + }, nil) + if err != nil { + log.Printf("error setting meta: %v\n", err) } + // wshclient.SetMetaCommand(GlobalVDomClient.RpcClient) + } + params := map[string]any{ + "bg": props["bg"], + "label": props["label"], + "clickHandler": clickFn, + } + return vdom.Bind(` +
+
+
+
`, params) +} + +func AllBgItemsTag(ctx context.Context, props map[string]any) any { + items := []map[string]any{ + {"bg": nil, "label": "default"}, + {"bg": "#ff0000", "label": "red"}, + {"bg": "#00ff00", "label": "green"}, + {"bg": "#0000ff", "label": "blue"}, + } + bgElems := make([]*vdom.VDomElem, 0) + for _, item := range items { + elem := vdom.E("BgItemTag", item) + bgElems = append(bgElems, elem) + } + return vdom.Bind(` +
+
+ +
+
+ `, map[string]any{"bgElems": bgElems}) +} + +func MakeVDom() *vdom.VDomElem { + vdomStr := ` +
+ +

Set Background

+
+ +
+
+ +
+
+ ` + elem := vdom.Bind(vdomStr, nil) + return elem +} + +func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { + if event.EventType == "clickinc" { + client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) + return } } + +func htmlRun(cmd *cobra.Command, args []string) error { + WriteStderr("running wsh html %q\n", RpcContext.BlockId) + + client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true}) + if err != nil { + return err + } + GlobalVDomClient = client + client.SetGlobalEventHandler(GlobalEventHandler) + log.Printf("created client: %v\n", client) + client.RegisterComponent("StyleTag", StyleTag) + client.RegisterComponent("BgItemTag", BgItemTag) + client.RegisterComponent("AllBgItemsTag", AllBgItemsTag) + client.SetRootElem(MakeVDom()) + err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) + if err != nil { + return err + } + log.Printf("created context\n") + go func() { + <-client.DoneCh + wshutil.DoShutdown("vdom closed by FE", 0, true) + }() + log.Printf("created vdom context\n") + go func() { + time.Sleep(5 * time.Second) + log.Printf("updating text\n") + client.SetAtomVal("text", "updated text") + err := client.SendAsyncInitiation() + if err != nil { + log.Printf("error sending async initiation: %v\n", err) + } + }() + <-client.DoneCh + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 77cc2ee30..6f29f65a5 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -71,6 +71,22 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { return nil } +func resolveBlockArg() (*waveobj.ORef, error) { + oref := blockArg + if oref == "" { + return nil, fmt.Errorf("blockid is required") + } + err := validateEasyORef(oref) + if err != nil { + return nil, err + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + return nil, fmt.Errorf("resolving blockid: %w", err) + } + return fullORef, nil +} + // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) func setupRpcClient(serverImpl wshutil.ServerImpl) error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) @@ -101,7 +117,7 @@ func setupRpcClient(serverImpl wshutil.ServerImpl) error { func setTermHtmlMode() { wshutil.SetExtraShutdownFunc(extraShutdownFn) cmd := &wshrpc.CommandSetMetaData{ - Meta: map[string]any{"term:mode": "html"}, + Meta: map[string]any{"term:mode": "vdom"}, } err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index bd2985931..775a0cfef 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -28,9 +28,9 @@ var webOpenCmd = &cobra.Command{ } var webGetCmd = &cobra.Command{ - Use: "get [--inner] [--all] [--json] blockid css-selector", + Use: "get [--inner] [--all] [--json] css-selector", Short: "get the html for a css selector", - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(1), Hidden: true, RunE: webGetRun, } @@ -51,7 +51,7 @@ func init() { } func webGetRun(cmd *cobra.Command, args []string) error { - oref := args[0] + oref := blockArg if oref == "" { return fmt.Errorf("blockid not specified") } @@ -67,14 +67,14 @@ func webGetRun(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("getting block info: %w", err) } - if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { return fmt.Errorf("block %s is not a web block", fullORef.OID) } data := wshrpc.CommandWebSelectorData{ WindowId: blockInfo.WindowId, BlockId: fullORef.OID, TabId: blockInfo.TabId, - Selector: args[1], + Selector: args[0], Opts: &wshrpc.WebSelectorOpts{ Inner: webGetInner, All: webGetAll, diff --git a/cmd/wsh/cmd/wshcmd-wsl.go b/cmd/wsh/cmd/wshcmd-wsl.go new file mode 100644 index 000000000..bad95ba21 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-wsl.go @@ -0,0 +1,60 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var distroName string + +var wslCmd = &cobra.Command{ + Use: "wsl [-d ]", + Short: "connect this terminal to a local wsl connection", + Args: cobra.NoArgs, + Run: wslRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + wslCmd.Flags().StringVarP(&distroName, "distribution", "d", "", "Run the specified distribution") + rootCmd.AddCommand(wslCmd) +} + +func wslRun(cmd *cobra.Command, args []string) { + var err error + if distroName == "" { + // get default distro from the host + distroName, err = wshclient.WslDefaultDistroCommand(RpcClient, nil) + if err != nil { + WriteStderr("[error] %s\n", err) + return + } + } + if !strings.HasPrefix(distroName, "wsl://") { + distroName = "wsl://" + distroName + } + blockId := RpcContext.BlockId + if blockId == "" { + WriteStderr("[error] cannot determine blockid (not in JWT)\n") + return + } + data := wshrpc.CommandSetMetaData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Meta: map[string]any{ + waveobj.MetaKey_Connection: distroName, + }, + } + err = wshclient.SetMetaCommand(RpcClient, data, nil) + if err != nil { + WriteStderr("[error] setting switching connection: %v\n", err) + return + } + WriteStderr("switched connection to %q\n", distroName) +} diff --git a/db/migrations-wstore/000005_blockparent.down.sql b/db/migrations-wstore/000005_blockparent.down.sql new file mode 100644 index 000000000..5aed013ca --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.down.sql @@ -0,0 +1 @@ +-- we don't need to remove parentoref \ No newline at end of file diff --git a/db/migrations-wstore/000005_blockparent.up.sql b/db/migrations-wstore/000005_blockparent.up.sql new file mode 100644 index 000000000..f81864ff8 --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.up.sql @@ -0,0 +1,4 @@ +UPDATE db_block +SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) +FROM db_tab +WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); diff --git a/emain/docsite.ts b/emain/docsite.ts index ddf9d21b8..37818b954 100644 --- a/emain/docsite.ts +++ b/emain/docsite.ts @@ -1,6 +1,6 @@ -import { getWebServerEndpoint } from "@/util/endpoints"; -import { fetch } from "@/util/fetchutil"; import { ipcMain } from "electron"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; +import { fetch } from "../frontend/util/fetchutil"; const docsiteWebUrl = "https://docs.waveterm.dev/"; let docsiteUrl: string; diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts new file mode 100644 index 000000000..fab7425d9 --- /dev/null +++ b/emain/emain-activity.ts @@ -0,0 +1,54 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// for activity updates +let wasActive = true; +let wasInFg = true; +let globalIsQuitting = false; +let globalIsStarting = true; +let globalIsRelaunching = false; +let forceQuit = false; + +export function setWasActive(val: boolean) { + wasActive = val; +} + +export function setWasInFg(val: boolean) { + wasInFg = val; +} + +export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { + return { wasActive, wasInFg }; +} + +export function setGlobalIsQuitting(val: boolean) { + globalIsQuitting = val; +} + +export function getGlobalIsQuitting(): boolean { + return globalIsQuitting; +} + +export function setGlobalIsStarting(val: boolean) { + globalIsStarting = val; +} + +export function getGlobalIsStarting(): boolean { + return globalIsStarting; +} + +export function setGlobalIsRelaunching(val: boolean) { + globalIsRelaunching = val; +} + +export function getGlobalIsRelaunching(): boolean { + return globalIsRelaunching; +} + +export function setForceQuit(val: boolean) { + forceQuit = val; +} + +export function getForceQuit(): boolean { + return forceQuit; +} diff --git a/emain/emain-util.ts b/emain/emain-util.ts new file mode 100644 index 000000000..ffdffb370 --- /dev/null +++ b/emain/emain-util.ts @@ -0,0 +1,168 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; + +export const WaveAppPathVarName = "WAVETERM_APP_PATH"; + +export function delay(ms): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function setCtrlShift(wc: Electron.WebContents, state: boolean) { + wc.send("control-shift-state-update", state); +} + +export function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { + if (!focused) { + setCtrlShift(sender, false); + } +} + +export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { + if (waveEvent.type == "keyup") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift") { + setCtrlShift(sender, false); + } + if (waveEvent.key == "Meta") { + if (waveEvent.control && waveEvent.shift) { + setCtrlShift(sender, true); + } + } + return; + } + if (waveEvent.type == "keydown") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { + if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { + // Set the control and shift without the Meta key + setCtrlShift(sender, true); + } else { + // Unset if Meta is pressed + setCtrlShift(sender, false); + } + } + return; + } +} + +export function shNavHandler(event: Electron.Event, url: string) { + if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { + // this is a dev-mode hot-reload, ignore it + console.log("allowing hot-reload of index.html"); + return; + } + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +export function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + electron.shell.openExternal(url); + return; + } + if ( + event.frame.name == "pdfview" && + (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) + ) { + // allowed + return; + } + event.preventDefault(); + console.log("frame navigation canceled"); +} + +function isWindowFullyVisible(bounds: electron.Rectangle): boolean { + const displays = electron.screen.getAllDisplays(); + + // Helper function to check if a point is inside any display + function isPointInDisplay(x: number, y: number) { + for (const display of displays) { + const { x: dx, y: dy, width, height } = display.bounds; + if (x >= dx && x < dx + width && y >= dy && y < dy + height) { + return true; + } + } + return false; + } + + // Check all corners of the window + const topLeft = isPointInDisplay(bounds.x, bounds.y); + const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); + const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); + const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); + + return topLeft && topRight && bottomLeft && bottomRight; +} + +function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { + const displays = electron.screen.getAllDisplays(); + let maxArea = 0; + let bestDisplay = null; + + for (let display of displays) { + const { x, y, width, height } = display.bounds; + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea > maxArea) { + maxArea = overlapArea; + bestDisplay = display; + } + } + + return bestDisplay; +} + +function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { + const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; + let { x, y, width, height } = bounds; + + // Adjust width and height to fit within the display's work area + width = Math.min(width, dWidth); + height = Math.min(height, dHeight); + + // Adjust x to ensure the window fits within the display + if (x < dx) { + x = dx; + } else if (x + width > dx + dWidth) { + x = dx + dWidth - width; + } + + // Adjust y to ensure the window fits within the display + if (y < dy) { + y = dy; + } else if (y + height > dy + dHeight) { + y = dy + dHeight - height; + } + return { x, y, width, height }; +} + +export function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { + if (!isWindowFullyVisible(bounds)) { + let targetDisplay = findDisplayWithMostArea(bounds); + + if (!targetDisplay) { + targetDisplay = electron.screen.getPrimaryDisplay(); + } + + return adjustBoundsToFitDisplay(bounds, targetDisplay); + } + return bounds; +} diff --git a/emain/emain-viewmgr.ts b/emain/emain-viewmgr.ts new file mode 100644 index 000000000..7818e69ac --- /dev/null +++ b/emain/emain-viewmgr.ts @@ -0,0 +1,558 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import * as path from "path"; +import { debounce } from "throttle-debounce"; +import { ClientService, FileService, ObjectService, WindowService } from "../frontend/app/store/services"; +import * as keyutil from "../frontend/util/keyutil"; +import { configureAuthKeyRequestInjection } from "./authkey"; +import { getGlobalIsQuitting, getGlobalIsStarting, setWasActive, setWasInFg } from "./emain-activity"; +import { + delay, + ensureBoundsAreVisible, + handleCtrlShiftFocus, + handleCtrlShiftState, + shFrameNavHandler, + shNavHandler, +} from "./emain-util"; +import { getElectronAppBasePath, isDevVite } from "./platform"; +import { updater } from "./updater"; + +let MaxCacheSize = 10; +let HotSpareTab: WaveTabView = null; + +const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow +let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do) +const wcvCache = new Map(); +const wcIdToWaveTabMap = new Map(); + +export function setMaxTabCacheSize(size: number) { + console.log("setMaxTabCacheSize", size); + MaxCacheSize = size; +} + +function computeBgColor(fullConfig: FullConfigType): string { + const settings = fullConfig?.settings; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + return "#00000000"; + } else if (isBlur) { + return "#00000000"; + } else { + return "#222222"; + } +} + +function createBareTabView(fullConfig: FullConfigType): WaveTabView { + console.log("createBareTabView"); + const tabView = new electron.WebContentsView({ + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + }) as WaveTabView; + tabView.createdTs = Date.now(); + tabView.savedInitOpts = null; + tabView.initPromise = new Promise((resolve, _) => { + tabView.initResolve = resolve; + }); + tabView.initPromise.then(() => { + console.log("tabview init", Date.now() - tabView.createdTs + "ms"); + }); + tabView.waveReadyPromise = new Promise((resolve, _) => { + tabView.waveReadyResolve = resolve; + }); + wcIdToWaveTabMap.set(tabView.webContents.id, tabView); + if (isDevVite) { + tabView.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); + } else { + tabView.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); + } + tabView.webContents.on("destroyed", () => { + wcIdToWaveTabMap.delete(tabView.webContents.id); + removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); + }); + tabView.setBackgroundColor(computeBgColor(fullConfig)); + return tabView; +} + +function positionTabOffScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { + if (tabView == null) { + return; + } + tabView.setBounds({ + x: -10000, + y: -10000, + width: winBounds.width, + height: winBounds.height, + }); +} + +async function repositionTabsSlowly( + newTabView: WaveTabView, + oldTabView: WaveTabView, + delayMs: number, + winBounds: Electron.Rectangle +) { + if (newTabView == null) { + return; + } + newTabView.setBounds({ + x: winBounds.width - 10, + y: winBounds.height - 10, + width: winBounds.width, + height: winBounds.height, + }); + await delay(delayMs); + newTabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); + oldTabView?.setBounds({ + x: -10000, + y: -10000, + width: winBounds.width, + height: winBounds.height, + }); +} + +function positionTabOnScreen(tabView: WaveTabView, winBounds: Electron.Rectangle) { + if (tabView == null) { + return; + } + tabView.setBounds({ x: 0, y: 0, width: winBounds.width, height: winBounds.height }); +} + +export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { + return wcIdToWaveTabMap.get(webContentsId); +} + +export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { + const tabView = wcIdToWaveTabMap.get(webContentsId); + if (tabView == null) { + return null; + } + return waveWindowMap.get(tabView.waveWindowId); +} + +export function getWaveWindowById(windowId: string): WaveBrowserWindow { + return waveWindowMap.get(windowId); +} + +export function getAllWaveWindows(): WaveBrowserWindow[] { + return Array.from(waveWindowMap.values()); +} + +export function getFocusedWaveWindow(): WaveBrowserWindow { + return focusedWaveWindow; +} + +export function ensureHotSpareTab(fullConfig: FullConfigType) { + console.log("ensureHotSpareTab"); + if (HotSpareTab == null) { + HotSpareTab = createBareTabView(fullConfig); + } +} + +export function destroyWindow(waveWindow: WaveBrowserWindow) { + if (waveWindow == null) { + return; + } + for (const tabView of waveWindow.allTabViews.values()) { + destroyTab(tabView); + } + waveWindowMap.delete(waveWindow.waveWindowId); +} + +export function destroyTab(tabView: WaveTabView) { + if (tabView == null) { + return; + } + tabView.webContents.close(); + wcIdToWaveTabMap.delete(tabView.webContents.id); + removeWaveTabView(tabView.waveWindowId, tabView.waveTabId); + const waveWindow = waveWindowMap.get(tabView.waveWindowId); + if (waveWindow) { + waveWindow.allTabViews.delete(tabView.waveTabId); + } +} + +function getSpareTab(fullConfig: FullConfigType): WaveTabView { + setTimeout(ensureHotSpareTab, 500); + if (HotSpareTab != null) { + const rtn = HotSpareTab; + HotSpareTab = null; + console.log("getSpareTab: returning hotspare"); + return rtn; + } else { + console.log("getSpareTab: creating new tab"); + return createBareTabView(fullConfig); + } +} + +function getWaveTabView(waveWindowId: string, waveTabId: string): WaveTabView | undefined { + const cacheKey = waveWindowId + "|" + waveTabId; + const rtn = wcvCache.get(cacheKey); + if (rtn) { + rtn.lastUsedTs = Date.now(); + } + return rtn; +} + +function setWaveTabView(waveWindowId: string, waveTabId: string, wcv: WaveTabView): void { + const cacheKey = waveWindowId + "|" + waveTabId; + wcvCache.set(cacheKey, wcv); + checkAndEvictCache(); +} + +function removeWaveTabView(waveWindowId: string, waveTabId: string): void { + const cacheKey = waveWindowId + "|" + waveTabId; + wcvCache.delete(cacheKey); +} + +function forceRemoveAllTabsForWindow(waveWindowId: string): void { + const keys = Array.from(wcvCache.keys()); + for (const key of keys) { + if (key.startsWith(waveWindowId)) { + wcvCache.delete(key); + } + } +} + +function checkAndEvictCache(): void { + if (wcvCache.size <= MaxCacheSize) { + return; + } + const sorted = Array.from(wcvCache.values()).sort((a, b) => { + // Prioritize entries which are active + if (a.isActiveTab && !b.isActiveTab) { + return -1; + } + // Otherwise, sort by lastUsedTs + return a.lastUsedTs - b.lastUsedTs; + }); + for (let i = 0; i < sorted.length - MaxCacheSize; i++) { + if (sorted[i].isActiveTab) { + // don't evict WaveTabViews that are currently showing in a window + continue; + } + const tabView = sorted[i]; + destroyTab(tabView); + } +} + +export function clearTabCache() { + const wcVals = Array.from(wcvCache.values()); + for (let i = 0; i < wcVals.length; i++) { + const tabView = wcVals[i]; + if (tabView.isActiveTab) { + continue; + } + destroyTab(tabView); + } +} + +// returns [tabview, initialized] +function getOrCreateWebViewForTab(fullConfig: FullConfigType, windowId: string, tabId: string): [WaveTabView, boolean] { + let tabView = getWaveTabView(windowId, tabId); + if (tabView) { + return [tabView, true]; + } + tabView = getSpareTab(fullConfig); + tabView.lastUsedTs = Date.now(); + tabView.waveTabId = tabId; + tabView.waveWindowId = windowId; + setWaveTabView(windowId, tabId, tabView); + tabView.webContents.on("will-navigate", shNavHandler); + tabView.webContents.on("will-frame-navigate", shFrameNavHandler); + tabView.webContents.on("did-attach-webview", (event, wc) => { + wc.setWindowOpenHandler((details) => { + tabView.webContents.send("webview-new-window", wc.id, details); + return { action: "deny" }; + }); + }); + tabView.webContents.on("before-input-event", (e, input) => { + const waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); + handleCtrlShiftState(tabView.webContents, waveEvent); + setWasActive(true); + }); + tabView.webContents.on("zoom-changed", (e) => { + tabView.webContents.send("zoom-changed"); + }); + tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + electron.shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + tabView.webContents.on("focus", () => { + setWasInFg(true); + setWasActive(true); + if (getGlobalIsStarting()) { + return; + } + }); + tabView.webContents.on("blur", () => { + handleCtrlShiftFocus(tabView.webContents, false); + }); + configureAuthKeyRequestInjection(tabView.webContents.session); + return [tabView, false]; +} + +async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { + if (win == null || win.isDestroyed() || win.fullScreen) { + return; + } + const bounds = win.getBounds(); + try { + await WindowService.SetWindowPosAndSize( + windowId, + { x: bounds.x, y: bounds.y }, + { width: bounds.width, height: bounds.height } + ); + } catch (e) { + console.log("error resizing window", e); + } +} + +type WindowOpts = { + unamePlatform: string; +}; + +function createBaseWaveBrowserWindow( + waveWindow: WaveWindow, + fullConfig: FullConfigType, + opts: WindowOpts +): WaveBrowserWindow { + let winWidth = waveWindow?.winsize?.width; + let winHeight = waveWindow?.winsize?.height; + let winPosX = waveWindow.pos.x; + let winPosY = waveWindow.pos.y; + if (winWidth == null || winWidth == 0) { + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const { height } = primaryDisplay.workAreaSize; + winHeight = height - winPosY - 100; + if (winHeight > 1200) { + winHeight = 1200; + } + } + let winBounds = { + x: winPosX, + y: winPosY, + width: winWidth, + height: winHeight, + }; + winBounds = ensureBoundsAreVisible(winBounds); + const settings = fullConfig?.settings; + const winOpts: Electron.BaseWindowConstructorOptions = { + titleBarStyle: + opts.unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", + titleBarOverlay: + opts.unamePlatform !== "darwin" + ? { + symbolColor: "white", + color: "#00000000", + } + : false, + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: 400, + minHeight: 300, + icon: + opts.unamePlatform == "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + show: false, + autoHideMenuBar: !settings?.["window:showmenubar"], + }; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + winOpts.transparent = true; + } else if (isBlur) { + switch (opts.unamePlatform) { + case "win32": { + winOpts.backgroundMaterial = "acrylic"; + break; + } + case "darwin": { + winOpts.vibrancy = "fullscreen-ui"; + break; + } + } + } else { + winOpts.backgroundColor = "#222222"; + } + const bwin = new electron.BaseWindow(winOpts); + const win: WaveBrowserWindow = bwin as WaveBrowserWindow; + win.waveWindowId = waveWindow.oid; + win.alreadyClosed = false; + win.allTabViews = new Map(); + win.on( + // @ts-expect-error + "resize", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("resize", () => { + if (win.isDestroyed() || win.fullScreen) { + return; + } + positionTabOnScreen(win.activeTabView, win.getContentBounds()); + }); + win.on( + // @ts-expect-error + "move", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("enter-full-screen", async () => { + const tabView = win.activeTabView; + if (tabView) { + tabView.webContents.send("fullscreen-change", true); + } + }); + win.on("leave-full-screen", async () => { + const tabView = win.activeTabView; + if (tabView) { + tabView.webContents.send("fullscreen-change", false); + } + }); + win.on("focus", () => { + focusedWaveWindow = win; + console.log("focus win", win.waveWindowId); + ClientService.FocusWindow(win.waveWindowId); + }); + win.on("blur", () => { + if (focusedWaveWindow == win) { + focusedWaveWindow = null; + } + }); + win.on("close", (e) => { + if (getGlobalIsQuitting() || updater?.status == "installing") { + return; + } + const numWindows = waveWindowMap.size; + if (numWindows == 1) { + return; + } + const choice = electron.dialog.showMessageBoxSync(win, { + type: "question", + buttons: ["Cancel", "Yes"], + title: "Confirm", + message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?", + }); + if (choice === 0) { + e.preventDefault(); + } + }); + win.on("closed", () => { + if (getGlobalIsQuitting() || updater?.status == "installing") { + return; + } + const numWindows = waveWindowMap.size; + if (numWindows == 0) { + return; + } + if (!win.alreadyClosed) { + WindowService.CloseWindow(waveWindow.oid, true); + } + destroyWindow(win); + }); + waveWindowMap.set(waveWindow.oid, win); + return win; +} + +export function getLastFocusedWaveWindow(): WaveBrowserWindow { + return focusedWaveWindow; +} + +// note, this does not *show* the window. +// to show, await win.readyPromise and then win.show() +export function createBrowserWindow( + clientId: string, + waveWindow: WaveWindow, + fullConfig: FullConfigType, + opts: WindowOpts +): WaveBrowserWindow { + const bwin = createBaseWaveBrowserWindow(waveWindow, fullConfig, opts); + // TODO fix null activetabid if it exists + if (waveWindow.activetabid != null) { + setActiveTab(bwin, waveWindow.activetabid); + } + return bwin; +} + +export async function setActiveTab(waveWindow: WaveBrowserWindow, tabId: string) { + const windowId = waveWindow.waveWindowId; + await ObjectService.SetActiveTab(waveWindow.waveWindowId, tabId); + const fullConfig = await FileService.GetFullConfig(); + const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, windowId, tabId); + setTabViewIntoWindow(waveWindow, tabView, tabInitialized); +} + +async function setTabViewIntoWindow(bwin: WaveBrowserWindow, tabView: WaveTabView, tabInitialized: boolean) { + const curTabView: WaveTabView = bwin.getContentView() as any; + const clientData = await ClientService.GetClientData(); + if (curTabView != null) { + curTabView.isActiveTab = false; + } + if (bwin.activeTabView == tabView) { + return; + } + const oldActiveView = bwin.activeTabView; + tabView.isActiveTab = true; + if (oldActiveView != null) { + oldActiveView.isActiveTab = false; + } + bwin.activeTabView = tabView; + bwin.allTabViews.set(tabView.waveTabId, tabView); + if (!tabInitialized) { + console.log("initializing a new tab"); + await tabView.initPromise; + bwin.contentView.addChildView(tabView); + const initOpts = { + tabId: tabView.waveTabId, + clientId: clientData.oid, + windowId: bwin.waveWindowId, + activate: true, + }; + tabView.savedInitOpts = { ...initOpts }; + tabView.savedInitOpts.activate = false; + let startTime = Date.now(); + tabView.webContents.send("wave-init", initOpts); + console.log("before wave ready"); + await tabView.waveReadyPromise; + // positionTabOnScreen(tabView, bwin.getContentBounds()); + console.log("wave-ready init time", Date.now() - startTime + "ms"); + // positionTabOffScreen(oldActiveView, bwin.getContentBounds()); + repositionTabsSlowly(tabView, oldActiveView, 100, bwin.getContentBounds()); + } else { + console.log("reusing an existing tab"); + repositionTabsSlowly(tabView, oldActiveView, 35, bwin.getContentBounds()); + tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit + } + + // something is causing the new tab to lose focus so it requires manual refocusing + tabView.webContents.focus(); + setTimeout(() => { + if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 10); + setTimeout(() => { + if (bwin.activeTabView == tabView && !tabView.webContents.isFocused()) { + tabView.webContents.focus(); + } + }, 30); +} diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts new file mode 100644 index 000000000..e8ade2475 --- /dev/null +++ b/emain/emain-wavesrv.ts @@ -0,0 +1,128 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import * as child_process from "node:child_process"; +import * as readline from "readline"; +import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; +import { AuthKey, AuthKeyEnv } from "./authkey"; +import { setForceQuit } from "./emain-activity"; +import { WaveAppPathVarName } from "./emain-util"; +import { + getElectronAppUnpackedBasePath, + getWaveConfigDir, + getWaveDataDir, + getWaveSrvCwd, + getWaveSrvPath, + WaveConfigHomeVarName, + WaveDataHomeVarName, +} from "./platform"; +import { updater } from "./updater"; + +export const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; + +let isWaveSrvDead = false; +let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; +let WaveVersion = "unknown"; // set by WAVESRV-ESTART +let WaveBuildTime = 0; // set by WAVESRV-ESTART + +export function getWaveVersion(): { version: string; buildTime: number } { + return { version: WaveVersion, buildTime: WaveBuildTime }; +} + +let waveSrvReadyResolve = (value: boolean) => {}; +const waveSrvReady: Promise = new Promise((resolve, _) => { + waveSrvReadyResolve = resolve; +}); + +export function getWaveSrvReady(): Promise { + return waveSrvReady; +} + +export function getWaveSrvProc(): child_process.ChildProcessWithoutNullStreams | null { + return waveSrvProc; +} + +export function getIsWaveSrvDead(): boolean { + return isWaveSrvDead; +} + +export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise { + let pResolve: (value: boolean) => void; + let pReject: (reason?: any) => void; + const rtnPromise = new Promise((argResolve, argReject) => { + pResolve = argResolve; + pReject = argReject; + }); + const envCopy = { ...process.env }; + envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); + envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); + envCopy[AuthKeyEnv] = AuthKey; + envCopy[WaveDataHomeVarName] = getWaveDataDir(); + envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); + const waveSrvCmd = getWaveSrvPath(); + console.log("trying to run local server", waveSrvCmd); + const proc = child_process.spawn(getWaveSrvPath(), { + cwd: getWaveSrvCwd(), + env: envCopy, + }); + proc.on("exit", (e) => { + if (updater?.status == "installing") { + return; + } + console.log("wavesrv exited, shutting down"); + setForceQuit(true); + isWaveSrvDead = true; + electron.app.quit(); + }); + proc.on("spawn", (e) => { + console.log("spawned wavesrv"); + waveSrvProc = proc; + pResolve(true); + }); + proc.on("error", (e) => { + console.log("error running wavesrv", e); + pReject(e); + }); + const rlStdout = readline.createInterface({ + input: proc.stdout, + terminal: false, + }); + rlStdout.on("line", (line) => { + console.log(line); + }); + const rlStderr = readline.createInterface({ + input: proc.stderr, + terminal: false, + }); + rlStderr.on("line", (line) => { + if (line.includes("WAVESRV-ESTART")) { + const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec( + line + ); + if (startParams == null) { + console.log("error parsing WAVESRV-ESTART line", line); + electron.app.quit(); + return; + } + process.env[WSServerEndpointVarName] = startParams[1]; + process.env[WebServerEndpointVarName] = startParams[2]; + WaveVersion = startParams[3]; + WaveBuildTime = parseInt(startParams[4]); + waveSrvReadyResolve(true); + return; + } + if (line.startsWith("WAVESRV-EVENT:")) { + const evtJson = line.slice("WAVESRV-EVENT:".length); + try { + const evtMsg: WSEventType = JSON.parse(evtJson); + handleWSEvent(evtMsg); + } catch (e) { + console.log("error handling WAVESRV-EVENT", e); + } + return; + } + console.log(line); + }); + return rtnPromise; +} diff --git a/emain/emain-web.ts b/emain/emain-web.ts index 842e266bf..f1fbd9aeb 100644 --- a/emain/emain-web.ts +++ b/emain/emain-web.ts @@ -1,13 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BrowserWindow, ipcMain, webContents, WebContents } from "electron"; +import { ipcMain, webContents, WebContents } from "electron"; -export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise { +export function getWebContentsByBlockId(ww: WaveBrowserWindow, tabId: string, blockId: string): Promise { const prtn = new Promise((resolve, reject) => { const randId = Math.floor(Math.random() * 1000000000).toString(); const respCh = `getWebContentsByBlockId-${randId}`; - win.webContents.send("webcontentsid-from-blockid", blockId, respCh); + ww?.activeTabView?.webContents.send("webcontentsid-from-blockid", blockId, respCh); ipcMain.once(respCh, (event, webContentsId) => { if (webContentsId == null) { resolve(null); diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index 60828fe01..a354f2676 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -1,12 +1,11 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import electron from "electron"; +import { Notification } from "electron"; import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; +import { getWaveWindowById } from "./emain-viewmgr"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; -type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; - export class ElectronWshClientType extends WshClient { constructor() { super("electron"); @@ -16,12 +15,11 @@ export class ElectronWshClientType extends WshClient { if (!data.tabid || !data.blockid || !data.windowid) { throw new Error("tabid and blockid are required"); } - const windows = electron.BrowserWindow.getAllWindows(); - const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid); - if (win == null) { + const ww = getWaveWindowById(data.windowid); + if (ww == null) { throw new Error(`no window found with id ${data.windowid}`); } - const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid); + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); if (wc == null) { throw new Error(`no webcontents found with blockid ${data.blockid}`); } @@ -30,7 +28,7 @@ export class ElectronWshClientType extends WshClient { } async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { - new electron.Notification({ + new Notification({ title: notificationOptions.title, body: notificationOptions.body, silent: notificationOptions.silent, diff --git a/emain/emain.ts b/emain/emain.ts index 4713f45e6..40f8a11e0 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -7,71 +7,70 @@ import fs from "fs"; import * as child_process from "node:child_process"; import * as path from "path"; import { PNG } from "pngjs"; -import * as readline from "readline"; import { sprintf } from "sprintf-js"; import { Readable } from "stream"; -import { debounce } from "throttle-debounce"; import * as util from "util"; import winston from "winston"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; -import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; +import { getWebServerEndpoint } from "../frontend/util/endpoints"; import { fetch } from "../frontend/util/fetchutil"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget } from "../frontend/util/util"; -import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; +import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; import { initDocsite } from "./docsite"; +import { + getActivityState, + getForceQuit, + getGlobalIsRelaunching, + setForceQuit, + setGlobalIsQuitting, + setGlobalIsRelaunching, + setGlobalIsStarting, + setWasActive, + setWasInFg, +} from "./emain-activity"; +import { handleCtrlShiftState } from "./emain-util"; +import { + createBrowserWindow, + ensureHotSpareTab, + getAllWaveWindows, + getFocusedWaveWindow, + getLastFocusedWaveWindow, + getWaveTabViewByWebContentsId, + getWaveWindowById, + getWaveWindowByWebContentsId, + setActiveTab, + setMaxTabCacheSize, +} from "./emain-viewmgr"; +import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { getAppMenu } from "./menu"; import { getElectronAppBasePath, getElectronAppUnpackedBasePath, - getWaveHomeDir, - getWaveSrvCwd, - getWaveSrvPath, + getWaveConfigDir, + getWaveDataDir, isDev, - isDevVite, unameArch, unamePlatform, } from "./platform"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; -let WaveVersion = "unknown"; // set by WAVESRV-ESTART -let WaveBuildTime = 0; // set by WAVESRV-ESTART -let forceQuit = false; -let isWaveSrvDead = false; -const WaveAppPathVarName = "WAVETERM_APP_PATH"; -const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; +const waveDataDir = getWaveDataDir(); +const waveConfigDir = getWaveConfigDir(); + electron.nativeTheme.themeSource = "dark"; -type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; - -let waveSrvReadyResolve = (value: boolean) => {}; -const waveSrvReady: Promise = new Promise((resolve, _) => { - waveSrvReadyResolve = resolve; -}); -let globalIsQuitting = false; -let globalIsStarting = true; -let globalIsRelaunching = false; - -// for activity updates -let wasActive = true; -let wasInFg = true; - let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) let webviewKeys: string[] = []; // the keys to trap when webview has focus - -let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; - -const waveHome = getWaveHomeDir(); - const oldConsoleLog = console.log; const loggerTransports: winston.transport[] = [ - new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveapp.log"), level: "info" }), + new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }), ]; if (isDev) { loggerTransports.push(new winston.transports.Console()); @@ -79,7 +78,7 @@ if (isDev) { const loggerConfig = { level: "info", format: winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), winston.format.printf((info) => `${info.timestamp} ${info.message}`) ), transports: loggerTransports, @@ -95,8 +94,9 @@ function log(...msg: any[]) { console.log = log; console.log( sprintf( - "waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s", - waveHome, + "waveterm-app starting, data_dir=%s, config_dir=%s electronpath=%s gopath=%s arch=%s/%s", + waveDataDir, + waveConfigDir, getElectronAppBasePath(), getElectronAppUnpackedBasePath(), unamePlatform, @@ -107,125 +107,6 @@ if (isDev) { console.log("waveterm-app WAVETERM_DEV set"); } -function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { - const windowId = event.sender.id; - return electron.BrowserWindow.fromId(windowId); -} - -function setCtrlShift(wc: Electron.WebContents, state: boolean) { - wc.send("control-shift-state-update", state); -} - -function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { - if (waveEvent.type == "keyup") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift") { - setCtrlShift(sender, false); - } - if (waveEvent.key == "Meta") { - if (waveEvent.control && waveEvent.shift) { - setCtrlShift(sender, true); - } - } - return; - } - if (waveEvent.type == "keydown") { - if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { - if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { - // Set the control and shift without the Meta key - setCtrlShift(sender, true); - } else { - // Unset if Meta is pressed - setCtrlShift(sender, false); - } - } - return; - } -} - -function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { - if (!focused) { - setCtrlShift(sender, false); - } -} - -function runWaveSrv(): Promise { - let pResolve: (value: boolean) => void; - let pReject: (reason?: any) => void; - const rtnPromise = new Promise((argResolve, argReject) => { - pResolve = argResolve; - pReject = argReject; - }); - const envCopy = { ...process.env }; - envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); - envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); - envCopy[AuthKeyEnv] = AuthKey; - const waveSrvCmd = getWaveSrvPath(); - console.log("trying to run local server", waveSrvCmd); - const proc = child_process.spawn(getWaveSrvPath(), { - cwd: getWaveSrvCwd(), - env: envCopy, - }); - proc.on("exit", (e) => { - if (updater?.status == "installing") { - return; - } - console.log("wavesrv exited, shutting down"); - forceQuit = true; - isWaveSrvDead = true; - electronApp.quit(); - }); - proc.on("spawn", (e) => { - console.log("spawned wavesrv"); - waveSrvProc = proc; - pResolve(true); - }); - proc.on("error", (e) => { - console.log("error running wavesrv", e); - pReject(e); - }); - const rlStdout = readline.createInterface({ - input: proc.stdout, - terminal: false, - }); - rlStdout.on("line", (line) => { - console.log(line); - }); - const rlStderr = readline.createInterface({ - input: proc.stderr, - terminal: false, - }); - rlStderr.on("line", (line) => { - if (line.includes("WAVESRV-ESTART")) { - const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec( - line - ); - if (startParams == null) { - console.log("error parsing WAVESRV-ESTART line", line); - electronApp.quit(); - return; - } - process.env[WSServerEndpointVarName] = startParams[1]; - process.env[WebServerEndpointVarName] = startParams[2]; - WaveVersion = startParams[3]; - WaveBuildTime = parseInt(startParams[4]); - waveSrvReadyResolve(true); - return; - } - if (line.startsWith("WAVESRV-EVENT:")) { - const evtJson = line.slice("WAVESRV-EVENT:".length); - try { - const evtMsg: WSEventType = JSON.parse(evtJson); - handleWSEvent(evtMsg); - } catch (e) { - console.log("error handling WAVESRV-EVENT", e); - } - return; - } - console.log(line); - }); - return rtnPromise; -} - async function handleWSEvent(evtMsg: WSEventType) { console.log("handleWSEvent", evtMsg?.eventtype); if (evtMsg.eventtype == "electron:newwindow") { @@ -236,391 +117,21 @@ async function handleWSEvent(evtMsg: WSEventType) { } const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); - const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig); - await newWin.readyPromise; + const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform }); + await newWin.waveReadyPromise; newWin.show(); } else if (evtMsg.eventtype == "electron:closewindow") { if (evtMsg.data === undefined) return; - const windows = electron.BrowserWindow.getAllWindows(); - for (const window of windows) { - if ((window as any).waveWindowId === evtMsg.data) { - // Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window. - window.destroy(); - } + const ww = getWaveWindowById(evtMsg.data); + if (ww != null) { + ww.alreadyClosed = true; + ww.destroy(); // bypass the "are you sure?" dialog } } else { console.log("unhandled electron ws eventtype", evtMsg.eventtype); } } -async function persistWindowBounds(windowId: string, bounds: electron.Rectangle) { - try { - await services.WindowService.SetWindowPosAndSize( - windowId, - { x: bounds.x, y: bounds.y }, - { width: bounds.width, height: bounds.height } - ); - } catch (e) { - console.log("error resizing window", e); - } -} - -async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { - if (win == null || win.isDestroyed() || win.fullScreen) { - return; - } - const bounds = win.getBounds(); - await persistWindowBounds(windowId, bounds); -} - -function shNavHandler(event: Electron.Event, url: string) { - if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { - // this is a dev-mode hot-reload, ignore it - console.log("allowing hot-reload of index.html"); - return; - } - event.preventDefault(); - if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { - console.log("open external, shNav", url); - electron.shell.openExternal(url); - } else { - console.log("navigation canceled", url); - } -} - -function shFrameNavHandler(event: Electron.Event) { - if (!event.frame?.parent) { - // only use this handler to process iframe events (non-iframe events go to shNavHandler) - return; - } - const url = event.url; - console.log(`frame-navigation url=${url} frame=${event.frame.name}`); - if (event.frame.name == "webview") { - // "webview" links always open in new window - // this will *not* effect the initial load because srcdoc does not count as an electron navigation - console.log("open external, frameNav", url); - event.preventDefault(); - electron.shell.openExternal(url); - return; - } - if ( - event.frame.name == "pdfview" && - (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) - ) { - // allowed - return; - } - event.preventDefault(); - console.log("frame navigation canceled"); -} - -function computeNewWinBounds(waveWindow: WaveWindow): Electron.Rectangle { - const targetWidth = waveWindow.winsize?.width || 2000; - const targetHeight = waveWindow.winsize?.height || 1080; - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const workArea = primaryDisplay.workArea; - const targetPadding = 100; - const minPadding = 10; - let rtn = { - x: workArea.x + targetPadding, - y: workArea.y + targetPadding, - width: targetWidth, - height: targetHeight, - }; - const spareWidth = workArea.width - targetWidth; - if (spareWidth < 2 * minPadding) { - rtn.x = workArea.x + minPadding; - rtn.width = workArea.width - 2 * minPadding; - } else if (spareWidth > 2 * targetPadding) { - rtn.x = workArea.x + targetPadding; - } else { - rtn.x = workArea.y + Math.floor(spareWidth / 2); - } - const spareHeight = workArea.height - targetHeight; - if (spareHeight < 2 * minPadding) { - rtn.y = workArea.y + minPadding; - rtn.height = workArea.height - 2 * minPadding; - } else if (spareHeight > 2 * targetPadding) { - rtn.y = workArea.y + targetPadding; - } else { - rtn.y = workArea.y + Math.floor(spareHeight / 2); - } - return rtn; -} - -function computeWinBounds(waveWindow: WaveWindow): Electron.Rectangle { - if (waveWindow.isnew) { - return computeNewWinBounds(waveWindow); - } - let winWidth = waveWindow?.winsize?.width; - let winHeight = waveWindow?.winsize?.height; - let winPosX = waveWindow.pos.x; - let winPosY = waveWindow.pos.y; - if (winWidth == null || winWidth == 0) { - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const { width } = primaryDisplay.workAreaSize; - winWidth = width - winPosX - 100; - if (winWidth > 2000) { - winWidth = 2000; - } - } - if (winHeight == null || winHeight == 0) { - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const { height } = primaryDisplay.workAreaSize; - winHeight = height - winPosY - 100; - if (winHeight > 1200) { - winHeight = 1200; - } - } - let winBounds = { - x: winPosX, - y: winPosY, - width: winWidth, - height: winHeight, - }; - return winBounds; -} - -// note, this does not *show* the window. -// to show, await win.readyPromise and then win.show() -function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow { - let winBounds = computeWinBounds(waveWindow); - winBounds = ensureBoundsAreVisible(winBounds); - persistWindowBounds(waveWindow.oid, winBounds); - const settings = fullConfig?.settings; - const winOpts: Electron.BrowserWindowConstructorOptions = { - titleBarStyle: - unamePlatform === "darwin" ? "hiddenInset" : settings["window:nativetitlebar"] ? "default" : "hidden", - titleBarOverlay: - unamePlatform !== "darwin" - ? { - symbolColor: "white", - color: "#00000000", - } - : false, - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: 400, - minHeight: 300, - icon: - unamePlatform == "linux" - ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") - : undefined, - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - show: false, - autoHideMenuBar: !settings?.["window:showmenubar"], - }; - const isTransparent = settings?.["window:transparent"] ?? false; - const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); - if (isTransparent) { - winOpts.transparent = true; - } else if (isBlur) { - switch (unamePlatform) { - case "win32": { - winOpts.backgroundMaterial = "acrylic"; - break; - } - case "darwin": { - winOpts.vibrancy = "fullscreen-ui"; - break; - } - } - } else { - winOpts.backgroundColor = "#222222"; - } - const bwin = new electron.BrowserWindow(winOpts); - (bwin as any).waveWindowId = waveWindow.oid; - let readyResolve: (value: void) => void; - (bwin as any).readyPromise = new Promise((resolve, _) => { - readyResolve = resolve; - }); - const win: WaveBrowserWindow = bwin as WaveBrowserWindow; - const usp = new URLSearchParams(); - usp.set("clientid", clientId); - usp.set("windowid", waveWindow.oid); - const indexHtml = "index.html"; - if (isDevVite) { - console.log("running as dev server"); - win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`); - } else { - console.log("running as file"); - win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() }); - } - win.once("ready-to-show", () => { - readyResolve(); - }); - win.webContents.on("will-navigate", shNavHandler); - win.webContents.on("will-frame-navigate", shFrameNavHandler); - win.webContents.on("did-attach-webview", (event, wc) => { - wc.setWindowOpenHandler((details) => { - win.webContents.send("webview-new-window", wc.id, details); - return { action: "deny" }; - }); - }); - win.webContents.on("before-input-event", (e, input) => { - const waveEvent = keyutil.adaptFromElectronKeyEvent(input); - // console.log("WIN bie", waveEvent.type, waveEvent.code); - handleCtrlShiftState(win.webContents, waveEvent); - if (win.isFocused()) { - wasActive = true; - } - }); - win.on( - // @ts-expect-error - "resize", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on( - // @ts-expect-error - "move", - debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) - ); - win.on("focus", () => { - wasInFg = true; - wasActive = true; - if (globalIsStarting) { - return; - } - console.log("focus", waveWindow.oid); - services.ClientService.FocusWindow(waveWindow.oid); - }); - win.on("blur", () => { - handleCtrlShiftFocus(win.webContents, false); - }); - win.on("enter-full-screen", async () => { - win.webContents.send("fullscreen-change", true); - }); - win.on("leave-full-screen", async () => { - win.webContents.send("fullscreen-change", false); - }); - win.on("close", (e) => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 1) { - return; - } - const choice = electron.dialog.showMessageBoxSync(win, { - type: "question", - buttons: ["Cancel", "Yes"], - title: "Confirm", - message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?", - }); - if (choice === 0) { - e.preventDefault(); - } - }); - win.on("closed", () => { - if (globalIsQuitting || updater?.status == "installing") { - return; - } - const numWindows = electron.BrowserWindow.getAllWindows().length; - if (numWindows == 0) { - return; - } - services.WindowService.CloseWindow(waveWindow.oid); - }); - win.webContents.on("zoom-changed", (e) => { - win.webContents.send("zoom-changed"); - }); - win.webContents.setWindowOpenHandler(({ url, frameName }) => { - if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { - console.log("openExternal fallback", url); - electron.shell.openExternal(url); - } - console.log("window-open denied", url); - return { action: "deny" }; - }); - configureAuthKeyRequestInjection(win.webContents.session); - return win; -} - -function isWindowFullyVisible(bounds: electron.Rectangle): boolean { - const displays = electron.screen.getAllDisplays(); - - // Helper function to check if a point is inside any display - function isPointInDisplay(x: number, y: number) { - for (const display of displays) { - const { x: dx, y: dy, width, height } = display.bounds; - if (x >= dx && x < dx + width && y >= dy && y < dy + height) { - return true; - } - } - return false; - } - - // Check all corners of the window - const topLeft = isPointInDisplay(bounds.x, bounds.y); - const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); - const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); - const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); - - return topLeft && topRight && bottomLeft && bottomRight; -} - -function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { - const displays = electron.screen.getAllDisplays(); - let maxArea = 0; - let bestDisplay = null; - - for (let display of displays) { - const { x, y, width, height } = display.bounds; - const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); - const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); - const overlapArea = overlapX * overlapY; - - if (overlapArea > maxArea) { - maxArea = overlapArea; - bestDisplay = display; - } - } - - return bestDisplay; -} - -function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { - const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; - let { x, y, width, height } = bounds; - - // Adjust width and height to fit within the display's work area - width = Math.min(width, dWidth); - height = Math.min(height, dHeight); - - // Adjust x to ensure the window fits within the display - if (x < dx) { - x = dx; - } else if (x + width > dx + dWidth) { - x = dx + dWidth - width; - } - - // Adjust y to ensure the window fits within the display - if (y < dy) { - y = dy; - } else if (y + height > dy + dHeight) { - y = dy + dHeight - height; - } - return { x, y, width, height }; -} - -function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { - if (!isWindowFullyVisible(bounds)) { - let targetDisplay = findDisplayWithMostArea(bounds); - - if (!targetDisplay) { - targetDisplay = electron.screen.getPrimaryDisplay(); - } - - return adjustBoundsToFitDisplay(bounds, targetDisplay); - } - return bounds; -} - // Listen for the open-external event from the renderer process electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { @@ -712,7 +223,7 @@ function getUrlInSession(session: Electron.Session, url: string): Promise { const menu = new electron.Menu(); - const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); if (win == null) { return; } @@ -733,19 +244,66 @@ electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, ); const { x, y } = electron.screen.getCursorScreenPoint(); const windowPos = win.getPosition(); - menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] }); + menu.popup(); }); electron.ipcMain.on("download", (event, payload) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath); - window.webContents.downloadURL(streamingUrl); + event.sender.downloadURL(streamingUrl); +}); + +electron.ipcMain.on("set-active-tab", async (event, tabId) => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("set-active-tab", tabId, ww?.waveWindowId); + await setActiveTab(ww, tabId); +}); + +electron.ipcMain.on("create-tab", async (event, opts) => { + const senderWc = event.sender; + const tabView = getWaveTabViewByWebContentsId(senderWc.id); + if (tabView == null) { + return; + } + const waveWindowId = tabView.waveWindowId; + const waveWindow = (await services.ObjectService.GetObject("window:" + waveWindowId)) as WaveWindow; + if (waveWindow == null) { + return; + } + const newTabId = await services.ObjectService.AddTabToWorkspace(waveWindowId, null, true); + const ww = getWaveWindowById(waveWindowId); + if (ww == null) { + return; + } + await setActiveTab(ww, newTabId); + event.returnValue = true; + return null; +}); + +electron.ipcMain.on("close-tab", async (event, tabId) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + return; + } + const rtn = await services.WindowService.CloseTab(tabView.waveWindowId, tabId, true); + if (rtn?.closewindow) { + const ww = getWaveWindowById(tabView.waveWindowId); + ww.alreadyClosed = true; + ww?.destroy(); // bypass the "are you sure?" dialog + } else if (rtn?.newactivetabid) { + setActiveTab(getWaveWindowById(tabView.waveWindowId), rtn.newactivetabid); + } + event.returnValue = true; + return null; }); electron.ipcMain.on("get-cursor-point", (event) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null) { + event.returnValue = null; + return; + } const screenPoint = electron.screen.getCursorScreenPoint(); - const windowRect = window.getContentBounds(); + const windowRect = tabView.getBounds(); const retVal: Electron.Point = { x: screenPoint.x - windowRect.x, y: screenPoint.y - windowRect.y, @@ -758,7 +316,7 @@ electron.ipcMain.on("get-env", (event, varName) => { }); electron.ipcMain.on("get-about-modal-details", (event) => { - event.returnValue = { version: WaveVersion, buildTime: WaveBuildTime } as AboutModalDetails; + event.returnValue = getWaveVersion() as AboutModalDetails; }); const hasBeforeInputRegisteredMap = new Map(); @@ -825,8 +383,8 @@ if (unamePlatform !== "darwin") { const overlayBuffer = overlay.toPNG(); const png = PNG.sync.read(overlayBuffer); const color = fac.prepareResult(fac.getColorFromArray4(png.data)); - const window = electron.BrowserWindow.fromWebContents(event.sender); - window.setTitleBarOverlay({ + const ww = getWaveWindowByWebContentsId(event.sender.id); + ww.setTitleBarOverlay({ color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. symbolColor: color.isDark ? "white" : "black", }); @@ -848,13 +406,14 @@ async function createNewWaveWindow(): Promise { const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); let recreatedWindow = false; - if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { + const allWindows = getAllWaveWindows(); + if (allWindows.length === 0 && clientData?.windowids?.length >= 1) { // reopen the first window const existingWindowId = clientData.windowids[0]; const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; if (existingWindowData != null) { - const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); - await win.readyPromise; + const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig, { unamePlatform }); + await win.waveReadyPromise; win.show(); recreatedWindow = true; } @@ -863,16 +422,37 @@ async function createNewWaveWindow(): Promise { return; } const newWindow = await services.ClientService.MakeWindow(); - const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); - await newBrowserWindow.readyPromise; + const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig, { unamePlatform }); + await newBrowserWindow.waveReadyPromise; newBrowserWindow.show(); } +electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (tabView == null || tabView.initResolve == null) { + return; + } + if (status === "ready") { + console.log("initResolve"); + tabView.initResolve(); + if (tabView.savedInitOpts) { + tabView.webContents.send("wave-init", tabView.savedInitOpts); + } + } else if (status === "wave-ready") { + console.log("waveReadyResolve"); + tabView.waveReadyResolve(); + } +}); + +electron.ipcMain.on("fe-log", (event, logStr: string) => { + console.log("fe-log", logStr); +}); + function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { if (defaultFileName == null || defaultFileName == "") { defaultFileName = "image"; } - const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context + const ww = getFocusedWaveWindow(); const mimeToExtension: { [key: string]: string } = { "image/png": "png", "image/jpeg": "jpg", @@ -891,7 +471,7 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string } defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); electron.dialog - .showSaveDialog(window, { + .showSaveDialog(ww, { title: "Save Image", defaultPath: defaultFileName, filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], @@ -922,20 +502,19 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { - const window = electron.BrowserWindow.fromWebContents(event.sender); if (menuDefArr?.length === 0) { return; } const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); - const { x, y } = electron.screen.getCursorScreenPoint(); - const windowPos = window.getPosition(); - - menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] }); + // const { x, y } = electron.screen.getCursorScreenPoint(); + // const windowPos = window.getPosition(); + menu.popup(); event.returnValue = true; }); async function logActiveState() { - const activeState = { fg: wasInFg, active: wasActive, open: true }; + const astate = getActivityState(); + const activeState = { fg: astate.wasInFg, active: astate.wasActive, open: true }; const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); try { const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); @@ -947,8 +526,9 @@ async function logActiveState() { console.log("error logging active state", e); } finally { // for next iteration - wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; - wasActive = false; + const ww = getFocusedWaveWindow(); + setWasInFg(ww?.isFocused() ?? false); + setWasActive(false); } } @@ -966,7 +546,9 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro label: menuDef.label, type: menuDef.type, click: (_, window) => { - (window as electron.BrowserWindow)?.webContents?.send("contextmenu-click", menuDef.id); + const ww = window as WaveBrowserWindow; + const tabView = ww.activeTabView; + tabView?.webContents?.send("contextmenu-click", menuDef.id); }, checked: menuDef.checked, }; @@ -980,7 +562,11 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro } function instantiateAppMenu(): electron.Menu { - return getAppMenu({ createNewWaveWindow, relaunchBrowserWindows }); + return getAppMenu({ + createNewWaveWindow, + relaunchBrowserWindows, + getLastFocusedWaveWindow: getLastFocusedWaveWindow, + }); } function makeAppMenu() { @@ -989,7 +575,7 @@ function makeAppMenu() { } electronApp.on("window-all-closed", () => { - if (globalIsRelaunching) { + if (getGlobalIsRelaunching()) { return; } if (unamePlatform !== "darwin") { @@ -997,32 +583,32 @@ electronApp.on("window-all-closed", () => { } }); electronApp.on("before-quit", (e) => { - globalIsQuitting = true; + setGlobalIsQuitting(true); updater?.stop(); if (unamePlatform == "win32") { // win32 doesn't have a SIGINT, so we just let electron die, which // ends up killing wavesrv via closing it's stdin. return; } - waveSrvProc?.kill("SIGINT"); + getWaveSrvProc()?.kill("SIGINT"); shutdownWshrpc(); - if (forceQuit) { + if (getForceQuit()) { return; } e.preventDefault(); - const allWindows = electron.BrowserWindow.getAllWindows(); + const allWindows = getAllWaveWindows(); for (const window of allWindows) { window.hide(); } - if (isWaveSrvDead) { + if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); - forceQuit = true; + setForceQuit(true); electronApp.quit(); return; } setTimeout(() => { console.log("waiting for wavesrv to exit..."); - forceQuit = true; + setForceQuit(true); electronApp.quit(); }, 3000); }); @@ -1051,13 +637,13 @@ process.on("uncaughtException", (error) => { }); async function relaunchBrowserWindows(): Promise { - globalIsRelaunching = true; - const windows = electron.BrowserWindow.getAllWindows(); + setGlobalIsRelaunching(true); + const windows = getAllWaveWindows(); for (const window of windows) { window.removeAllListeners(); window.close(); } - globalIsRelaunching = false; + setGlobalIsRelaunching(false); const clientData = await services.ClientService.GetClientData(); const fullConfig = await services.FileService.GetFullConfig(); @@ -1065,16 +651,16 @@ async function relaunchBrowserWindows(): Promise { for (const windowId of clientData.windowids.slice().reverse()) { const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; if (windowData == null) { - services.WindowService.CloseWindow(windowId).catch((e) => { + services.WindowService.CloseWindow(windowId, true).catch((e) => { /* ignore */ }); continue; } - const win = createBrowserWindow(clientData.oid, windowData, fullConfig); + const win = createBrowserWindow(clientData.oid, windowData, fullConfig, { unamePlatform }); wins.push(win); } for (const win of wins) { - await win.readyPromise; + await win.waveReadyPromise; console.log("show window", win.waveWindowId); win.show(); } @@ -1087,7 +673,6 @@ async function appMain() { console.log("disabling hardware acceleration, per launch settings"); electronApp.disableHardwareAcceleration(); } - const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); if (!instanceLock) { @@ -1095,20 +680,18 @@ async function appMain() { electronApp.quit(); return; } - const waveHomeDir = getWaveHomeDir(); - if (!fs.existsSync(waveHomeDir)) { - fs.mkdirSync(waveHomeDir); - } makeAppMenu(); try { - await runWaveSrv(); + await runWaveSrv(handleWSEvent); } catch (e) { console.log(e.toString()); } - const ready = await waveSrvReady; + const ready = await getWaveSrvReady(); console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); await electronApp.whenReady(); configureAuthKeyRequestInjection(electron.session.defaultSession); + const fullConfig = await services.FileService.GetFullConfig(); + ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); await initDocsite(); setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe @@ -1120,10 +703,14 @@ async function appMain() { } await configureAutoUpdater(); - globalIsStarting = false; + setGlobalIsStarting(false); + if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { + setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); + } electronApp.on("activate", async () => { - if (electron.BrowserWindow.getAllWindows().length === 0) { + const allWindows = getAllWaveWindows(); + if (allWindows.length === 0) { await createNewWaveWindow(); } }); diff --git a/emain/launchsettings.ts b/emain/launchsettings.ts index 92339c915..cb0c253ad 100644 --- a/emain/launchsettings.ts +++ b/emain/launchsettings.ts @@ -1,6 +1,6 @@ import fs from "fs"; import path from "path"; -import { getWaveHomeDir } from "./platform"; +import { getWaveConfigDir } from "./platform"; /** * Get settings directly from the Wave Home directory on launch. @@ -8,7 +8,7 @@ import { getWaveHomeDir } from "./platform"; * @returns The initial launch settings for the application. */ export function getLaunchSettings(): SettingsType { - const settingsPath = path.join(getWaveHomeDir(), "config", "settings.json"); + const settingsPath = path.join(getWaveConfigDir(), "settings.json"); try { const settingsContents = fs.readFileSync(settingsPath, "utf8"); return JSON.parse(settingsContents); diff --git a/emain/menu.ts b/emain/menu.ts index e5406a8de..0a38e7752 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -3,20 +3,26 @@ import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; +import { clearTabCache, getFocusedWaveWindow } from "./emain-viewmgr"; import { unamePlatform } from "./platform"; import { updater } from "./updater"; type AppMenuCallbacks = { createNewWaveWindow: () => Promise; relaunchBrowserWindows: () => Promise; + getLastFocusedWaveWindow: () => WaveBrowserWindow; }; function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { if (window == null) { return null; } - if (window instanceof electron.BrowserWindow) { - return window.webContents; + if (window instanceof electron.BaseWindow) { + const waveWin = window as WaveBrowserWindow; + if (waveWin.activeTabView) { + return waveWin.activeTabView.webContents; + } + return null; } return null; } @@ -32,7 +38,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "close", accelerator: "", // clear the accelerator click: () => { - electron.BrowserWindow.getFocusedWindow()?.close(); + getFocusedWaveWindow()?.close(); }, }, ]; @@ -112,9 +118,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { }, ]; + const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Meta+I"; const viewMenu: Electron.MenuItemConstructorOptions[] = [ { - role: "forceReload", + label: "Reload Tab", + accelerator: "Shift+CommandOrControl+R", + click: (_, window) => { + getWindowWebContents(window)?.reloadIgnoringCache(); + }, }, { label: "Relaunch All Windows", @@ -123,7 +134,18 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { }, }, { - role: "toggleDevTools", + label: "Clear Tab Cache", + click: () => { + clearTabCache(); + }, + }, + { + label: "Toggle DevTools", + accelerator: devToolsAccel, + click: (_, window) => { + let wc = getWindowWebContents(window); + wc?.toggleDevTools(); + }, }, { type: "separator", @@ -143,6 +165,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() >= 5) { + return; + } wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, }, @@ -154,6 +179,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() >= 5) { + return; + } wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, visible: false, @@ -167,9 +195,28 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { if (wc == null) { return; } + if (wc.getZoomFactor() <= 0.2) { + return; + } wc.setZoomFactor(wc.getZoomFactor() - 0.2); }, }, + { + label: "Zoom Out (hidden)", + accelerator: "CommandOrControl+Shift+-", + click: (_, window) => { + const wc = getWindowWebContents(window); + if (wc == null) { + return; + } + if (wc.getZoomFactor() <= 0.2) { + return; + } + wc.setZoomFactor(wc.getZoomFactor() - 0.2); + }, + visible: false, + acceleratorWorksWhenHidden: true, + }, { type: "separator", }, diff --git a/emain/platform.ts b/emain/platform.ts index 1d6d06076..d7360cad5 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -2,12 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { app, ipcMain } from "electron"; +import envPaths from "env-paths"; +import { existsSync, mkdirSync } from "fs"; import os from "os"; import path from "path"; import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; import * as keyutil from "../frontend/util/keyutil"; -const WaveHomeVarName = "WAVETERM_HOME"; +// This is a little trick to ensure that Electron puts all its runtime data into a subdirectory to avoid conflicts with our own data. +// On macOS, it will store to ~/Library/Application \Support/waveterm/electron +// On Linux, it will store to ~/.config/waveterm/electron +// On Windows, it will store to %LOCALAPPDATA%/waveterm/electron +app.setName("waveterm/electron"); const isDev = !app.isPackaged; const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; @@ -18,35 +24,100 @@ if (isDevVite) { process.env[WaveDevViteVarName] = "1"; } +const waveDirNamePrefix = "waveterm"; +const waveDirNameSuffix = isDev ? "dev" : ""; +const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`; + +const paths = envPaths("waveterm", { suffix: waveDirNameSuffix }); + app.setName(isDev ? "Wave (Dev)" : "Wave"); const unamePlatform = process.platform; const unameArch: string = process.arch; keyutil.setKeyUtilPlatform(unamePlatform); -ipcMain.on("get-is-dev", (event) => { - event.returnValue = isDev; -}); -ipcMain.on("get-platform", (event, url) => { - event.returnValue = unamePlatform; -}); -ipcMain.on("get-user-name", (event) => { - const userInfo = os.userInfo(); - event.returnValue = userInfo.username; -}); -ipcMain.on("get-host-name", (event) => { - event.returnValue = os.hostname(); -}); -ipcMain.on("get-webview-preload", (event) => { - event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); -}); +const WaveConfigHomeVarName = "WAVETERM_CONFIG_HOME"; +const WaveDataHomeVarName = "WAVETERM_DATA_HOME"; +const WaveHomeVarName = "WAVETERM_HOME"; -// must match golang -function getWaveHomeDir() { - const override = process.env[WaveHomeVarName]; - if (override) { - return override; +/** + * Gets the path to the old Wave home directory (defaults to `~/.waveterm`). + * @returns The path to the directory if it exists and contains valid data for the current app, otherwise null. + */ +function getWaveHomeDir(): string { + let home = process.env[WaveHomeVarName]; + if (!home) { + const homeDir = app.getPath("home"); + if (homeDir) { + home = path.join(homeDir, `.${waveDirName}`); + } } - return path.join(os.homedir(), isDev ? ".waveterm-dev" : ".waveterm"); + // If home exists and it has `wave.lock` in it, we know it has valid data from Wave >=v0.8. Otherwise, it could be for WaveLegacy ( { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); +ipcMain.on("get-user-name", (event) => { + const userInfo = os.userInfo(); + event.returnValue = userInfo.username; +}); +ipcMain.on("get-host-name", (event) => { + event.returnValue = os.hostname(); +}); +ipcMain.on("get-webview-preload", (event) => { + event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); +}); +ipcMain.on("get-data-dir", (event) => { + event.returnValue = getWaveDataDir(); +}); +ipcMain.on("get-config-dir", (event) => { + event.returnValue = getWaveConfigDir(); +}); + export { getElectronAppBasePath, getElectronAppUnpackedBasePath, - getWaveHomeDir, + getWaveConfigDir, + getWaveDataDir, getWaveSrvCwd, getWaveSrvPath, isDev, isDevVite, unameArch, unamePlatform, + WaveConfigHomeVarName, + WaveDataHomeVarName, }; diff --git a/emain/preload.ts b/emain/preload.ts index 6fbec0d59..d0e0acfa9 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld("api", { getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getUserName: () => ipcRenderer.sendSync("get-user-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"), + getDataDir: () => ipcRenderer.sendSync("get-data-dir"), + getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), @@ -38,6 +40,12 @@ contextBridge.exposeInMainWorld("api", { registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), onControlShiftStateUpdate: (callback) => ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), + setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), + createTab: () => ipcRenderer.send("create-tab"), + closeTab: (tabId) => ipcRenderer.send("close-tab", tabId), + setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), + onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), + sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), }); diff --git a/emain/updater.ts b/emain/updater.ts index 22d737c29..f3849d144 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -1,15 +1,16 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { RpcApi } from "@/app/store/wshclientapi"; -import { BrowserWindow, dialog, ipcMain, Notification } from "electron"; +import { dialog, ipcMain, Notification } from "electron"; import { autoUpdater } from "electron-updater"; import { readFileSync } from "fs"; import path from "path"; import YAML from "yaml"; import { FileService } from "../frontend/app/store/services"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; import { isDev } from "../frontend/util/isdev"; import { fireAndForget } from "../frontend/util/util"; +import { getAllWaveWindows, getFocusedWaveWindow } from "./emain-viewmgr"; import { ElectronWshClient } from "./emain-wsh"; export let updater: Updater; @@ -109,8 +110,11 @@ export class Updater { private set status(value: UpdaterStatus) { this._status = value; - BrowserWindow.getAllWindows().forEach((window) => { - window.webContents.send("app-update-status", value); + getAllWaveWindows().forEach((window) => { + const allTabs = Array.from(window.allTabViews.values()); + allTabs.forEach((tab) => { + tab.webContents.send("app-update-status", value); + }); }); } @@ -159,7 +163,7 @@ export class Updater { type: "info", message: "There are currently no updates available.", }; - dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts); + dialog.showMessageBox(getFocusedWaveWindow(), dialogOpts); } // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. @@ -179,15 +183,14 @@ export class Updater { detail: "A new version has been downloaded. Restart the application to apply the updates.", }; - const allWindows = BrowserWindow.getAllWindows(); + const allWindows = getAllWaveWindows(); if (allWindows.length > 0) { - await dialog - .showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) - .then(({ response }) => { - if (response === 0) { - this.installUpdate(); - } - }); + const focusedWindow = getFocusedWaveWindow(); + await dialog.showMessageBox(focusedWindow ?? allWindows[0], dialogOpts).then(({ response }) => { + if (response === 0) { + this.installUpdate(); + } + }); } } diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index 5cc4a929f..f4939e61d 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import { getWebServerEndpoint } from "@/util/endpoints"; import * as util from "@/util/util"; import useResizeObserver from "@react-hook/resize-observer"; @@ -72,7 +75,7 @@ function processBackgroundUrls(cssText: string): string { export function AppBackground() { const bgRef = useRef(null); - const tabId = useAtomValue(atoms.activeTabId); + const tabId = useAtomValue(atoms.staticTabId); const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); const bgAttr = tabData?.meta?.bg; const style: CSSProperties = {}; diff --git a/frontend/app/app.less b/frontend/app/app.less index c726dbc35..f208d85d9 100644 --- a/frontend/app/app.less +++ b/frontend/app/app.less @@ -18,6 +18,10 @@ body { transform: translateZ(0); } +.is-transparent { + background-color: transparent; +} + a.plain-link { color: var(--secondary-text-color); } diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 02ce0da0d..5cb1be735 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -23,7 +23,10 @@ import { CenteredDiv } from "./element/quickelems"; const dlog = debug("wave:app"); const focusLog = debug("wave:focus"); -const App = () => { +const App = ({ onFirstRender }: { onFirstRender: () => void }) => { + useEffect(() => { + onFirstRender(); + }, []); return ( @@ -65,6 +68,9 @@ async function getClipboardURL(): Promise { return null; } const url = new URL(clipboardText); + if (!url.protocol.startsWith("http")) { + return null; + } return url; } catch (e) { return null; @@ -115,18 +121,20 @@ function AppSettingsUpdater() { (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); let baseBgColor = windowSettings?.["window:bgcolor"]; + let mainDiv = document.getElementById("main"); + // console.log("window settings", windowSettings, isTransparentOrBlur, opacity, baseBgColor, mainDiv); if (isTransparentOrBlur) { - document.body.classList.add("is-transparent"); + mainDiv.classList.add("is-transparent"); const rootStyles = getComputedStyle(document.documentElement); if (baseBgColor == null) { baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim(); } const color = new Color(baseBgColor); const rgbaColor = color.alpha(opacity).string(); - document.body.style.backgroundColor = rgbaColor; + mainDiv.style.backgroundColor = rgbaColor; } else { - document.body.classList.remove("is-transparent"); - document.body.style.opacity = null; + mainDiv.classList.remove("is-transparent"); + mainDiv.style.opacity = null; } }, [windowSettings]); return null; diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 12d879a70..6390ecbea 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -1,13 +1,22 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes"; +import { + BlockComponentModel2, + BlockNodeModel, + BlockProps, + FullBlockProps, + FullSubBlockProps, + SubBlockProps, +} from "@/app/block/blocktypes"; import { PlotView } from "@/app/view/plotview/plotview"; import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { VDomView, makeVDomModel } from "@/app/view/vdom/vdom"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; -import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; +import { useDebouncedNodeInnerRect } from "@/layout/index"; import { counterInc, getBlockComponentModel, @@ -28,13 +37,7 @@ import "./block.less"; import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; -type FullBlockProps = { - preview: boolean; - nodeModel: NodeModel; - viewModel: ViewModel; -}; - -function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { +function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { if (blockView === "term") { return makeTerminalModel(blockId, nodeModel); } @@ -51,6 +54,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel) // "cpuplot" is for backwards compatibility with already-opened widgets return makeSysinfoViewModel(blockId, blockView); } + if (blockView == "vdom") { + return makeVDomModel(blockId, nodeModel); + } if (blockView === "help") { return makeHelpViewModel(blockId, nodeModel); } @@ -100,6 +106,9 @@ function getViewElem( if (blockView == "tips") { return ; } + if (blockView == "vdom") { + return ; + } return Invalid View "{blockView}"; } @@ -137,6 +146,26 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { ); }); +const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { + const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); + const blockRef = useRef(null); + const contentRef = useRef(null); + const viewElem = useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), + [nodeModel.blockId, blockData?.meta?.view, viewModel] + ); + if (!blockData) { + return null; + } + return ( +
+ + Loading...}>{viewElem} + +
+ ); +}); + const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const focusElemRef = useRef(null); @@ -255,7 +284,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const Block = memo((props: BlockProps) => { counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); + counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; @@ -266,6 +295,7 @@ const Block = memo((props: BlockProps) => { useEffect(() => { return () => { unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); }; }, []); if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { @@ -277,4 +307,26 @@ const Block = memo((props: BlockProps) => { return ; }); -export { Block }; +const SubBlock = memo((props: SubBlockProps) => { + counterInc("render-Block"); + counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); + const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); + const bcm = getBlockComponentModel(props.nodeModel.blockId); + let viewModel = bcm?.viewModel; + if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { + viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel); + registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); + } + useEffect(() => { + return () => { + unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); + }; + }, []); + if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { + return null; + } + return ; +}); + +export { Block, SubBlock }; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 6c2f5fd52..199544727 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -26,9 +26,8 @@ import { useBlockAtom, WOS, } from "@/app/store/global"; -import * as services from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { ErrorBoundary } from "@/element/errorboundary"; import { IconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; @@ -60,17 +59,17 @@ function handleHeaderContextMenu( onMagnifyToggle(); }, }, - { - label: "Move to New Window", - click: () => { - const currentTabId = globalStore.get(atoms.activeTabId); - try { - services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); - } catch (e) { - console.error("error moving block to new window", e); - } - }, - }, + // { + // label: "Move to New Window", + // click: () => { + // const currentTabId = globalStore.get(atoms.staticTabId); + // try { + // services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); + // } catch (e) { + // console.error("error moving block to new window", e); + // } + // }, + // }, { type: "separator" }, { label: "Copy BlockId", @@ -321,7 +320,7 @@ const ConnStatusOverlay = React.memo( }, [width, connStatus, setShowError]); const handleTryReconnect = React.useCallback(() => { - const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connName, { timeout: 60000 }); + const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connName, { timeout: 60000 }); prtn.catch((e) => console.log("error reconnecting", connName, e)); }, [connName]); @@ -437,7 +436,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const connName = blockData?.meta?.connection; if (!util.isBlank(connName)) { console.log("ensure conn", nodeModel.blockId, connName); - RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }).catch((e) => { + RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 }).catch((e) => { console.log("error ensuring connection", nodeModel.blockId, connName, e); }); } @@ -521,6 +520,7 @@ const ChangeConnectionBlockModal = React.memo( const connStatusAtom = getConnStatusAtom(connection); const connStatus = jotai.useAtomValue(connStatusAtom); const [connList, setConnList] = React.useState>([]); + const [wslList, setWslList] = React.useState>([]); const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); const [rowIndex, setRowIndex] = React.useState(0); const connStatusMap = new Map(); @@ -536,10 +536,22 @@ const ChangeConnectionBlockModal = React.memo( setConnList([]); return; } - const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 }); + const prtn = RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }); prtn.then((newConnList) => { setConnList(newConnList ?? []); }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); + const p2rtn = RpcApi.WslListCommand(TabRpcClient, { timeout: 2000 }); + p2rtn + .then((newWslList) => { + console.log(newWslList); + setWslList(newWslList ?? []); + }) + .catch((e) => { + // removing this log and failing silentyly since it will happen + // if a system isn't using the wsl. and would happen every time the + // typeahead was opened. good candidate for verbose log level. + //console.log("unable to load wsl list from backend. using blank list: ", e) + }); }, [changeConnModalOpen, setConnList]); const changeConnection = React.useCallback( @@ -557,12 +569,12 @@ const ChangeConnectionBlockModal = React.memo( } else { newCwd = "~"; } - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { connection: connName, file: newCwd }, }); try { - await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }); + await RpcApi.ConnEnsureCommand(TabRpcClient, connName, { timeout: 60000 }); } catch (e) { console.log("error connecting", blockId, connName, e); } @@ -588,6 +600,15 @@ const ChangeConnectionBlockModal = React.memo( filteredList.push(conn); } } + const filteredWslList: Array = []; + for (const conn of wslList) { + if (conn === connSelected) { + createNew = false; + } + if (conn.includes(connSelected)) { + filteredWslList.push(conn); + } + } // priority handles special suggestions when necessary // for instance, when reconnecting const newConnectionSuggestion: SuggestionConnectionItem = { @@ -608,7 +629,7 @@ const ChangeConnectionBlockModal = React.memo( label: `Reconnect to ${connStatus.connection}`, value: "", onSelect: async (_: string) => { - const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connStatus.connection, { timeout: 60000 }); + const prtn = RpcApi.ConnConnectCommand(TabRpcClient, connStatus.connection, { timeout: 60000 }); prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); }, }; @@ -637,6 +658,20 @@ const ChangeConnectionBlockModal = React.memo( label: localName, }); } + for (const wslConn of filteredWslList) { + const connStatus = connStatusMap.get(wslConn); + const connColorNum = computeConnColorNum(connStatus); + localSuggestion.items.push({ + status: "connected", + icon: "arrow-right-arrow-left", + iconColor: + connStatus?.status == "connected" + ? `var(--conn-icon-color-${connColorNum})` + : "var(--grey-text-color)", + value: "wsl://" + wslConn, + label: "wsl://" + wslConn, + }); + } const remoteItems = filteredList.map((connName) => { const connStatus = connStatusMap.get(connName); const connColorNum = computeConnColorNum(connStatus); diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts index 2340f9d17..97126c5eb 100644 --- a/frontend/app/block/blocktypes.ts +++ b/frontend/app/block/blocktypes.ts @@ -2,11 +2,35 @@ // SPDX-License-Identifier: Apache-2.0 import { NodeModel } from "@/layout/index"; +import { Atom } from "jotai"; + +export interface BlockNodeModel { + blockId: string; + isFocused: Atom; + onClose: () => void; + focusNode: () => void; +} + +export type FullBlockProps = { + preview: boolean; + nodeModel: NodeModel; + viewModel: ViewModel; +}; + export interface BlockProps { preview: boolean; nodeModel: NodeModel; } +export type FullSubBlockProps = { + nodeModel: BlockNodeModel; + viewModel: ViewModel; +}; + +export interface SubBlockProps { + nodeModel: BlockNodeModel; +} + export interface BlockComponentModel2 { onClick?: () => void; onFocusCapture?: React.FocusEventHandler; diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 07f745546..ccbdf1004 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -3,7 +3,7 @@ import { CopyButton } from "@/app/element/copybutton"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getWebServerEndpoint } from "@/util/endpoints"; import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; import { clsx } from "clsx"; @@ -143,7 +143,7 @@ const MarkdownImg = ({ } const resolveFn = async () => { const route = makeConnRoute(resolveOpts.connName); - const fileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [resolveOpts.baseDir, props.src], { + const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, props.src], { route: route, }); const usp = new URLSearchParams(); diff --git a/frontend/app/element/quicktips.less b/frontend/app/element/quicktips.less index 9a88ee51d..e3ff547e8 100644 --- a/frontend/app/element/quicktips.less +++ b/frontend/app/element/quicktips.less @@ -40,6 +40,7 @@ align-items: center; margin-left: 5px; margin-right: 5px; + align-self: flex-start; &:first-child { margin-left: 0; @@ -53,10 +54,11 @@ font: var(--fixed-font); font-size: 0.85em; color: var(--keybinding-color); - background-color: var(--keybinding-bg-color); + background-color: var(--highlight-bg-color); border-radius: 4px; border: 1px solid var(--keybinding-border-color); box-shadow: none; + white-space: nowrap; } .icon-wrap { @@ -66,6 +68,7 @@ font-size: 12px; border-radius: 2px; margin-right: 5px; + align-self: flex-start; svg { position: relative; diff --git a/frontend/app/element/quicktips.tsx b/frontend/app/element/quicktips.tsx index 604ce8383..397b2a61a 100644 --- a/frontend/app/element/quicktips.tsx +++ b/frontend/app/element/quicktips.tsx @@ -145,14 +145,44 @@ const QuickTips = () => { +
More Tips
+
+
+ +
+ Right click the tabs to change backgrounds or rename. +
+
+
+ +
+ Click the gear in the web view to set your homepage +
+
+
+ +
+ Click the gear in the terminal to set your terminal theme and font size +
Need More Help?
+ diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx index 2824661c8..6c172905f 100644 --- a/frontend/app/hook/useDimensions.tsx +++ b/frontend/app/hook/useDimensions.tsx @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + import * as React from "react"; import { useCallback, useState } from "react"; import { debounce } from "throttle-debounce"; @@ -56,6 +59,49 @@ export function useDimensionsWithCallbackRef( return [refCallback, ref, domRect]; } +export function useOnResize( + ref: React.RefObject, + callback: (domRect: DOMRectReadOnly) => void, + debounceMs: number = null +) { + const isFirst = React.useRef(true); + const rszObjRef = React.useRef(null); + const oldHtmlElem = React.useRef(null); + const setDomRectDebounced = React.useCallback(debounceMs == null ? callback : debounce(debounceMs, callback), [ + debounceMs, + callback, + ]); + React.useEffect(() => { + if (!rszObjRef.current) { + rszObjRef.current = new ResizeObserver((entries) => { + for (const entry of entries) { + if (isFirst.current) { + isFirst.current = false; + callback(entry.contentRect); + } else { + setDomRectDebounced(entry.contentRect); + } + } + }); + } + if (ref.current) { + rszObjRef.current.observe(ref.current); + oldHtmlElem.current = ref.current; + } + return () => { + if (oldHtmlElem.current) { + rszObjRef.current?.unobserve(oldHtmlElem.current); + oldHtmlElem.current = null; + } + }; + }, [ref.current, callback]); + React.useEffect(() => { + return () => { + rszObjRef.current?.disconnect(); + }; + }, []); +} + // will not react to ref changes // pass debounceMs of null to not debounce export function useDimensionsWithExistingRef( diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 7309e77b3..2cfc77297 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { - getLayoutModelForActiveTab, getLayoutModelForTabById, LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode, } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { getPrefixedSettings, isBlank } from "@/util/util"; @@ -26,6 +26,7 @@ const Counters = new Map(); const ConnStatusMap = new Map>(); type GlobalInitOptions = { + tabId: string; platform: NodeJS.Platform; windowId: string; clientId: string; @@ -46,10 +47,9 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; const uiContextAtom = atom((get) => { - const windowData = get(windowDataAtom); const uiContext: UIContext = { - windowid: get(atoms.windowId), - activetabid: windowData?.activetabid, + windowid: initOpts.windowId, + activetabid: initOpts.tabId, }; return uiContext; }) as Atom; @@ -63,7 +63,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { // do nothing } - const showAboutModalAtom = atom(false) as PrimitiveAtom; try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); @@ -99,18 +98,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { return get(fullConfigAtom)?.settings ?? {}; }) as Atom; const tabAtom: Atom = atom((get) => { - const windowData = get(windowDataAtom); - if (windowData == null) { - return null; - } - return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get); + return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get); }); - const activeTabIdAtom: Atom = atom((get) => { - const windowData = get(windowDataAtom); - if (windowData == null) { - return null; - } - return windowData.activetabid; + const staticTabIdAtom: Atom = atom((get) => { + return initOpts.tabId; }); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; @@ -151,7 +142,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const flashErrorsAtom = atom([]); atoms = { // initialized in wave.ts (will not be null inside of application) - windowId: windowIdAtom, clientId: clientIdAtom, uiContext: uiContextAtom, client: clientAtom, @@ -160,7 +150,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { fullConfigAtom, settingsAtom, tabAtom, - activeTabId: activeTabIdAtom, + staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, controlShiftDelayAtom, updaterStatusAtom, @@ -228,8 +218,56 @@ function useBlockCache(blockId: string, name: string, makeFn: () => T): T { return value as T; } +function getBlockMetaKeyAtom(blockId: string, key: T): Atom { + const blockCache = getSingleBlockAtomCache(blockId); + const metaAtomName = "#meta-" + key; + let metaAtom = blockCache.get(metaAtomName); + if (metaAtom != null) { + return metaAtom; + } + metaAtom = atom((get) => { + let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + let blockData = get(blockAtom); + return blockData?.meta?.[key]; + }); + blockCache.set(metaAtomName, metaAtom); + return metaAtom; +} + +function useBlockMetaKeyAtom(blockId: string, key: T): MetaType[T] { + return useAtomValue(getBlockMetaKeyAtom(blockId, key)); +} + const settingsAtomCache = new Map>(); +function makeOverrideConfigAtom(blockId: string, key: T): Atom { + const blockCache = getSingleBlockAtomCache(blockId); + const overrideAtomName = "#settingsoverride-" + key; + let overrideAtom = blockCache.get(overrideAtomName); + if (overrideAtom != null) { + return overrideAtom; + } + overrideAtom = atom((get) => { + const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any); + const metaKeyVal = get(blockMetaKeyAtom); + if (metaKeyVal != null) { + return metaKeyVal; + } + const settingsKeyAtom = getSettingsKeyAtom(key); + const settingsVal = get(settingsKeyAtom); + if (settingsVal != null) { + return settingsVal; + } + return null; + }); + blockCache.set(overrideAtomName, overrideAtom); + return overrideAtom; +} + +function useOverrideConfigAtom(blockId: string, key: T): SettingsType[T] { + return useAtomValue(makeOverrideConfigAtom(blockId, key)); +} + function getSettingsKeyAtom(key: T): Atom { let settingsKeyAtom = settingsAtomCache.get(key) as Atom; if (settingsKeyAtom == null) { @@ -245,6 +283,10 @@ function getSettingsKeyAtom(key: T): Atom(key: T): SettingsType[T] { + return useAtomValue(getSettingsKeyAtom(key)); +} + function useSettingsPrefixAtom(prefix: string): Atom { // TODO: use a shallow equal here to make this more efficient let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom; @@ -263,12 +305,17 @@ function useSettingsPrefixAtom(prefix: string): Atom { const blockAtomCache = new Map>>(); -function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { +function getSingleBlockAtomCache(blockId: string): Map> { let blockCache = blockAtomCache.get(blockId); if (blockCache == null) { blockCache = new Map>(); blockAtomCache.set(blockId, blockCache); } + return blockCache; +} + +function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { + const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); if (atom == null) { atom = makeFn(); @@ -301,8 +348,8 @@ async function createBlock(blockDef: BlockDef, magnified = false): Promise { + await getApi().createTab(); +} + export { atoms, counterInc, countersClear, countersPrint, createBlock, + createTab, fetchWaveFile, getApi, getBlockComponentModel, + getBlockMetaKeyAtom, getConnStatusAtom, getHostName, getObjectId, @@ -541,6 +604,7 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, + makeOverrideConfigAtom, openLink, PLATFORM, pushFlashError, @@ -554,6 +618,9 @@ export { useBlockAtom, useBlockCache, useBlockDataLoaded, + useBlockMetaKeyAtom, + useOverrideConfigAtom, + useSettingsKeyAtom, useSettingsPrefixAtom, WOS, }; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 048786f2e..be54f9ed7 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -1,24 +1,32 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global"; -import * as services from "@/app/store/services"; +import { + atoms, + createBlock, + createTab, + getApi, + getBlockComponentModel, + globalStore, + refocusNode, + WOS, +} from "@/app/store/global"; import { deleteLayoutModelForTab, - getLayoutModelForActiveTab, getLayoutModelForTab, getLayoutModelForTabById, NavigateDirection, } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import * as keyutil from "@/util/keyutil"; import * as jotai from "jotai"; const simpleControlShiftAtom = jotai.atom(false); const globalKeyMap = new Map boolean>(); -function getFocusedBlockInActiveTab() { - const activeTabId = globalStore.get(atoms.activeTabId); - const layoutModel = getLayoutModelForTabById(activeTabId); +function getFocusedBlockInStaticTab() { + const tabId = globalStore.get(atoms.staticTabId); + const layoutModel = getLayoutModelForTabById(tabId); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; } @@ -70,7 +78,7 @@ function genericClose(tabId: string) { } if (tabData.blockids == null || tabData.blockids.length == 0) { // close tab - services.WindowService.CloseTab(tabId); + getApi().closeTab(tabId); deleteLayoutModelForTab(tabId); return; } @@ -79,7 +87,7 @@ function genericClose(tabId: string) { } function switchBlockByBlockNum(index: number) { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { return; } @@ -92,21 +100,24 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) { } function switchTabAbs(index: number) { + console.log("switchTabAbs", index); const ws = globalStore.get(atoms.workspace); + const waveWindow = globalStore.get(atoms.waveWindow); const newTabIdx = index - 1; if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) { return; } const newActiveTabId = ws.tabids[newTabIdx]; - services.ObjectService.SetActiveTab(newActiveTabId); + getApi().setActiveTab(newActiveTabId); } function switchTab(offset: number) { + console.log("switchTab", offset); const ws = globalStore.get(atoms.workspace); - const activeTabId = globalStore.get(atoms.tabAtom).oid; + const curTabId = globalStore.get(atoms.staticTabId); let tabIdx = -1; for (let i = 0; i < ws.tabids.length; i++) { - if (ws.tabids[i] == activeTabId) { + if (ws.tabids[i] == curTabId) { tabIdx = i; break; } @@ -116,11 +127,11 @@ function switchTab(offset: number) { } const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length; const newActiveTabId = ws.tabids[newTabIdx]; - services.ObjectService.SetActiveTab(newActiveTabId); + getApi().setActiveTab(newActiveTabId); } function handleCmdI() { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { // focus a node @@ -141,7 +152,7 @@ async function handleCmdN() { controller: "shell", }, }; - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); @@ -163,7 +174,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (handled) { return true; } - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; if (blockId != null && shouldDispatchToBlock(waveEvent)) { @@ -225,18 +236,16 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Cmd:t", () => { - const workspace = globalStore.get(atoms.workspace); - const newTabName = `T${workspace.tabids.length + 1}`; - services.ObjectService.AddTabToWorkspace(newTabName, true); + createTab(); return true; }); globalKeyMap.set("Cmd:w", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); genericClose(tabId); return true; }); globalKeyMap.set("Cmd:m", () => { - const layoutModel = getLayoutModelForActiveTab(); + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { layoutModel.magnifyNodeToggle(focusedNode.id); @@ -244,27 +253,27 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Up); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Down); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Left); return true; }); globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); switchBlockInDirection(tabId, NavigateDirection.Right); return true; }); globalKeyMap.set("Cmd:g", () => { - const bcm = getBlockComponentModel(getFocusedBlockInActiveTab()); + const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.openSwitchConnection != null) { bcm.openSwitchConnection(); return true; diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 815ba7e36..19d762189 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -90,7 +90,7 @@ export const FileService = new FileServiceType(); // objectservice.ObjectService (object) class ObjectServiceType { // @returns tabId (and object updates) - AddTabToWorkspace(tabName: string, activateTab: boolean): Promise { + AddTabToWorkspace(windowId: string, tabName: string, activateTab: boolean): Promise { return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments)) } @@ -115,7 +115,7 @@ class ObjectServiceType { } // @returns object updates - SetActiveTab(tabId: string): Promise { + SetActiveTab(uiContext: string, tabId: string): Promise { return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments)) } @@ -154,10 +154,10 @@ export const UserInputService = new UserInputServiceType(); // windowservice.WindowService (window) class WindowServiceType { // @returns object updates - CloseTab(arg3: string): Promise { + CloseTab(arg2: string, arg3: string, arg4: boolean): Promise { return WOS.callBackendService("window", "CloseTab", Array.from(arguments)) } - CloseWindow(arg2: string): Promise { + CloseWindow(arg2: string, arg3: boolean): Promise { return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 6b2c01c12..dadb43d49 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -3,6 +3,7 @@ // WaveObjectStore +import { waveEventSubscribe } from "@/app/store/wps"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; @@ -76,6 +77,16 @@ function debugLogBackendCall(methodName: string, durationStr: string, args: any[ console.log("[service]", methodName, durationStr); } +function wpsSubscribeToObject(oref: string): () => void { + return waveEventSubscribe({ + eventType: "waveobj:update", + scope: oref, + handler: (event) => { + updateWaveObject(event.data); + }, + }); +} + function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise { const startTs = Date.now(); let uiContext: UIContext = null; @@ -130,6 +141,19 @@ function clearWaveObjectCache() { const defaultHoldTime = 5000; // 5-seconds +function reloadWaveObject(oref: string): Promise { + let wov = waveObjectValueCache.get(oref); + if (wov === undefined) { + wov = getWaveObjectValue(oref, true); + return wov.pendingPromise; + } + const prtn = GetObject(oref); + prtn.then((val) => { + globalStore.set(wov.dataAtom, { value: val, loading: false }); + }); + return prtn; +} + function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue { const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 }; wov.dataAtom = atom({ value: null, loading: true }); @@ -290,8 +314,11 @@ export { getWaveObjectLoadingAtom, loadAndPinWaveObject, makeORef, + reloadWaveObject, setObjectValue, + splitORef, updateWaveObject, updateWaveObjects, useWaveObjectValue, + wpsSubscribeToObject, }; diff --git a/frontend/app/store/ws.ts b/frontend/app/store/ws.ts index bbf5477b1..e2da02707 100644 --- a/frontend/app/store/ws.ts +++ b/frontend/app/store/ws.ts @@ -37,7 +37,7 @@ class WSControl { opening: boolean = false; reconnectTimes: number = 0; msgQueue: any[] = []; - windowId: string; + tabId: string; messageCallback: WSEventCallback; watchSessionId: string = null; watchScreenId: string = null; @@ -50,13 +50,13 @@ class WSControl { constructor( baseHostPort: string, - windowId: string, + tabId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { this.baseHostPort = baseHostPort; this.messageCallback = messageCallback; - this.windowId = windowId; + this.tabId = tabId; this.open = false; this.eoOpts = electronOverrideOpts; setInterval(this.sendPing.bind(this), 5000); @@ -75,7 +75,7 @@ class WSControl { dlog("try reconnect:", desc); this.opening = true; this.wsConn = newWebSocket( - this.baseHostPort + "/ws?windowid=" + this.windowId, + this.baseHostPort + "/ws?tabid=" + this.tabId, this.eoOpts ? { [AuthKeyHeader]: this.eoOpts.authKey, @@ -231,11 +231,11 @@ class WSControl { let globalWS: WSControl; function initGlobalWS( baseHostPort: string, - windowId: string, + tabId: string, messageCallback: WSEventCallback, electronOverrideOpts?: ElectronOverrideOpts ) { - globalWS = new WSControl(baseHostPort, windowId, messageCallback, electronOverrideOpts); + globalWS = new WSControl(baseHostPort, tabId, messageCallback, electronOverrideOpts); } function sendRawRpcMessage(msg: RpcMessage) { diff --git a/frontend/app/store/wshclient.ts b/frontend/app/store/wshclient.ts index 93e7a7e38..3ec87ab00 100644 --- a/frontend/app/store/wshclient.ts +++ b/frontend/app/store/wshclient.ts @@ -18,6 +18,10 @@ class RpcResponseHelper { this.done = cmdMsg.reqid == null; } + getSource(): string { + return this.cmdMsg?.source; + } + sendResponse(msg: RpcMessage) { if (this.done || util.isBlank(this.cmdMsg.reqid)) { return; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 44a9923c4..a9b4950b5 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -67,11 +67,26 @@ class RpcApiType { return client.wshRpcCall("createblock", data, opts); } + // command "createsubblock" [call] + CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("createsubblock", data, opts); + } + // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deleteblock", data, opts); } + // command "deletesubblock" [call] + DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deletesubblock", data, opts); + } + + // command "dispose" [call] + DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { + return client.wshRpcCall("dispose", data, opts); + } + // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { return client.wshRpcCall("eventpublish", data, opts); @@ -217,11 +232,46 @@ class RpcApiType { return client.wshRpcCall("test", data, opts); } + // command "vdomasyncinitiation" [call] + VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomasyncinitiation", data, opts); + } + + // command "vdomcreatecontext" [call] + VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomcreatecontext", data, opts); + } + + // command "vdomrender" [call] + VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomrender", data, opts); + } + + // command "waitforroute" [call] + WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { + return client.wshRpcCall("waitforroute", data, opts); + } + // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { return client.wshRpcCall("webselector", data, opts); } + // command "wsldefaultdistro" [call] + WslDefaultDistroCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wsldefaultdistro", null, opts); + } + + // command "wsllist" [call] + WslListCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wsllist", null, opts); + } + + // command "wslstatus" [call] + WslStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("wslstatus", null, opts); + } + } export const RpcApi = new RpcApiType(); diff --git a/frontend/app/store/wshrouter.ts b/frontend/app/store/wshrouter.ts index 9acb0442e..30fbbc441 100644 --- a/frontend/app/store/wshrouter.ts +++ b/frontend/app/store/wshrouter.ts @@ -15,14 +15,14 @@ type RouteInfo = { destRouteId: string; }; -function makeWindowRouteId(windowId: string): string { - return `window:${windowId}`; -} - function makeFeBlockRouteId(feBlockId: string): string { return `feblock:${feBlockId}`; } +function makeTabRouteId(tabId: string): string { + return `tab:${tabId}`; +} + class WshRouter { routeMap: Map; // routeid -> client upstreamClient: AbstractWshClient; @@ -149,4 +149,4 @@ class WshRouter { } } -export { makeFeBlockRouteId, makeWindowRouteId, WshRouter }; +export { makeFeBlockRouteId, makeTabRouteId, WshRouter }; diff --git a/frontend/app/store/wshrpcutil.ts b/frontend/app/store/wshrpcutil.ts index 868725ca6..b0d40bf2e 100644 --- a/frontend/app/store/wshrpcutil.ts +++ b/frontend/app/store/wshrpcutil.ts @@ -3,12 +3,12 @@ import { wpsReconnectHandler } from "@/app/store/wps"; import { WshClient } from "@/app/store/wshclient"; -import { makeWindowRouteId, WshRouter } from "@/app/store/wshrouter"; +import { makeTabRouteId, WshRouter } from "@/app/store/wshrouter"; import { getWSServerEndpoint } from "@/util/endpoints"; import { addWSReconnectHandler, ElectronOverrideOpts, globalWS, initGlobalWS, WSControl } from "./ws"; let DefaultRouter: WshRouter; -let WindowRpcClient: WshClient; +let TabRpcClient: WshClient; async function* rpcResponseGenerator( openRpcs: Map, @@ -126,15 +126,15 @@ function shutdownWshrpc() { globalWS?.shutdown(); } -function initWshrpc(windowId: string): WSControl { +function initWshrpc(tabId: string): WSControl { DefaultRouter = new WshRouter(new UpstreamWshRpcProxy()); const handleFn = (event: WSEventType) => { DefaultRouter.recvRpcMessage(event.data); }; - initGlobalWS(getWSServerEndpoint(), windowId, handleFn); + initGlobalWS(getWSServerEndpoint(), tabId, handleFn); globalWS.connectNow("connectWshrpc"); - WindowRpcClient = new WshClient(makeWindowRouteId(windowId)); - DefaultRouter.registerRoute(WindowRpcClient.routeId, WindowRpcClient); + TabRpcClient = new WshClient(makeTabRouteId(tabId)); + DefaultRouter.registerRoute(TabRpcClient.routeId, TabRpcClient); addWSReconnectHandler(() => { DefaultRouter.reannounceRoutes(); }); @@ -149,12 +149,4 @@ class UpstreamWshRpcProxy implements AbstractWshClient { } } -export { - DefaultRouter, - initElectronWshrpc, - initWshrpc, - sendRpcCommand, - sendRpcResponse, - shutdownWshrpc, - WindowRpcClient, -}; +export { DefaultRouter, initElectronWshrpc, initWshrpc, sendRpcCommand, sendRpcResponse, shutdownWshrpc, TabRpcClient }; diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index f1c4b21b9..5244339ff 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -9,7 +9,7 @@ import { clsx } from "clsx"; import * as React from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; -import { atoms, globalStore } from "@/app/store/global"; +import { atoms, globalStore, refocusNode } from "@/app/store/global"; import "./tab.less"; interface TabProps { @@ -69,8 +69,8 @@ const Tab = React.memo( }; }, []); - const handleDoubleClick = (event) => { - event.stopPropagation(); + const handleRenameTab = (event) => { + event?.stopPropagation(); setIsEditable(true); editableTimeoutRef.current = setTimeout(() => { if (editableRef.current) { @@ -86,6 +86,7 @@ const Tab = React.memo( editableRef.current.innerText = newText; setIsEditable(false); services.ObjectService.UpdateTabName(id, newText); + setTimeout(() => refocusNode(null), 10); }; const handleKeyDown = (event) => { @@ -114,7 +115,7 @@ const Tab = React.memo( editableRef.current.blur(); event.preventDefault(); event.stopPropagation(); - } else if (curLen >= 10 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { + } else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { event.preventDefault(); event.stopPropagation(); } @@ -155,6 +156,7 @@ const Tab = React.memo( const bOrder = fullConfig.presets[b]["display:order"] ?? 0; return aOrder - bOrder; }); + menu.push({ label: "Rename Tab", click: () => handleRenameTab(null) }); menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) }); menu.push({ type: "separator" }); if (bgPresets.length > 0) { @@ -198,7 +200,7 @@ const Tab = React.memo( ref={editableRef} className={clsx("name", { focused: isEditable })} contentEditable={isEditable} - onDoubleClick={handleDoubleClick} + onDoubleClick={handleRenameTab} onBlur={handleBlur} onKeyDown={handleKeyDown} suppressContentEditableWarning={true} diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index eb5b7d863..bcf92d5fc 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -5,7 +5,7 @@ import { Button } from "@/app/element/button"; import { modalsModel } from "@/app/store/modalmodel"; import { WindowDrag } from "@/element/windowdrag"; import { deleteLayoutModelForTab } from "@/layout/index"; -import { atoms, getApi, isDev, PLATFORM } from "@/store/global"; +import { atoms, createTab, getApi, isDev, PLATFORM } from "@/store/global"; import * as services from "@/store/services"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -134,10 +134,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const updateStatusButtonRef = useRef(null); const configErrorButtonRef = useRef(null); const prevAllLoadedRef = useRef(false); - - const windowData = useAtomValue(atoms.waveWindow); - const { activetabid } = windowData; - + const activeTabId = useAtomValue(atoms.staticTabId); const isFullScreen = useAtomValue(atoms.isFullScreen); const settings = useAtomValue(atoms.settingsAtom); @@ -483,17 +480,12 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const handleSelectTab = (tabId: string) => { if (!draggingTabDataRef.current.dragged) { - services.ObjectService.SetActiveTab(tabId); + getApi().setActiveTab(tabId); } }; const handleAddTab = () => { - const newTabName = `T${tabIds.length + 1}`; - services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => { - setTabIds([...tabIds, tabId]); - setNewTabId(tabId); - }); - services.ObjectService.GetObject; + createTab(); tabsWrapperRef.current.style.transition; tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); @@ -509,7 +501,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - services.WindowService.CloseTab(tabId); + getApi().closeTab(tabId); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); deleteLayoutModelForTab(tabId); }; @@ -525,7 +517,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { }, []); const isBeforeActive = (tabId: string) => { - return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1; + return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1; }; function onEllipsisClick() { @@ -560,7 +552,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { id={tabId} isFirst={index === 0} onSelect={() => handleSelectTab(tabId)} - active={activetabid === tabId} + active={activeTabId === tabId} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} onClose={(event) => handleCloseTab(event, tabId)} onLoaded={() => handleTabLoaded(tabId)} diff --git a/frontend/app/view/helpview/helpview.tsx b/frontend/app/view/helpview/helpview.tsx index 5f45c45b7..f843c6ca9 100644 --- a/frontend/app/view/helpview/helpview.tsx +++ b/frontend/app/view/helpview/helpview.tsx @@ -1,16 +1,16 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { BlockNodeModel } from "@/app/block/blocktypes"; import { getApi } from "@/app/store/global"; import { WebView, WebViewModel } from "@/app/view/webview/webview"; -import { NodeModel } from "@/layout/index"; import { fireAndForget } from "@/util/util"; import { atom, useAtomValue } from "jotai"; import { useCallback } from "react"; import "./helpview.less"; class HelpViewModel extends WebViewModel { - constructor(blockId: string, nodeModel: NodeModel) { + constructor(blockId: string, nodeModel: BlockNodeModel) { super(blockId, nodeModel); this.getSettingsMenuItems = undefined; this.viewText = atom((get) => { @@ -44,7 +44,7 @@ class HelpViewModel extends WebViewModel { } } -function makeHelpViewModel(blockId: string, nodeModel: NodeModel) { +function makeHelpViewModel(blockId: string, nodeModel: BlockNodeModel) { return new HelpViewModel(blockId, nodeModel); } diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 4c485a1a2..f0e1a67f9 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -1,15 +1,15 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { BlockNodeModel } from "@/app/block/blocktypes"; import { CenteredDiv } from "@/app/element/quickelems"; import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; -import { NodeModel } from "@/layout/index"; import { atoms, createBlock, getConnStatusAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; @@ -98,7 +98,7 @@ function isStreamingType(mimeType: string): boolean { export class PreviewModel implements ViewModel { viewType: string; blockId: string; - nodeModel: NodeModel; + nodeModel: BlockNodeModel; blockAtom: Atom; viewIcon: Atom; viewName: Atom; @@ -141,7 +141,7 @@ export class PreviewModel implements ViewModel { directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; - constructor(blockId: string, nodeModel: NodeModel) { + constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "preview"; this.blockId = blockId; this.nodeModel = nodeModel; @@ -496,7 +496,7 @@ export class PreviewModel implements ViewModel { async getParentInfo(fileInfo: FileInfo): Promise { const conn = globalStore.get(this.connection); try { - const parentFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { + const parentFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); return parentFileInfo; @@ -517,7 +517,7 @@ export class PreviewModel implements ViewModel { } const conn = globalStore.get(this.connection); try { - const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.path, ".."], { + const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); if (newFileInfo.path != "" && newFileInfo.notfound) { @@ -600,7 +600,7 @@ export class PreviewModel implements ViewModel { } const conn = globalStore.get(this.connection); try { - const newFileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [fileInfo.dir, filePath], { + const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.dir, filePath], { route: makeConnRoute(conn), }); this.updateOpenFileModalAndError(false); @@ -733,7 +733,7 @@ export class PreviewModel implements ViewModel { } } -function makePreviewModel(blockId: string, nodeModel: NodeModel): PreviewModel { +function makePreviewModel(blockId: string, nodeModel: BlockNodeModel): PreviewModel { const previewModel = new PreviewModel(blockId, nodeModel); return previewModel; } diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index 6c15cff47..f4392fc26 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -13,7 +13,7 @@ import * as React from "react"; import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms } from "@/store/global"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import "./sysinfo.less"; @@ -175,7 +175,7 @@ class SysinfoViewModel { this.incrementCount = jotai.atom(null, async (get, set) => { const meta = get(this.blockAtom).meta; const count = meta.count ?? 0; - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { count: count + 1 }, }); @@ -203,7 +203,7 @@ class SysinfoViewModel { try { const numPoints = globalStore.get(this.numPoints); const connName = globalStore.get(this.connection); - const initialData = await RpcApi.EventReadHistoryCommand(WindowRpcClient, { + const initialData = await RpcApi.EventReadHistoryCommand(TabRpcClient, { event: "sysinfo", scope: connName, maxitems: numPoints, @@ -245,7 +245,7 @@ class SysinfoViewModel { type: "radio", checked: currentlySelected == plotType, click: async () => { - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "graph:metrics": dataTypes, "sysinfo:type": plotType }, }); diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx new file mode 100644 index 000000000..275abd3ce --- /dev/null +++ b/frontend/app/view/term/term-wsh.tsx @@ -0,0 +1,76 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms, globalStore } from "@/app/store/global"; +import { makeORef, splitORef } from "@/app/store/wos"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { TermViewModel } from "@/app/view/term/term"; +import { isBlank } from "@/util/util"; +import debug from "debug"; + +const dlog = debug("wave:vdom"); + +export class TermWshClient extends WshClient { + blockId: string; + model: TermViewModel; + + constructor(blockId: string, model: TermViewModel) { + super(makeFeBlockRouteId(blockId)); + this.blockId = blockId; + this.model = model; + } + + async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { + const source = rh.getSource(); + if (isBlank(source)) { + throw new Error("source cannot be blank"); + } + console.log("vdom-create", source, data); + const tabId = globalStore.get(atoms.staticTabId); + if (data.target?.newblock) { + const oref = await RpcApi.CreateBlockCommand(this, { + tabid: tabId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, + }, + magnified: data.target?.magnified, + }); + return oref; + } else { + // in the terminal + // check if there is a current active vdom block + const oldVDomBlockId = globalStore.get(this.model.vdomBlockId); + const oref = await RpcApi.CreateSubBlockCommand(this, { + parentblockid: this.blockId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, + }, + }); + const [_, newVDomBlockId] = splitORef(oref); + if (!isBlank(oldVDomBlockId)) { + // dispose of the old vdom block + setTimeout(() => { + RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId }); + }, 500); + } + setTimeout(() => { + RpcApi.SetMetaCommand(this, { + oref: makeORef("block", this.model.blockId), + meta: { + "term:mode": "vdom", + "term:vdomblockid": newVDomBlockId, + }, + }); + }, 50); + return oref; + } + } +} diff --git a/frontend/app/view/term/term.less b/frontend/app/view/term/term.less index 18e1f26b5..4351ca11c 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -76,7 +76,7 @@ } } - &.term-mode-html { + &.term-mode-vdom { .term-connectelem { display: none; } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 71cad44b4..aa1f30359 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,15 +1,19 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Block, SubBlock } from "@/app/block/block"; +import { BlockNodeModel } from "@/app/block/blocktypes"; import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; -import { VDomView } from "@/app/view/term/vdom"; -import { NodeModel } from "@/layout/index"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { TermWshClient } from "@/app/view/term/term-wsh"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; import { WOS, atoms, + getBlockComponentModel, getConnStatusAtom, getSettingsKeyAtom, globalStore, @@ -18,8 +22,8 @@ import { } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; -import * as util from "@/util/util"; import clsx from "clsx"; +import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; import { TermStickers } from "./termsticker"; @@ -28,122 +32,103 @@ import { computeTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; -const keyMap = { - Enter: "\r", - Backspace: "\x7f", - Tab: "\t", - Escape: "\x1b", - ArrowUp: "\x1b[A", - ArrowDown: "\x1b[B", - ArrowRight: "\x1b[C", - ArrowLeft: "\x1b[D", - Insert: "\x1b[2~", - Delete: "\x1b[3~", - Home: "\x1b[1~", - End: "\x1b[4~", - PageUp: "\x1b[5~", - PageDown: "\x1b[6~", -}; - -function keyboardEventToASCII(event: React.KeyboardEvent): string { - // check modifiers - // if no modifiers are set, just send the key - if (!event.altKey && !event.ctrlKey && !event.metaKey) { - if (event.key == null || event.key == "") { - return ""; - } - if (keyMap[event.key] != null) { - return keyMap[event.key]; - } - if (event.key.length == 1) { - return event.key; - } else { - console.log("not sending keyboard event", event.key, event); - } - } - // if meta or alt is set, there is no ASCII representation - if (event.metaKey || event.altKey) { - return ""; - } - // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value - if (event.ctrlKey) { - if ( - (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || - (event.key >= "a" && event.key <= "z") - ) { - const key = event.key.toUpperCase(); - return String.fromCharCode(key.charCodeAt(0) - 64); - } - } - return ""; -} +const dlog = debug("wave:term"); type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; }; -function vdomText(text: string): VDomElem { - return { - tag: "#text", - text: text, - }; -} - -const testVDom: VDomElem = { - id: "testid1", - tag: "div", - children: [ - { - id: "testh1", - tag: "h1", - children: [vdomText("Hello World")], - }, - { - id: "testp", - tag: "p", - children: [vdomText("This is a paragraph (from VDOM)")], - }, - ], -}; - class TermViewModel { viewType: string; + nodeModel: BlockNodeModel; connected: boolean; termRef: React.RefObject; blockAtom: jotai.Atom; termMode: jotai.Atom; - htmlElemFocusRef: React.RefObject; blockId: string; - nodeModel: NodeModel; viewIcon: jotai.Atom; viewName: jotai.Atom; + viewText: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; + termWshClient: TermWshClient; + shellProcStatusRef: React.MutableRefObject; + vdomBlockId: jotai.Atom; fontSizeAtom: jotai.Atom; termThemeNameAtom: jotai.Atom; - constructor(blockId: string, nodeModel: NodeModel) { + constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "term"; this.blockId = blockId; + this.termWshClient = new TermWshClient(blockId, this); + DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient); this.nodeModel = nodeModel; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.vdomBlockId = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return blockData?.meta?.["term:vdomblockid"]; + }); this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:mode"] ?? "term"; }); this.viewIcon = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "bolt"; + } return "terminal"; }); this.viewName = jotai.atom((get) => { const blockData = get(this.blockAtom); + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "Wave App"; + } if (blockData?.meta?.controller == "cmd") { return "Command"; } return "Terminal"; }); - this.manageConnection = jotai.atom(true); + this.viewText = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return [ + { + elemtype: "iconbutton", + icon: "square-terminal", + title: "Switch back to Terminal", + click: () => { + this.setTermMode("term"); + }, + }, + ]; + } else { + const vdomBlockId = get(this.vdomBlockId); + if (vdomBlockId) { + return [ + { + elemtype: "iconbutton", + icon: "bolt", + title: "Switch to Wave App", + click: () => { + this.setTermMode("vdom"); + }, + }, + ]; + } + } + return null; + }); + this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return false; + } + return true; + }); this.blockBg = jotai.atom((get) => { const blockData = get(this.blockAtom); const fullConfig = get(atoms.fullConfigAtom); @@ -184,6 +169,32 @@ class TermViewModel { }); } + setTermMode(mode: "term" | "vdom") { + if (mode == "term") { + mode = null; + } + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": mode }, + }); + } + + getVDomModel(): VDomModel { + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (!vdomBlockId) { + return null; + } + const bcm = getBlockComponentModel(vdomBlockId); + if (!bcm) { + return null; + } + return bcm.viewModel as VDomModel; + } + + dispose() { + DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); + } + giveFocus(): boolean { let termMode = globalStore.get(this.termMode); if (termMode == "term") { @@ -191,17 +202,74 @@ class TermViewModel { this.termRef.current.terminal.focus(); return true; } - } else { - if (this.htmlElemFocusRef?.current) { - this.htmlElemFocusRef.current.focus(); - return true; - } } return false; } + keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { + const blockAtom = WOS.getWaveObjectAtom(`block:${this.blockId}`); + const blockData = globalStore.get(blockAtom); + const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom"; + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (newTermMode == "vdom" && !vdomBlockId) { + return; + } + this.setTermMode(newTermMode); + return true; + } + const blockData = globalStore.get(this.blockAtom); + if (blockData.meta?.["term:mode"] == "vdom") { + const vdomModel = this.getVDomModel(); + return vdomModel?.keyDownHandler(waveEvent); + } + return false; + } + + handleTerminalKeydown(event: KeyboardEvent): boolean { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); + if (waveEvent.type != "keydown") { + return true; + } + if (this.keyDownHandler(waveEvent)) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + // deal with terminal specific keybindings + if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { + const p = navigator.clipboard.readText(); + p.then((text) => { + this.termRef.current?.terminal.paste(text); + }); + event.preventDefault(); + event.stopPropagation(); + return false; + } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { + const sel = this.termRef.current?.terminal.getSelection(); + navigator.clipboard.writeText(sel); + event.preventDefault(); + event.stopPropagation(); + return false; + } + if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { + // restart + const tabId = globalStore.get(atoms.staticTabId); + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId }); + prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e)); + return false; + } + const globalKeys = getAllGlobalKeyBindings(); + for (const key of globalKeys) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + return false; + } + } + return true; + } + setTerminalTheme(themeName: string) { - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:theme": themeName }, }); @@ -235,7 +303,7 @@ class TermViewModel { type: "checkbox", checked: overrideFontSize == fontSize, click: () => { - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:fontsize": fontSize }, }); @@ -248,7 +316,7 @@ class TermViewModel { type: "checkbox", checked: overrideFontSize == null, click: () => { - RpcApi.SetMetaCommand(WindowRpcClient, { + RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "term:fontsize": null }, }); @@ -270,8 +338,8 @@ class TermViewModel { rows: this.termRef.current?.terminal?.rows, cols: this.termRef.current?.terminal?.cols, }; - const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { - tabid: globalStore.get(atoms.activeTabId), + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), blockid: this.blockId, forcerestart: true, rtopts: { termsize: termsize }, @@ -283,7 +351,7 @@ class TermViewModel { } } -function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel { +function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel { return new TermViewModel(blockId, nodeModel); } @@ -314,66 +382,74 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => return null; }); +const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => { + React.useEffect(() => { + const unsub = waveEventSubscribe({ + eventType: "blockclose", + scope: WOS.makeORef("block", vdomBlockId), + handler: (event) => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { + "term:mode": null, + "term:vdomblockid": null, + }, + }); + }, + }); + return () => { + unsub(); + }; + }, []); + const isFocusedAtom = jotai.atom((get) => { + return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom"; + }); + let vdomNodeModel = { + blockId: vdomBlockId, + isFocused: isFocusedAtom, + focusNode: () => { + model.nodeModel.focusNode(); + }, + onClose: () => { + if (vdomBlockId != null) { + RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId }); + } + }, + }; + return ( +
+ +
+ ); +}; + +const TermVDomNode = ({ blockId, model }: TerminalViewProps) => { + const vdomBlockId = jotai.useAtomValue(model.vdomBlockId); + if (vdomBlockId == null) { + return null; + } + return ; +}; + const TerminalView = ({ blockId, model }: TerminalViewProps) => { - const viewRef = React.createRef(); + const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); const termRef = React.useRef(null); model.termRef = termRef; - const shellProcStatusRef = React.useRef(null); - const htmlElemFocusRef = React.useRef(null); - model.htmlElemFocusRef = htmlElemFocusRef; + const spstatusRef = React.useRef(null); + model.shellProcStatusRef = spstatusRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); + let termMode = blockData?.meta?.["term:mode"] ?? "term"; + if (termMode != "term" && termMode != "vdom") { + termMode = "term"; + } + const termModeRef = React.useRef(termMode); const termFontSize = jotai.useAtomValue(model.fontSizeAtom); React.useEffect(() => { - function handleTerminalKeydown(event: KeyboardEvent): boolean { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (waveEvent.type != "keydown") { - return true; - } - // deal with terminal specific keybindings - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - event.preventDefault(); - event.stopPropagation(); - RpcApi.SetMetaCommand(WindowRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - const p = navigator.clipboard.readText(); - p.then((text) => { - termRef.current?.terminal.paste(text); - }); - event.preventDefault(); - event.stopPropagation(); - return false; - } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { - const sel = termRef.current?.terminal.getSelection(); - navigator.clipboard.writeText(sel); - event.preventDefault(); - event.stopPropagation(); - return false; - } - if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { - // restart - const tabId = globalStore.get(atoms.activeTabId); - const prtn = RpcApi.ControllerResyncCommand(WindowRpcClient, { tabid: tabId, blockid: blockId }); - prtn.catch((e) => console.log("error controller resync (enter)", blockId, e)); - return false; - } - const globalKeys = getAllGlobalKeyBindings(); - for (const key of globalKeys) { - if (keyutil.checkKeyPressed(waveEvent, key)) { - return false; - } - } - return true; - } const fullConfig = globalStore.get(atoms.fullConfigAtom); const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]); const themeCopy = { ...termTheme }; @@ -406,7 +482,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { scrollback: termScrollback, }, { - keydownHandler: handleTerminalKeydown, + keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], } ); @@ -428,29 +504,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { }; }, [blockId, termSettings, termFontSize]); - const handleHtmlKeyDown = (event: React.KeyboardEvent) => { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - // reset term:mode - RpcApi.SetMetaCommand(WindowRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; + React.useEffect(() => { + if (termModeRef.current == "vdom" && termMode == "term") { + // focus the terminal + model.giveFocus(); } - const asciiVal = keyboardEventToASCII(event); - if (asciiVal.length == 0) { - return false; - } - const b64data = util.stringToBase64(asciiVal); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: blockId, inputdata64: b64data }); - return true; - }; - - let termMode = blockData?.meta?.["term:mode"] ?? "term"; - if (termMode != "term" && termMode != "html") { - termMode = "term"; - } + termModeRef.current = termMode; + }, [termMode]); // set intitial controller status, and then subscribe for updates React.useEffect(() => { @@ -458,7 +518,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { if (status == null) { return; } - shellProcStatusRef.current = status; + model.shellProcStatusRef.current = status; if (status == "running") { termRef.current?.setIsRunning(true); } else { @@ -494,28 +554,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
-
{ - if (htmlElemFocusRef.current != null) { - htmlElemFocusRef.current.focus(); - } - }} - > -
- {}} - /> -
-
- -
-
+ ); }; diff --git a/frontend/app/view/term/termsticker.tsx b/frontend/app/view/term/termsticker.tsx index b0126174e..1d262b31e 100644 --- a/frontend/app/view/term/termsticker.tsx +++ b/frontend/app/view/term/termsticker.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { createBlock } from "@/store/global"; import { getWebServerEndpoint } from "@/util/endpoints"; import { stringToBase64 } from "@/util/util"; @@ -101,7 +101,7 @@ function TermSticker({ sticker, config }: { sticker: StickerType; config: Sticke console.log("clickHandler", sticker.clickcmd, sticker.clickblockdef); if (sticker.clickcmd) { const b64data = stringToBase64(sticker.clickcmd); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: config.blockId, inputdata64: b64data }); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: config.blockId, inputdata64: b64data }); } if (sticker.clickblockdef) { createBlock(sticker.clickblockdef); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 1fa250fa7..270850c6c 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -4,7 +4,7 @@ import { getFileSubject } from "@/app/store/wps"; import { sendWSCommand } from "@/app/store/ws"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; import * as util from "@/util/util"; @@ -168,7 +168,7 @@ export class TermWrap { handleTermData(data: string) { const b64data = util.stringToBase64(data); - RpcApi.ControllerInputCommand(WindowRpcClient, { blockid: this.blockId, inputdata64: b64data }); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } addFocusListener(focusFn: () => void) { @@ -244,10 +244,10 @@ export class TermWrap { async resyncController(reason: string) { dlog("resync controller", this.blockId, reason); - const tabId = globalStore.get(atoms.activeTabId); + const tabId = globalStore.get(atoms.staticTabId); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; try { - await RpcApi.ControllerResyncCommand(WindowRpcClient, { + await RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId, rtopts: rtOpts, diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx deleted file mode 100644 index 4e87ed46d..000000000 --- a/frontend/app/view/term/vdom.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import * as React from "react"; - -const AllowedTags: { [tagName: string]: boolean } = { - div: true, - b: true, - i: true, - p: true, - s: true, - span: true, - a: true, - img: true, - h1: true, - h2: true, - h3: true, - h4: true, - h5: true, - h6: true, - ul: true, - ol: true, - li: true, - input: true, - button: true, - textarea: true, - select: true, - option: true, - form: true, -}; - -function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void { - return (e: any) => { - if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { - let waveEvent = adaptFromReactOrNativeKeyEvent(e); - for (let keyDesc of fnDecl["#keys"]) { - if (checkKeyPressed(waveEvent, keyDesc)) { - e.preventDefault(); - e.stopPropagation(); - callFunc(e, compId, propName); - return; - } - } - return; - } - if (fnDecl["#preventDefault"]) { - e.preventDefault(); - } - if (fnDecl["#stopPropagation"]) { - e.stopPropagation(); - } - callFunc(e, compId, propName); - }; -} - -function convertElemToTag(elem: VDomElem): JSX.Element | string { - if (elem == null) { - return null; - } - if (elem.tag == "#text") { - return elem.text; - } - return React.createElement(VDomTag, { elem: elem, key: elem.id }); -} - -function isObject(v: any): boolean { - return v != null && !Array.isArray(v) && typeof v === "object"; -} - -function isArray(v: any): boolean { - return Array.isArray(v); -} - -function callFunc(e: any, compId: string, propName: string) { - console.log("callfunc", compId, propName); -} - -function updateRefFunc(elem: any, ref: VDomRefType) { - console.log("updateref", ref["#ref"], elem); -} - -function VDomTag({ elem }: { elem: VDomElem }) { - if (!AllowedTags[elem.tag]) { - return
{"Invalid Tag <" + elem.tag + ">"}
; - } - let props = {}; - for (let key in elem.props) { - let val = elem.props[key]; - if (val == null) { - continue; - } - if (key == "ref") { - if (val == null) { - continue; - } - if (isObject(val) && "#ref" in val) { - props[key] = (elem: HTMLElement) => { - updateRefFunc(elem, val); - }; - } - continue; - } - if (isObject(val) && "#func" in val) { - props[key] = convertVDomFunc(val, elem.id, key); - continue; - } - } - let childrenComps: (string | JSX.Element)[] = []; - if (elem.children) { - for (let child of elem.children) { - if (child == null) { - continue; - } - childrenComps.push(convertElemToTag(child)); - } - } - if (elem.tag == "#fragment") { - return childrenComps; - } - return React.createElement(elem.tag, props, childrenComps); -} - -function VDomView({ rootNode }: { rootNode: VDomElem }) { - let rtn = convertElemToTag(rootNode); - return
{rtn}
; -} - -export { VDomView }; diff --git a/frontend/app/view/vdom/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx new file mode 100644 index 000000000..07f301fdb --- /dev/null +++ b/frontend/app/view/vdom/vdom-model.tsx @@ -0,0 +1,595 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; +import { makeORef } from "@/app/store/wos"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import debug from "debug"; +import * as jotai from "jotai"; + +const dlog = debug("wave:vdom"); + +type AtomContainer = { + val: any; + beVal: any; + usedBy: Set; +}; + +type RefContainer = { + refFn: (elem: HTMLElement) => void; + vdomRef: VDomRef; + elem: HTMLElement; + updated: boolean; +}; + +function makeVDomIdMap(vdom: VDomElem, idMap: Map) { + if (vdom == null) { + return; + } + if (vdom.waveid != null) { + idMap.set(vdom.waveid, vdom); + } + if (vdom.children == null) { + return; + } + for (let child of vdom.children) { + makeVDomIdMap(child, idMap); + } +} + +function convertEvent(e: React.SyntheticEvent, fromProp: string): any { + if (e == null) { + return null; + } + if (fromProp == "onClick") { + return { type: "click" }; + } + if (fromProp == "onKeyDown") { + const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent); + return waveKeyEvent; + } + if (fromProp == "onFocus") { + return { type: "focus" }; + } + if (fromProp == "onBlur") { + return { type: "blur" }; + } + return { type: "unknown" }; +} + +class VDomWshClient extends WshClient { + model: VDomModel; + + constructor(model: VDomModel) { + super(makeFeBlockRouteId(model.blockId)); + this.model = model; + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + console.log("async-initiation", rh.getSource(), data); + this.model.queueUpdate(true); + } +} + +export class VDomModel { + blockId: string; + nodeModel: BlockNodeModel; + viewType: string; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewRef: React.RefObject = { current: null }; + vdomRoot: jotai.PrimitiveAtom = jotai.atom(); + atoms: Map = new Map(); // key is atomname + refs: Map = new Map(); // key is refid + batchedEvents: VDomEvent[] = []; + messages: VDomMessage[] = []; + needsResync: boolean = true; + vdomNodeVersion: WeakMap> = new WeakMap(); + compoundAtoms: Map> = new Map(); + rootRefId: string = crypto.randomUUID(); + backendRoute: jotai.Atom; + backendOpts: VDomBackendOpts; + shouldDispose: boolean; + disposed: boolean; + hasPendingRequest: boolean; + needsUpdate: boolean; + maxNormalUpdateIntervalMs: number = 100; + needsImmediateUpdate: boolean; + lastUpdateTs: number = 0; + queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + contextActive: jotai.PrimitiveAtom; + wshClient: VDomWshClient; + persist: jotai.Atom; + routeGoneUnsub: () => void; + routeConfirmed: boolean = false; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "vdom"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.contextActive = jotai.atom(false); + this.reset(); + this.viewIcon = jotai.atom("bolt"); + this.viewName = jotai.atom("Wave App"); + this.backendRoute = jotai.atom((get) => { + const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + }); + this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); + this.wshClient = new VDomWshClient(this); + DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); + const curBackendRoute = globalStore.get(this.backendRoute); + if (curBackendRoute) { + this.queueUpdate(true); + } + this.routeGoneUnsub = waveEventSubscribe({ + eventType: "route:gone", + scope: curBackendRoute, + handler: (event: WaveEvent) => { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + }, + }); + RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( + (routeOk: boolean) => { + if (routeOk) { + this.routeConfirmed = true; + this.queueUpdate(true); + } else { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + } + } + ); + } + + dispose() { + DefaultRouter.unregisterRoute(this.wshClient.routeId); + this.routeGoneUnsub?.(); + } + + reset() { + globalStore.set(this.vdomRoot, null); + this.atoms.clear(); + this.refs.clear(); + this.batchedEvents = []; + this.messages = []; + this.needsResync = true; + this.vdomNodeVersion = new WeakMap(); + this.compoundAtoms.clear(); + this.rootRefId = crypto.randomUUID(); + this.backendOpts = {}; + this.shouldDispose = false; + this.disposed = false; + this.hasPendingRequest = false; + this.needsUpdate = false; + this.maxNormalUpdateIntervalMs = 100; + this.needsImmediateUpdate = false; + this.lastUpdateTs = 0; + this.queuedUpdate = null; + globalStore.set(this.contextActive, false); + } + + getBackendRoute(): string { + const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { + if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { + this.shouldDispose = true; + this.queueUpdate(true); + return true; + } + if (this.backendOpts?.globalkeyboardevents) { + if (e.cmd || e.meta) { + return false; + } + this.batchedEvents.push({ + waveid: null, + eventtype: "onKeyDown", + eventdata: e, + }); + this.queueUpdate(); + return true; + } + return false; + } + + hasRefUpdates() { + for (let ref of this.refs.values()) { + if (ref.updated) { + return true; + } + } + return false; + } + + getRefUpdates(): VDomRefUpdate[] { + let updates: VDomRefUpdate[] = []; + for (let ref of this.refs.values()) { + if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { + const ru: VDomRefUpdate = { + refid: ref.vdomRef.refid, + hascurrent: ref.vdomRef.hascurrent, + }; + if (ref.vdomRef.trackposition && ref.elem != null) { + ru.position = { + offsetheight: ref.elem.offsetHeight, + offsetwidth: ref.elem.offsetWidth, + scrollheight: ref.elem.scrollHeight, + scrollwidth: ref.elem.scrollWidth, + scrolltop: ref.elem.scrollTop, + boundingclientrect: ref.elem.getBoundingClientRect(), + }; + } + updates.push(ru); + ref.updated = false; + } + } + return updates; + } + + queueUpdate(quick: boolean = false, delay: number = 10) { + if (this.disposed) { + return; + } + this.needsUpdate = true; + let nowTs = Date.now(); + if (delay > this.maxNormalUpdateIntervalMs) { + delay = this.maxNormalUpdateIntervalMs; + } + if (quick) { + if (this.queuedUpdate) { + if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { + return; + } + clearTimeout(this.queuedUpdate.timeoutId); + this.queuedUpdate = null; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(true); + }, 0); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; + return; + } + if (this.queuedUpdate) { + return; + } + let lastUpdateDiff = nowTs - this.lastUpdateTs; + let timeoutMs: number = null; + if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { + // it has been a while since the last update, so use delay + timeoutMs = delay; + } else { + timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; + } + if (timeoutMs < delay) { + timeoutMs = delay; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(false); + }, timeoutMs); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; + } + + async _sendRenderRequest(force: boolean) { + this.queuedUpdate = null; + if (this.disposed || !this.routeConfirmed) { + return; + } + if (this.hasPendingRequest) { + if (force) { + this.needsImmediateUpdate = true; + } + return; + } + if (!force && !this.needsUpdate) { + return; + } + const backendRoute = globalStore.get(this.backendRoute); + if (backendRoute == null) { + console.log("vdom-model", "no backend route"); + return; + } + this.hasPendingRequest = true; + this.needsImmediateUpdate = false; + try { + const feUpdate = this.createFeUpdate(); + dlog("fe-update", feUpdate); + const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); + this.handleBackendUpdate(beUpdate); + } finally { + this.lastUpdateTs = Date.now(); + this.hasPendingRequest = false; + } + if (this.needsImmediateUpdate) { + this.queueUpdate(true); + } + } + + getAtomContainer(atomName: string): AtomContainer { + let container = this.atoms.get(atomName); + if (container == null) { + container = { + val: null, + beVal: null, + usedBy: new Set(), + }; + this.atoms.set(atomName, container); + } + return container; + } + + getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { + let container = this.refs.get(vdomRef.refid); + if (container == null) { + container = { + refFn: (elem: HTMLElement) => { + container.elem = elem; + const hasElem = elem != null; + if (vdomRef.hascurrent != hasElem) { + container.updated = true; + vdomRef.hascurrent = hasElem; + } + }, + vdomRef: vdomRef, + elem: null, + updated: false, + }; + this.refs.set(vdomRef.refid, container); + } + return container; + } + + tagUseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.add(waveId); + } + } + + tagUnuseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.delete(waveId); + } + } + + getVDomNodeVersionAtom(vdom: VDomElem) { + let atom = this.vdomNodeVersion.get(vdom); + if (atom == null) { + atom = jotai.atom(0); + this.vdomNodeVersion.set(vdom, atom); + } + return atom; + } + + incVDomNodeVersion(vdom: VDomElem) { + if (vdom == null) { + return; + } + const atom = this.getVDomNodeVersionAtom(vdom); + globalStore.set(atom, globalStore.get(atom) + 1); + } + + addErrorMessage(message: string) { + this.messages.push({ + messagetype: "error", + message: message, + }); + } + + handleRenderUpdates(update: VDomBackendUpdate, idMap: Map) { + if (!update.renderupdates) { + return; + } + for (let renderUpdate of update.renderupdates) { + if (renderUpdate.updatetype == "root") { + globalStore.set(this.vdomRoot, renderUpdate.vdom); + continue; + } + if (renderUpdate.updatetype == "append") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + parent.children.push(renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "replace") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children[renderUpdate.index] = renderUpdate.vdom; + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "remove") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 1); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "insert") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); + } + } + + setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map) { + dlog("setAtomValue", atomName, value, fromBe); + let container = this.getAtomContainer(atomName); + container.val = value; + if (fromBe) { + container.beVal = value; + } + for (let id of container.usedBy) { + this.incVDomNodeVersion(idMap.get(id)); + } + } + + handleStateSync(update: VDomBackendUpdate, idMap: Map) { + if (update.statesync == null) { + return; + } + for (let sync of update.statesync) { + this.setAtomValue(sync.atom, sync.value, true, idMap); + } + } + + getRefElem(refId: string): HTMLElement { + if (refId == this.rootRefId) { + return this.viewRef.current; + } + const ref = this.refs.get(refId); + return ref?.elem; + } + + handleRefOperations(update: VDomBackendUpdate, idMap: Map) { + if (update.refoperations == null) { + return; + } + for (let refOp of update.refoperations) { + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); + continue; + } + if (refOp.op == "focus") { + if (elem == null) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); + continue; + } + try { + elem.focus(); + } catch (e) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); + } + } else { + this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); + } + } + } + + handleBackendUpdate(update: VDomBackendUpdate) { + if (update == null) { + return; + } + globalStore.set(this.contextActive, true); + const idMap = new Map(); + const vdomRoot = globalStore.get(this.vdomRoot); + if (update.opts != null) { + this.backendOpts = update.opts; + } + makeVDomIdMap(vdomRoot, idMap); + this.handleRenderUpdates(update, idMap); + this.handleStateSync(update, idMap); + this.handleRefOperations(update, idMap); + if (update.messages) { + for (let message of update.messages) { + console.log("vdom-message", this.blockId, message.messagetype, message.message); + if (message.stacktrace) { + console.log("vdom-message-stacktrace", message.stacktrace); + } + } + } + } + + callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) { + const eventData = convertEvent(e, propName); + if (fnDecl.globalevent) { + const waveEvent: VDomEvent = { + waveid: null, + eventtype: fnDecl.globalevent, + eventdata: eventData, + }; + this.batchedEvents.push(waveEvent); + } else { + const vdomEvent: VDomEvent = { + waveid: compId, + eventtype: propName, + eventdata: eventData, + }; + this.batchedEvents.push(vdomEvent); + } + this.queueUpdate(); + } + + createFeUpdate(): VDomFrontendUpdate { + const blockORef = makeORef("block", this.blockId); + const blockAtom = WOS.getWaveObjectAtom(blockORef); + const blockData = globalStore.get(blockAtom); + const isBlockFocused = globalStore.get(this.nodeModel.isFocused); + const renderContext: VDomRenderContext = { + blockid: this.blockId, + focused: isBlockFocused, + width: this.viewRef?.current?.offsetWidth ?? 0, + height: this.viewRef?.current?.offsetHeight ?? 0, + rootrefid: this.rootRefId, + background: false, + }; + const feUpdate: VDomFrontendUpdate = { + type: "frontendupdate", + ts: Date.now(), + blockid: this.blockId, + rendercontext: renderContext, + dispose: this.shouldDispose, + resync: this.needsResync, + events: this.batchedEvents, + refupdates: this.getRefUpdates(), + }; + this.needsResync = false; + this.batchedEvents = []; + if (this.shouldDispose) { + this.disposed = true; + } + return feUpdate; + } +} diff --git a/frontend/app/view/vdom/vdom-utils.tsx b/frontend/app/view/vdom/vdom-utils.tsx new file mode 100644 index 000000000..4df721a63 --- /dev/null +++ b/frontend/app/view/vdom/vdom-utils.tsx @@ -0,0 +1,58 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import type { CssNode, List, ListItem } from "css-tree"; +import * as csstree from "css-tree"; + +const TextTag = "#text"; + +// TODO support binding +export function getTextChildren(elem: VDomElem): string { + if (elem.tag == TextTag) { + return elem.text; + } + if (!elem.children) { + return null; + } + const textArr = elem.children.map((child) => { + return getTextChildren(child); + }); + return textArr.join(""); +} + +export function convertVDomId(model: VDomModel, id: string): string { + return model.blockId + "::" + id; +} + +export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperClassName: string) { + try { + const ast = csstree.parse(cssText); + csstree.walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + // Remove disallowed @rules + const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"]; + if (node.type === "Atrule" && blockedRules.includes(node.name)) { + list.remove(item); + } + // Remove :root selectors + if ( + node.type === "Selector" && + node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root") + ) { + list.remove(item); + } + + if (node.type === "IdSelector") { + node.name = convertVDomId(model, node.name); + } + }, + }); + const sanitizedCss = csstree.generate(ast); + return `.${wrapperClassName} { ${sanitizedCss} }`; + } catch (error) { + // TODO better error handling + console.error("CSS processing error:", error); + return null; + } +} diff --git a/frontend/app/view/vdom/vdom.less b/frontend/app/view/vdom/vdom.less new file mode 100644 index 000000000..3e959889d --- /dev/null +++ b/frontend/app/view/vdom/vdom.less @@ -0,0 +1,5 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.view-vdom { +} diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx new file mode 100644 index 000000000..08adfee7e --- /dev/null +++ b/frontend/app/view/vdom/vdom.tsx @@ -0,0 +1,354 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Markdown } from "@/app/element/markdown"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import clsx from "clsx"; +import debug from "debug"; +import * as jotai from "jotai"; +import * as React from "react"; + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { convertVDomId, getTextChildren, validateAndWrapCss } from "@/app/view/vdom/vdom-utils"; +import "./vdom.less"; + +const TextTag = "#text"; +const FragmentTag = "#fragment"; +const WaveTextTag = "wave:text"; +const WaveNullTag = "wave:null"; +const StyleTagName = "style"; + +const VDomObjType_Ref = "ref"; +const VDomObjType_Binding = "binding"; +const VDomObjType_Func = "func"; + +const dlog = debug("wave:vdom"); + +type VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => JSX.Element; + +const WaveTagMap: Record = { + "wave:markdown": WaveMarkdown, +}; + +const AllowedSimpleTags: { [tagName: string]: boolean } = { + div: true, + b: true, + i: true, + p: true, + s: true, + span: true, + a: true, + img: true, + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + li: true, + input: true, + button: true, + textarea: true, + select: true, + option: true, + form: true, + label: true, + table: true, + thead: true, + tbody: true, + tr: true, + th: true, + td: true, + hr: true, + br: true, + pre: true, + code: true, +}; + +const IdAttributes = { + id: true, + for: true, + "aria-labelledby": true, + "aria-describedby": true, + "aria-controls": true, + "aria-owns": true, + form: true, + headers: true, + usemap: true, + list: true, +}; + +function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { + return (e: any) => { + if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { + let waveEvent = adaptFromReactOrNativeKeyEvent(e); + for (let keyDesc of fnDecl.keys || []) { + if (checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + e.stopPropagation(); + model.callVDomFunc(fnDecl, e, compId, propName); + return; + } + } + return; + } + if (fnDecl.preventdefault) { + e.preventDefault(); + } + if (fnDecl.stoppropagation) { + e.stopPropagation(); + } + model.callVDomFunc(fnDecl, e, compId, propName); + }; +} + +function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string { + if (elem == null) { + return null; + } + if (elem.tag == TextTag) { + return elem.text; + } + return React.createElement(VDomTag, { key: elem.waveid, elem, model }); +} + +function isObject(v: any): boolean { + return v != null && !Array.isArray(v) && typeof v === "object"; +} + +function isArray(v: any): boolean { + return Array.isArray(v); +} + +function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] { + const bindName = binding.bind; + if (bindName == null || bindName == "") { + return [null, []]; + } + // for now we only recognize $.[atomname] bindings + if (!bindName.startsWith("$.")) { + return [null, []]; + } + const atomName = bindName.substring(2); + if (atomName == "") { + return [null, []]; + } + const atom = model.getAtomContainer(atomName); + if (atom == null) { + return [null, []]; + } + return [atom.val, [atomName]]; +} + +type GenericPropsType = { [key: string]: any }; + +// returns props, and a set of atom keys used in the props +function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set] { + let props: GenericPropsType = {}; + let atomKeys = new Set(); + if (elem.props == null) { + return [props, atomKeys]; + } + for (let key in elem.props) { + let val = elem.props[key]; + if (val == null) { + continue; + } + if (key == "ref") { + if (val == null) { + continue; + } + if (isObject(val) && val.type == VDomObjType_Ref) { + const valRef = val as VDomRef; + const refContainer = model.getOrCreateRefContainer(valRef); + props[key] = refContainer.refFn; + } + continue; + } + if (isObject(val) && val.type == VDomObjType_Func) { + const valFunc = val as VDomFunc; + props[key] = convertVDomFunc(model, valFunc, elem.waveid, key); + continue; + } + if (isObject(val) && val.type == VDomObjType_Binding) { + const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model); + props[key] = propVal; + for (let atomDep of atomDeps) { + atomKeys.add(atomDep); + } + continue; + } + if (key == "style" && isObject(val)) { + // assuming the entire style prop wasn't bound, look through the individual keys and bind them + for (let styleKey in val) { + let styleVal = val[styleKey]; + if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) { + const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model); + val[styleKey] = stylePropVal; + for (let styleAtomDep of styleAtomDeps) { + atomKeys.add(styleAtomDep); + } + } + } + // fallthrough to set props[key] = val + } + if (IdAttributes[key]) { + props[key] = convertVDomId(model, val); + continue; + } + props[key] = val; + } + return [props, atomKeys]; +} + +function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] { + let childrenComps: (string | JSX.Element)[] = []; + if (elem.children == null) { + return childrenComps; + } + for (let child of elem.children) { + if (child == null) { + continue; + } + childrenComps.push(convertElemToTag(child, model)); + } + return childrenComps; +} + +function stringSetsEqual(set1: Set, set2: Set): boolean { + if (set1.size != set2.size) { + return false; + } + for (let elem of set1) { + if (!set2.has(elem)) { + return false; + } + } + return true; +} + +function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType { + const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); + const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); + let [props, atomKeys] = convertProps(elem, model); + React.useEffect(() => { + if (stringSetsEqual(atomKeys, oldAtomKeys)) { + return; + } + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + model.tagUseAtoms(elem.waveid, atomKeys); + setOldAtomKeys(atomKeys); + }, [atomKeys]); + React.useEffect(() => { + return () => { + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + }; + }, []); + return props; +} + +function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); + return ( + + ); +} + +function StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const styleText = getTextChildren(elem); + if (styleText == null) { + return null; + } + const wrapperClassName = "vdom-" + model.blockId; + // TODO handle errors + const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName); + if (sanitizedCss == null) { + return null; + } + return ; +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); + if (elem.tag == WaveNullTag) { + return null; + } + if (elem.tag == WaveTextTag) { + return props.text; + } + const waveTag = WaveTagMap[elem.tag]; + if (waveTag) { + return waveTag({ elem, model }); + } + if (elem.tag == StyleTagName) { + return ; + } + if (!AllowedSimpleTags[elem.tag]) { + return
{"Invalid Tag <" + elem.tag + ">"}
; + } + let childrenComps = convertChildren(elem, model); + if (elem.tag == FragmentTag) { + return childrenComps; + } + props.key = "e-" + elem.waveid; + return React.createElement(elem.tag, props, childrenComps); +} + +function vdomText(text: string): VDomElem { + return { + tag: "#text", + text: text, + }; +} + +const testVDom: VDomElem = { + waveid: "testid1", + tag: "div", + children: [ + { + waveid: "testh1", + tag: "h1", + children: [vdomText("Hello World")], + }, + { + waveid: "testp", + tag: "p", + children: [vdomText("This is a paragraph (from VDOM)")], + }, + ], +}; + +function VDomRoot({ model }: { model: VDomModel }) { + let rootNode = jotai.useAtomValue(model.vdomRoot); + if (model.viewRef.current == null || rootNode == null) { + return null; + } + dlog("render", rootNode); + let rtn = convertElemToTag(rootNode, model); + return
{rtn}
; +} + +function makeVDomModel(blockId: string, nodeModel: BlockNodeModel): VDomModel { + return new VDomModel(blockId, nodeModel); +} + +type VDomViewProps = { + model: VDomModel; + blockId: string; +}; + +function VDomView({ blockId, model }: VDomViewProps) { + let viewRef = React.useRef(null); + model.viewRef = viewRef; + const vdomClass = "vdom-" + blockId; + return ( +
+ +
+ ); +} + +export { makeVDomModel, VDomView }; diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index db6bb67ef..7c100542d 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -5,8 +5,8 @@ import { Button } from "@/app/element/button"; import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; @@ -182,26 +182,41 @@ export class WaveAiModel implements ViewModel { }); } } - + const dropdownItems = Object.entries(presets) + .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) + .map( + (preset) => + ({ + label: preset[1]["display:name"], + onClick: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + ...preset[1], + "ai:preset": preset[0], + }); + }), + }) as MenuItem + ); + dropdownItems.push({ + label: "Add AI preset...", + onClick: () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/presets/ai.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, true); + }); + }, + }); viewTextChildren.push({ elemtype: "menubutton", text: presetName, title: "Select AI Configuration", - items: Object.entries(presets) - .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - ...preset[1], - "ai:preset": preset[0], - }); - }), - }) as MenuItem - ), + items: dropdownItems, }); return viewTextChildren; }); @@ -274,7 +289,7 @@ export class WaveAiModel implements ViewModel { }; let fullMsg = ""; try { - const aiGen = RpcApi.StreamWaveAiCommand(WindowRpcClient, beMsg, { timeout: opts.timeoutms }); + const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); for await (const msg of aiGen) { fullMsg += msg.text ?? ""; globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index d9f038a73..42fa739e5 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -1,12 +1,12 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { BlockNodeModel } from "@/app/block/blocktypes"; import { getApi, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { ObjectService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { WindowRpcClient } from "@/app/store/wshrpcutil"; -import { NodeModel } from "@/layout/index"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; @@ -44,12 +44,12 @@ export class WebViewModel implements ViewModel { refreshIcon: PrimitiveAtom; webviewRef: React.RefObject; urlInputRef: React.RefObject; - nodeModel: NodeModel; + nodeModel: BlockNodeModel; endIconButtons?: Atom; mediaPlaying: PrimitiveAtom; mediaMuted: PrimitiveAtom; - constructor(blockId: string, nodeModel: NodeModel) { + constructor(blockId: string, nodeModel: BlockNodeModel) { this.nodeModel = nodeModel; this.viewType = "web"; this.blockId = blockId; @@ -369,17 +369,17 @@ export class WebViewModel implements ViewModel { if (url != null && url != "") { switch (scope) { case "block": - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { pinnedurl: url }, }); break; case "global": - await RpcApi.SetMetaCommand(WindowRpcClient, { + await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { pinnedurl: "" }, }); - await RpcApi.SetConfigCommand(WindowRpcClient, { "web:defaulturl": url }); + await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); break; } } @@ -459,7 +459,7 @@ export class WebViewModel implements ViewModel { } } -function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel { +function makeWebViewModel(blockId: string, nodeModel: BlockNodeModel): WebViewModel { const webviewModel = new WebViewModel(blockId, nodeModel); return webviewModel; } diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 343e9d602..157ab070c 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -94,20 +94,18 @@ const Widget = memo(({ widget }: { widget: WidgetConfigType }) => { }); const WorkspaceElem = memo(() => { - const windowData = useAtomValue(atoms.waveWindow); - const activeTabId = windowData?.activetabid; + const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); - return (
- - {activeTabId == "" ? ( + + {tabId == "" ? ( No Active Tab ) : ( <> - + diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts index 57ce26312..44514e0ef 100644 --- a/frontend/layout/index.ts +++ b/frontend/layout/index.ts @@ -5,7 +5,7 @@ import { TileLayout } from "./lib/TileLayout"; import { LayoutModel } from "./lib/layoutModel"; import { deleteLayoutModelForTab, - getLayoutModelForActiveTab, + getLayoutModelForStaticTab, getLayoutModelForTab, getLayoutModelForTabById, useDebouncedNodeInnerRect, @@ -37,7 +37,7 @@ import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/ty export { deleteLayoutModelForTab, DropDirection, - getLayoutModelForActiveTab, + getLayoutModelForStaticTab, getLayoutModelForTab, getLayoutModelForTabById, LayoutModel, diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index eefdfa03c..e8cfb6104 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -128,7 +128,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr ); } - export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; interface DisplayNodesWrapperProps { @@ -247,6 +246,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { magnified: addlProps?.isMagnifiedNode, "last-magnified": addlProps?.isLastMagnifiedNode, })} + key={node.id} ref={tileNodeRef} id={node.id} style={addlProps?.transform} diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts index d732ab146..3c1bdcd3f 100644 --- a/frontend/layout/lib/layoutAtom.ts +++ b/frontend/layout/lib/layoutAtom.ts @@ -39,6 +39,10 @@ export function withLayoutTreeStateAtomFromTab(tabAtom: Atom): WritableLayo const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); if (!stateAtom) return; const waveObjVal = get(stateAtom); + if (waveObjVal == null) { + console.log("in withLayoutTreeStateAtomFromTab, waveObjVal is null", value); + return; + } waveObjVal.rootnode = value.rootNode; waveObjVal.magnifiednodeid = value.magnifiedNodeId; waveObjVal.focusednodeid = value.focusedNodeId; diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts index 81c9efd06..42597a492 100644 --- a/frontend/layout/lib/layoutModelHooks.ts +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -1,9 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useOnResize } from "@/app/hook/useDimensions"; import { atoms, globalStore, WOS } from "@/app/store/global"; import { fireAndForget } from "@/util/util"; -import useResizeObserver from "@react-hook/resize-observer"; import { Atom, useAtomValue } from "jotai"; import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react"; import { debounce } from "throttle-debounce"; @@ -36,8 +36,8 @@ export function getLayoutModelForTabById(tabId: string) { return getLayoutModelForTab(tabAtom); } -export function getLayoutModelForActiveTab() { - const tabId = globalStore.get(atoms.activeTabId); +export function getLayoutModelForStaticTab() { + const tabId = globalStore.get(atoms.staticTabId); return getLayoutModelForTabById(tabId); } @@ -53,7 +53,8 @@ export function useTileLayout(tabAtom: Atom, tileContent: TileLayoutContent // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading) useAtomValue(tabAtom); const layoutModel = useLayoutModel(tabAtom); - useResizeObserver(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); + + useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index a33feb5fb..209405d49 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -7,16 +7,15 @@ import type * as rxjs from "rxjs"; declare global { type GlobalAtomsType = { - windowId: jotai.Atom; // readonly clientId: jotai.Atom; // readonly client: jotai.Atom; // driven from WOS - uiContext: jotai.Atom; // driven from windowId, activetabid, etc. + uiContext: jotai.Atom; // driven from windowId, tabId waveWindow: jotai.Atom; // driven from WOS workspace: jotai.Atom; // driven from WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig tabAtom: jotai.Atom; // driven from WOS - activeTabId: jotai.Atom; // derrived from windowDataAtom + staticTabId: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; controlShiftDelayAtom: jotai.PrimitiveAtom; prefersReducedMotionAtom: jotai.Atom; @@ -50,6 +49,13 @@ declare global { blockId: string; }; + type WaveInitOpts = { + tabId: string; + clientId: string; + windowId: string; + activate: boolean; + }; + type ElectronApi = { getAuthKey(): string; getIsDev(): boolean; @@ -58,6 +64,8 @@ declare global { getEnv: (varName: string) => string; getUserName: () => string; getHostName: () => string; + getDataDir: () => string; + getConfigDir: () => string; getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; getDocsiteUrl: () => string; @@ -78,6 +86,12 @@ declare global { setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview registerGlobalWebviewKeys: (keys: string[]) => void; onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; + setActiveTab: (tabId: string) => void; + createTab: () => void; + closeTab: (tabId: string) => void; + setWindowInitStatus: (status: "ready" | "wave-ready") => void; + onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; + sendLog: (log: string) => void; onQuicklook: (filePath: string) => void; }; @@ -262,6 +276,7 @@ declare global { getSettingsMenuItems?: () => ContextMenuItem[]; giveFocus?: () => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + dispose?: () => void; } type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; @@ -333,6 +348,27 @@ declare global { msgFn: (msg: RpcMessage) => void; }; + type WaveBrowserWindow = Electron.BaseWindow & { + waveWindowId: string; + waveReadyPromise: Promise; + allTabViews: Map; + activeTabView: WaveTabView; + alreadyClosed: boolean; + }; + + type WaveTabView = Electron.WebContentsView & { + isActiveTab: boolean; + waveWindowId: string; // set when showing in an active window + waveTabId: string; // always set, WaveTabViews are unique per tab + lastUsedTs: number; // ts milliseconds + createdTs: number; // ts milliseconds + initPromise: Promise; + savedInitOpts: WaveInitOpts; + waveReadyPromise: Promise; + initResolve: () => void; + waveReadyResolve: () => void; + }; + type TimeSeriesMeta = { name?: string; color?: string; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index fcbe84680..37ced6401 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -7,9 +7,11 @@ declare global { // waveobj.Block type Block = WaveObj & { + parentoref?: string; blockdef: BlockDef; runtimeopts?: RuntimeOpts; stickers?: StickerType[]; + subblockids?: string[]; }; // blockcontroller.BlockControllerRuntimeStatus @@ -30,7 +32,7 @@ declare global { blockid: string; tabid: string; windowid: string; - meta: MetaType; + block: Block; }; // webcmd.BlockInputWSCommand @@ -45,6 +47,13 @@ declare global { windowids: string[]; tosagreed?: number; hasoldhistory?: boolean; + nexttabid?: number; + }; + + // windowservice.CloseTabRtnType + type CloseTabRtnType = { + closewindow?: boolean; + newactivetabid?: string; }; // wshrpc.CommandAppendIJsonData @@ -57,6 +66,7 @@ declare global { // wshrpc.CommandAuthenticateRtnData type CommandAuthenticateRtnData = { routeid: string; + authtoken?: string; }; // wshrpc.CommandBlockInputData @@ -89,11 +99,22 @@ declare global { magnified?: boolean; }; + // wshrpc.CommandCreateSubBlockData + type CommandCreateSubBlockData = { + parentblockid: string; + blockdef: BlockDef; + }; + // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; }; + // wshrpc.CommandDisposeData + type CommandDisposeData = { + routeid: string; + }; + // wshrpc.CommandEventReadHistoryData type CommandEventReadHistoryData = { event: string; @@ -155,6 +176,12 @@ declare global { meta: MetaType; }; + // wshrpc.CommandWaitForRouteData + type CommandWaitForRouteData = { + routeid: string; + waitms: number; + }; + // wshrpc.CommandWebSelectorData type CommandWebSelectorData = { windowid: string; @@ -186,6 +213,16 @@ declare global { count: number; }; + // vdom.DomRect + type DomRect = { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + }; + // waveobj.FileDef type FileDef = { filetype?: string; @@ -319,6 +356,12 @@ declare global { "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; + "term:vdomblockid"?: string; + "vdom:*"?: boolean; + "vdom:initialized"?: boolean; + "vdom:correlationid"?: string; + "vdom:route"?: string; + "vdom:persist"?: boolean; count?: number; }; @@ -397,6 +440,7 @@ declare global { resid?: string; timeout?: number; route?: string; + authtoken?: string; source?: string; cont?: boolean; cancel?: boolean; @@ -473,6 +517,7 @@ declare global { "window:showmenubar"?: boolean; "window:nativetitlebar"?: boolean; "window:disablehardwareacceleration"?: boolean; + "window:maxtabcachesize"?: number; "telemetry:*"?: boolean; "telemetry:enabled"?: boolean; "conn:*"?: boolean; @@ -582,27 +627,155 @@ declare global { checkboxstat?: boolean; }; - // vdom.Elem + // vdom.VDomAsyncInitiationRequest + type VDomAsyncInitiationRequest = { + type: "asyncinitiationrequest"; + ts: number; + blockid?: string; + }; + + // vdom.VDomBackendOpts + type VDomBackendOpts = { + closeonctrlc?: boolean; + globalkeyboardevents?: boolean; + }; + + // vdom.VDomBackendUpdate + type VDomBackendUpdate = { + type: "backendupdate"; + ts: number; + blockid: string; + opts?: VDomBackendOpts; + renderupdates?: VDomRenderUpdate[]; + statesync?: VDomStateSync[]; + refoperations?: VDomRefOperation[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomBinding + type VDomBinding = { + type: "binding"; + bind: string; + }; + + // vdom.VDomCreateContext + type VDomCreateContext = { + type: "createcontext"; + ts: number; + meta?: MetaType; + target?: VDomTarget; + persist?: boolean; + }; + + // vdom.VDomElem type VDomElem = { - id?: string; + waveid?: string; tag: string; props?: {[key: string]: any}; children?: VDomElem[]; text?: string; }; - // vdom.VDomFuncType - type VDomFuncType = { - #func: string; - #stopPropagation?: boolean; - #preventDefault?: boolean; - #keys?: string[]; + // vdom.VDomEvent + type VDomEvent = { + waveid: string; + eventtype: string; + eventdata: any; }; - // vdom.VDomRefType - type VDomRefType = { - #ref: string; - current: any; + // vdom.VDomFrontendUpdate + type VDomFrontendUpdate = { + type: "frontendupdate"; + ts: number; + blockid: string; + correlationid?: string; + dispose?: boolean; + resync?: boolean; + rendercontext?: VDomRenderContext; + events?: VDomEvent[]; + statesync?: VDomStateSync[]; + refupdates?: VDomRefUpdate[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomFunc + type VDomFunc = { + type: "func"; + stoppropagation?: boolean; + preventdefault?: boolean; + globalevent?: string; + keys?: string[]; + }; + + // vdom.VDomMessage + type VDomMessage = { + messagetype: string; + message: string; + stacktrace?: string; + params?: any[]; + }; + + // vdom.VDomRef + type VDomRef = { + type: "ref"; + refid: string; + trackposition?: boolean; + position?: VDomRefPosition; + hascurrent?: boolean; + }; + + // vdom.VDomRefOperation + type VDomRefOperation = { + refid: string; + op: string; + params?: any[]; + }; + + // vdom.VDomRefPosition + type VDomRefPosition = { + offsetheight: number; + offsetwidth: number; + scrollheight: number; + scrollwidth: number; + scrolltop: number; + boundingclientrect: DomRect; + }; + + // vdom.VDomRefUpdate + type VDomRefUpdate = { + refid: string; + hascurrent: boolean; + position?: VDomRefPosition; + }; + + // vdom.VDomRenderContext + type VDomRenderContext = { + blockid: string; + focused: boolean; + width: number; + height: number; + rootrefid: string; + background?: boolean; + }; + + // vdom.VDomRenderUpdate + type VDomRenderUpdate = { + updatetype: "root"|"append"|"replace"|"remove"|"insert"; + waveid?: string; + vdom: VDomElem; + index?: number; + }; + + // vdom.VDomStateSync + type VDomStateSync = { + atom: string; + value: any; + }; + + // vdom.VDomTarget + type VDomTarget = { + newblock?: boolean; + magnified?: boolean; }; type WSCommandType = { diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index b1fb5ccdf..daf50840e 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { return rtn; } +const keyMap = { + Enter: "\r", + Backspace: "\x7f", + Tab: "\t", + Escape: "\x1b", + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowRight: "\x1b[C", + ArrowLeft: "\x1b[D", + Insert: "\x1b[2~", + Delete: "\x1b[3~", + Home: "\x1b[1~", + End: "\x1b[4~", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", +}; + +function keyboardEventToASCII(event: WaveKeyboardEvent): string { + // check modifiers + // if no modifiers are set, just send the key + if (!event.alt && !event.control && !event.meta) { + if (event.key == null || event.key == "") { + return ""; + } + if (keyMap[event.key] != null) { + return keyMap[event.key]; + } + if (event.key.length == 1) { + return event.key; + } else { + console.log("not sending keyboard event", event.key, event); + } + } + // if meta or alt is set, there is no ASCII representation + if (event.meta || event.alt) { + return ""; + } + // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value + if (event.control) { + if ( + (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || + (event.key >= "a" && event.key <= "z") + ) { + const key = event.key.toUpperCase(); + return String.fromCharCode(key.charCodeAt(0) - 64); + } + } + return ""; +} + export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, @@ -248,6 +298,7 @@ export { getKeyUtilPlatform, isCharacterKeyEvent, isInputEvent, + keyboardEventToASCII, keydownWrapper, parseKeyDescription, setKeyUtilPlatform, diff --git a/frontend/wave.ts b/frontend/wave.ts index d94bf14b7..4e05f7e12 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -8,11 +8,11 @@ import { registerGlobalKeys, } from "@/app/store/keymodel"; import { modalsModel } from "@/app/store/modalmodel"; -import { FileService, ObjectService } from "@/app/store/services"; +import { FileService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; -import { initWshrpc, WindowRpcClient } from "@/app/store/wshrpcutil"; +import { initWshrpc, TabRpcClient } from "@/app/store/wshrpcutil"; import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; -import { getLayoutModelForActiveTab } from "@/layout/index"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import { atoms, countersClear, @@ -32,18 +32,9 @@ import { createElement } from "react"; import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); -const urlParams = new URLSearchParams(window.location.search); -const windowId = urlParams.get("windowid"); -const clientId = urlParams.get("clientid"); +document.title = `Wave Terminal`; +let savedInitOpts: WaveInitOpts = null; -console.log("Wave Starting"); -console.log("clientid", clientId, "windowid", windowId); - -initGlobal({ clientId, windowId, platform, environment: "renderer" }); - -setKeyUtilPlatform(platform); - -loadFonts(); (window as any).WOS = WOS; (window as any).globalStore = globalStore; (window as any).globalAtoms = atoms; @@ -51,29 +42,109 @@ loadFonts(); (window as any).isFullScreen = false; (window as any).countersPrint = countersPrint; (window as any).countersClear = countersClear; -(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab; +(window as any).getLayoutModelForStaticTab = getLayoutModelForStaticTab; (window as any).pushFlashError = pushFlashError; (window as any).modalsModel = modalsModel; -document.title = `Wave (${windowId.substring(0, 8)})`; +async function initBare() { + getApi().sendLog("Init Bare"); + document.body.style.visibility = "hidden"; + document.body.style.opacity = "0"; + document.body.classList.add("is-transparent"); + getApi().onWaveInit(initWaveWrap); + setKeyUtilPlatform(platform); + loadFonts(); + document.fonts.ready.then(() => { + console.log("Init Bare Done"); + getApi().setWindowInitStatus("ready"); + }); +} -document.addEventListener("DOMContentLoaded", async () => { - console.log("DOMContentLoaded"); +document.addEventListener("DOMContentLoaded", initBare); + +async function initWaveWrap(initOpts: WaveInitOpts) { + try { + if (savedInitOpts) { + await reinitWave(); + return; + } + savedInitOpts = initOpts; + await initWave(initOpts); + } catch (e) { + getApi().sendLog("Error in initWave " + e.message); + console.error("Error in initWave", e); + } finally { + document.body.style.visibility = null; + document.body.style.opacity = null; + document.body.classList.remove("is-transparent"); + } +} + +async function reinitWave() { + console.log("Reinit Wave"); + getApi().sendLog("Reinit Wave"); + const client = await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); + const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); + await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); + await WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); + document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change + getApi().setWindowInitStatus("wave-ready"); +} + +function loadAllWorkspaceTabs(ws: Workspace) { + if (ws == null || ws.tabids == null) { + return; + } + ws.tabids.forEach((tabid) => { + WOS.getObjectValue(WOS.makeORef("tab", tabid)); + }); +} + +async function initWave(initOpts: WaveInitOpts) { + getApi().sendLog("Init Wave " + JSON.stringify(initOpts)); + console.log( + "Wave Init", + "tabid", + initOpts.tabId, + "clientid", + initOpts.clientId, + "windowid", + initOpts.windowId, + "platform", + platform + ); + initGlobal({ + tabId: initOpts.tabId, + clientId: initOpts.clientId, + windowId: initOpts.windowId, + platform, + environment: "renderer", + }); + (window as any).globalAtoms = atoms; // Init WPS event handlers - const globalWS = initWshrpc(windowId); + const globalWS = initWshrpc(initOpts.tabId); (window as any).globalWS = globalWS; - (window as any).WindowRpcClient = WindowRpcClient; + (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); initGlobalWaveEventSubs(); subscribeToConnEvents(); // ensures client/window/workspace are loaded into the cache before rendering - const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", clientId)); - const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId)); - await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); - const initialTab = await WOS.loadAndPinWaveObject(WOS.makeORef("tab", waveWindow.activetabid)); - await WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); + const [client, waveWindow, initialTab] = await Promise.all([ + WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), + WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), + WOS.loadAndPinWaveObject(WOS.makeORef("tab", initOpts.tabId)), + ]); + const [ws, layoutState] = await Promise.all([ + WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)), + WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)), + ]); + loadAllWorkspaceTabs(ws); + WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + + document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change registerGlobalKeys(); registerElectronReinjectKeyHandler(); @@ -82,15 +153,16 @@ document.addEventListener("DOMContentLoaded", async () => { const fullConfig = await FileService.GetFullConfig(); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); - const prtn = ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait - prtn.catch((e) => { - console.log("error on initial SetActiveTab", e); + console.log("Wave First Render"); + let firstRenderResolveFn: () => void = null; + let firstRenderPromise = new Promise((resolve) => { + firstRenderResolveFn = resolve; }); - const reactElem = createElement(App, null, null); + const reactElem = createElement(App, { onFirstRender: firstRenderResolveFn }, null); const elem = document.getElementById("main"); const root = createRoot(elem); - document.fonts.ready.then(() => { - console.log("Wave First Render"); - root.render(reactElem); - }); -}); + root.render(reactElem); + await firstRenderPromise; + console.log("Wave First Render Done"); + getApi().setWindowInitStatus("wave-ready"); +} diff --git a/go.mod b/go.mod index 2388535d5..a5c99490e 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/shirou/gopsutil/v4 v4.24.9 github.com/skeema/knownhosts v1.3.0 github.com/spf13/cobra v1.8.1 + github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.1.0 golang.org/x/crypto v0.28.0 golang.org/x/sys v0.26.0 @@ -36,9 +37,11 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/net v0.29.0 // indirect diff --git a/go.sum b/go.sum index 158ee10b7..e0175c9a5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg= +github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E= github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -62,6 +64,8 @@ github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI= github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -71,12 +75,17 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM= +github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g= +github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM= +github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s= github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY= @@ -91,6 +100,7 @@ golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -102,5 +112,6 @@ golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html index 9d0cc2941..5100477ba 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + Wave @@ -11,7 +12,7 @@ - +
diff --git a/package.json b/package.json index 3b983506c..7381a92c2 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "dayjs": "^1.11.13", "debug": "^4.3.7", "electron-updater": "6.3.9", + "env-paths": "^3.0.0", "fast-average-color": "^9.4.0", "htl": "^0.3.1", "html-to-image": "^1.11.11", diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 146df6b38..f2d6a2751 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -11,6 +11,7 @@ import ( "io" "io/fs" "log" + "strings" "sync" "time" @@ -24,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -34,7 +36,7 @@ const ( const ( BlockFile_Term = "term" // used for main pty output - BlockFile_Html = "html" // used for alt html layout + BlockFile_VDom = "vdom" // used for alt html layout ) const ( @@ -262,7 +264,30 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj return fmt.Errorf("unknown controller type %q", bc.ControllerType) } var shellProc *shellexec.ShellProc - if remoteName != "" { + if strings.HasPrefix(remoteName, "wsl://") { + wslName := strings.TrimPrefix(remoteName, "wsl://") + credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFunc() + + wslConn := wsl.GetWslConn(credentialCtx, wslName, false) + connStatus := wslConn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected, cannot start shellproc") + } + + // create jwt + if !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) { + jwtStr, err := wshutil.MakeClientJWTToken(wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId, Conn: wslConn.GetName()}, wslConn.GetDomainSocketName()) + if err != nil { + return fmt.Errorf("error making jwt token: %w", err) + } + cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr + } + shellProc, err = shellexec.StartWslShellProc(ctx, rc.TermSize, cmdStr, cmdOpts, wslConn) + if err != nil { + return err + } + } else if remoteName != "" { credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFunc() @@ -325,7 +350,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj // we don't need to authenticate this wshProxy since it is coming direct wshProxy := wshutil.MakeRpcProxy() wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}) - wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy, true) ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh) go func() { // handles regular output from the pty (goes to the blockfile and xterm) @@ -359,9 +384,6 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj } }() go func() { - defer func() { - log.Printf("[shellproc] shellInputCh loop done\n") - }() // handles input from the shellInputCh, sent to pty // use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated) for ic := range shellInputCh { @@ -497,6 +519,15 @@ func CheckConnStatus(blockId string) error { if connName == "" { return nil } + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(context.Background(), distroName, false) + connStatus := conn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected: %s", connStatus.Status) + } + return nil + } opts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index 9c216f8ad..2a98f2e36 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -10,8 +10,6 @@ import ( "os" "sync" "time" - - "github.com/wavetermdev/waveterm/pkg/waveobj" ) const ( @@ -27,21 +25,19 @@ type WSEventType struct { } type WindowWatchData struct { - WindowWSCh chan any - WaveWindowId string - WatchedORefs map[waveobj.ORef]bool + WindowWSCh chan any + TabId string } var globalLock = &sync.Mutex{} var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData -func RegisterWSChannel(connId string, windowId string, ch chan any) { +func RegisterWSChannel(connId string, tabId string, ch chan any) { globalLock.Lock() defer globalLock.Unlock() wsMap[connId] = &WindowWatchData{ - WindowWSCh: ch, - WaveWindowId: windowId, - WatchedORefs: make(map[waveobj.ORef]bool), + WindowWSCh: ch, + TabId: tabId, } } @@ -56,7 +52,7 @@ func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { defer globalLock.Unlock() var watches []*WindowWatchData for _, wdata := range wsMap { - if wdata.WaveWindowId == windowId { + if wdata.TabId == windowId { watches = append(watches, wdata) } } diff --git a/pkg/filestore/blockstore_dbsetup.go b/pkg/filestore/blockstore_dbsetup.go index d828c4173..8ceb5b366 100644 --- a/pkg/filestore/blockstore_dbsetup.go +++ b/pkg/filestore/blockstore_dbsetup.go @@ -50,7 +50,7 @@ func InitFilestore() error { } func GetDBName() string { - waveHome := wavebase.GetWaveHomeDir() + waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName) } diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index d50f63978..545738f02 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -422,9 +422,9 @@ func (conn *SSHConn) WithLock(fn func()) { } func (conn *SSHConn) connectInternal(ctx context.Context) error { - client, err := remote.ConnectToClient(ctx, conn.Opts) //todo specify or remove opts + client, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0) if err != nil { - log.Printf("error: failed to connect to client %s: %v\n", conn.GetName(), err) + log.Printf("error: failed to connect to client %s: %s\n", conn.GetName(), err) return err } fmtAddr := knownhosts.Normalize(fmt.Sprintf("%s@%s", client.User(), client.RemoteAddr().String())) diff --git a/pkg/remote/connutil.go b/pkg/remote/connutil.go index 61be2edcd..89a5d827a 100644 --- a/pkg/remote/connutil.go +++ b/pkg/remote/connutil.go @@ -1,3 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + package remote import ( diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 13ee56d31..13893784e 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "fmt" "log" + "math" "net" "os" "os/exec" @@ -31,6 +32,8 @@ import ( xknownhosts "golang.org/x/crypto/ssh/knownhosts" ) +const SshProxyJumpMaxDepth = 10 + type UserInputCancelError struct { Err error } @@ -41,6 +44,24 @@ func (uice UserInputCancelError) Error() string { return uice.Err.Error() } +type ConnectionDebugInfo struct { + CurrentClient *ssh.Client + NextOpts *SSHOpts + JumpNum int32 +} + +type ConnectionError struct { + *ConnectionDebugInfo + Err error +} + +func (ce ConnectionError) Error() string { + if ce.CurrentClient == nil { + return fmt.Sprintf("Connecting to %+#v, Error: %v", ce.NextOpts, ce.Err) + } + return fmt.Sprintf("Connecting from %v to %+#v (jump number %d), Error: %v", ce.CurrentClient, ce.NextOpts, ce.JumpNum, ce.Err) +} + // This exists to trick the ssh library into continuing to try // different public keys even when the current key cannot be // properly parsed @@ -68,7 +89,7 @@ func createDummySigner() ([]ssh.Signer, error) { // they were successes. An error in this function prevents any other // keys from being attempted. But if there's an error because of a dummy // file, the library can still try again with a new key. -func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent) func() ([]ssh.Signer, error) { +func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent, debugInfo *ConnectionDebugInfo) func() ([]ssh.Signer, error) { var identityFiles []string existingKeys := make(map[string][]byte) @@ -103,7 +124,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, } if len(*identityFilesPtr) == 0 { - return nil, fmt.Errorf("no identity files remaining") + return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("no identity files remaining")} } identityFile := (*identityFilesPtr)[0] *identityFilesPtr = (*identityFilesPtr)[1:] @@ -123,7 +144,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, PrivateKey: unencryptedPrivateKey, }) } - return []ssh.Signer{signer}, err + return []ssh.Signer{signer}, nil } } if _, ok := err.(*ssh.PassphraseMissingError); !ok { @@ -148,7 +169,8 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, if err != nil { // this is an error where we actually do want to stop // trying keys - return nil, UserInputCancelError{Err: err} + + return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: UserInputCancelError{Err: err}} } unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text))) if err != nil { @@ -165,11 +187,11 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, PrivateKey: unencryptedPrivateKey, }) } - return []ssh.Signer{signer}, err + return []ssh.Signer{signer}, nil } } -func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) { +func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) { return func() (secret string, err error) { ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() @@ -185,13 +207,13 @@ func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisp } response, err := userinput.GetUserInput(ctx, request) if err != nil { - return "", err + return "", ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } return response.Text, nil } } -func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { +func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string, debugInfo *ConnectionDebugInfo) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) != len(echos) { return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos)) @@ -200,7 +222,7 @@ func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteNam echo := echos[i] answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName) if err != nil { - return nil, err + return nil, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } answers = append(answers, answer) } @@ -336,12 +358,9 @@ func lineContainsMatch(line []byte, matches [][]byte) bool { return false } -func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithms, error) { - ssh_config.ReloadConfigs() - rawUserKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "UserKnownHostsFile") - userKnownHostsFiles := strings.Fields(rawUserKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes - rawGlobalKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "GlobalKnownHostsFile") - globalKnownHostsFiles := strings.Fields(rawGlobalKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes +func createHostKeyCallback(sshKeywords *SshKeywords) (ssh.HostKeyCallback, HostKeyAlgorithms, error) { + globalKnownHostsFiles := sshKeywords.GlobalKnownHostsFile + userKnownHostsFiles := sshKeywords.UserKnownHostsFile osUser, err := user.Current() if err != nil { @@ -485,6 +504,7 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithm "%s\n\n"+ "**Offending Keys** \n"+ "%s", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, " \n"), strings.Join(offendingKeysFmt, " \n")) + log.Print(errorMsg) //update := scbus.MakeUpdatePacket() // create update into alert message @@ -504,29 +524,7 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithm return waveHostKeyCallback, hostKeyAlgorithms, nil } -func DialContext(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { - d := net.Dialer{Timeout: config.Timeout} - conn, err := d.DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) - if err != nil { - return nil, err - } - return ssh.NewClient(c, chans, reqs), nil -} - -func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error) { - sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) - if err != nil { - return nil, err - } - - sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords) - if err != nil { - return nil, err - } +func createClientConfig(connCtx context.Context, sshKeywords *SshKeywords, debugInfo *ConnectionDebugInfo) (*ssh.ClientConfig, error) { remoteName := sshKeywords.User + "@" + xknownhosts.Normalize(sshKeywords.HostName+":"+sshKeywords.Port) var authSockSigners []ssh.Signer @@ -539,9 +537,9 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error authSockSigners, _ = agentClient.Signers() } - publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient)) - keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName)) - passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName)) + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient, debugInfo)) + keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName, debugInfo)) + passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName, debugInfo)) // exclude gssapi-with-mic and hostbased until implemented authMethodMap := map[string]ssh.AuthMethod{ @@ -570,19 +568,90 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error authMethods = append(authMethods, authMethod) } - hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(opts) + hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(sshKeywords) if err != nil { return nil, err } networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port - clientConfig := &ssh.ClientConfig{ + return &ssh.ClientConfig{ User: sshKeywords.User, Auth: authMethods, HostKeyCallback: hostKeyCallback, HostKeyAlgorithms: hostKeyAlgorithms(networkAddr), + }, nil +} + +func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.ClientConfig, currentClient *ssh.Client) (*ssh.Client, error) { + var clientConn net.Conn + var err error + if currentClient == nil { + d := net.Dialer{Timeout: clientConfig.Timeout} + clientConn, err = d.DialContext(ctx, "tcp", networkAddr) + if err != nil { + return nil, err + } + } else { + clientConn, err = currentClient.DialContext(ctx, "tcp", networkAddr) + if err != nil { + return nil, err + } } - return DialContext(connCtx, "tcp", networkAddr, clientConfig) + c, chans, reqs, err := ssh.NewClientConn(clientConn, networkAddr, clientConfig) + if err != nil { + return nil, err + } + return ssh.NewClient(c, chans, reqs), nil +} + +func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32) (*ssh.Client, int32, error) { + debugInfo := &ConnectionDebugInfo{ + CurrentClient: currentClient, + NextOpts: opts, + JumpNum: jumpNum, + } + if jumpNum > SshProxyJumpMaxDepth { + return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: fmt.Errorf("ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)} + } + // todo print final warning if logging gets turned off + sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) + if err != nil { + return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + } + + sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords) + if err != nil { + return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + } + + for _, proxyName := range sshKeywords.ProxyJump { + proxyOpts, err := ParseOpts(proxyName) + if err != nil { + return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + } + + // ensure no overflow (this will likely never happen) + if jumpNum < math.MaxInt32 { + jumpNum += 1 + } + + debugInfo.CurrentClient, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum) + if err != nil { + // do not add a context on a recursive call + // (this can cause a recursive nested context that's arbitrarily deep) + return nil, jumpNum, err + } + } + clientConfig, err := createClientConfig(connCtx, sshKeywords, debugInfo) + if err != nil { + return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + } + networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port + client, err := connectInternal(connCtx, networkAddr, clientConfig, debugInfo.CurrentClient) + if err != nil { + return client, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} + } + return client, debugInfo.JumpNum, nil } type SshKeywords struct { @@ -597,6 +666,9 @@ type SshKeywords struct { PreferredAuthentications []string AddKeysToAgent bool IdentityAgent string + ProxyJump []string + UserKnownHostsFile []string + GlobalKnownHostsFile []string } func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { @@ -641,6 +713,9 @@ func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeyword sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent sshKeywords.IdentityAgent = configKeywords.IdentityAgent + sshKeywords.ProxyJump = configKeywords.ProxyJump + sshKeywords.UserKnownHostsFile = configKeywords.UserKnownHostsFile + sshKeywords.GlobalKnownHostsFile = configKeywords.GlobalKnownHostsFile return sshKeywords, nil } @@ -740,6 +815,23 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { sshKeywords.IdentityAgent = agentPath } + proxyJumpRaw, err := ssh_config.GetStrict(hostPattern, "ProxyJump") + if err != nil { + return nil, err + } + proxyJumpSplit := strings.Split(proxyJumpRaw, ",") + for _, proxyJumpName := range proxyJumpSplit { + proxyJumpName = strings.TrimSpace(proxyJumpName) + if proxyJumpName == "" || strings.ToLower(proxyJumpName) == "none" { + continue + } + sshKeywords.ProxyJump = append(sshKeywords.ProxyJump, proxyJumpName) + } + rawUserKnownHostsFile, _ := ssh_config.GetStrict(hostPattern, "UserKnownHostsFile") + sshKeywords.UserKnownHostsFile = strings.Fields(rawUserKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes + rawGlobalKnownHostsFile, _ := ssh_config.GetStrict(hostPattern, "GlobalKnownHostsFile") + sshKeywords.GlobalKnownHostsFile = strings.Fields(rawGlobalKnownHostsFile) // TODO - smarter splitting escaped spaces and quotes + return sshKeywords, nil } diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go index f2a8d9b68..b8ec51fbd 100644 --- a/pkg/service/clientservice/clientservice.go +++ b/pkg/service/clientservice/clientservice.go @@ -17,6 +17,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -77,7 +78,9 @@ func (cs *ClientService) MakeWindow(ctx context.Context) (*waveobj.Window, error } func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) { - return conncontroller.GetAllConnStatus(), nil + sshStatuses := conncontroller.GetAllConnStatus() + wslStatuses := wsl.GetAllConnStatus() + return append(sshStatuses, wslStatuses...), nil } // moves the window to the front of the windowId stack diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 7a6e4b298..08338da1f 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -13,6 +13,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -74,25 +75,28 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er func (svc *ObjectService) AddTabToWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ - ArgNames: []string{"uiContext", "tabName", "activateTab"}, + ArgNames: []string{"windowId", "tabName", "activateTab"}, ReturnDesc: "tabId", } } -func (svc *ObjectService) AddTabToWorkspace(uiContext waveobj.UIContext, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { +func (svc *ObjectService) AddTabToWorkspace(windowId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - tabId, err := wcore.CreateTab(ctx, uiContext.WindowId, tabName, activateTab) + tabId, err := wcore.CreateTab(ctx, windowId, tabName, activateTab) if err != nil { return "", nil, fmt.Errorf("error creating tab: %w", err) } - err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout()) if err != nil { return "", nil, fmt.Errorf("error applying new tab layout: %w", err) } - return tabId, waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + return tabId, updates, nil } func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta { @@ -118,11 +122,11 @@ func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta { } } -func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { +func (svc *ObjectService) SetActiveTab(windowId string, tabId string) (waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId) + err := wstore.SetActiveTab(ctx, windowId, tabId) if err != nil { return nil, fmt.Errorf("error setting active tab: %w", err) } @@ -137,9 +141,14 @@ func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string return nil, fmt.Errorf("error getting tab blocks: %w", err) } updates := waveobj.ContextGetUpdatesRtn(ctx) - updates = append(updates, waveobj.MakeUpdate(tab)) - updates = append(updates, waveobj.MakeUpdates(blocks)...) - return updates, nil + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + var extraUpdates waveobj.UpdatesRtnType + extraUpdates = append(extraUpdates, updates...) + extraUpdates = append(extraUpdates, waveobj.MakeUpdate(tab)) + extraUpdates = append(extraUpdates, waveobj.MakeUpdates(blocks)...) + return extraUpdates, nil } func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { @@ -192,7 +201,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId) + err := wcore.DeleteBlock(ctx, blockId) if err != nil { return nil, fmt.Errorf("error deleting block: %w", err) } diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index f3702690f..2735e7dd8 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -16,6 +16,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -46,19 +47,25 @@ func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId strin return waveobj.ContextGetUpdatesRtn(ctx), nil } -func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { +type CloseTabRtnType struct { + CloseWindow bool `json:"closewindow,omitempty"` + NewActiveTabId string `json:"newactivetabid,omitempty"` +} + +// returns the new active tabid +func (svc *WindowService) CloseTab(ctx context.Context, windowId string, tabId string, fromElectron bool) (*CloseTabRtnType, waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) - window, err := wstore.DBMustGet[*waveobj.Window](ctx, uiContext.WindowId) + window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { - return nil, fmt.Errorf("error getting window: %w", err) + return nil, nil, fmt.Errorf("error getting window: %w", err) } tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) if err != nil { - return nil, fmt.Errorf("error getting tab: %w", err) + return nil, nil, fmt.Errorf("error getting tab: %w", err) } ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) if err != nil { - return nil, fmt.Errorf("error getting workspace: %w", err) + return nil, nil, fmt.Errorf("error getting workspace: %w", err) } tabIndex := -1 for i, id := range ws.TabIds { @@ -73,26 +80,36 @@ func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UICont } }() if err := wcore.DeleteTab(ctx, window.WorkspaceId, tabId); err != nil { - return nil, fmt.Errorf("error closing tab: %w", err) + return nil, nil, fmt.Errorf("error closing tab: %w", err) } + rtn := &CloseTabRtnType{} if window.ActiveTabId == tabId && tabIndex != -1 { if len(ws.TabIds) == 1 { - svc.CloseWindow(ctx, uiContext.WindowId) - eventbus.SendEventToElectron(eventbus.WSEventType{ - EventType: eventbus.WSEvent_ElectronCloseWindow, - Data: uiContext.WindowId, - }) + rtn.CloseWindow = true + svc.CloseWindow(ctx, windowId, fromElectron) + if !fromElectron { + eventbus.SendEventToElectron(eventbus.WSEventType{ + EventType: eventbus.WSEvent_ElectronCloseWindow, + Data: windowId, + }) + } } else { if tabIndex < len(ws.TabIds)-1 { newActiveTabId := ws.TabIds[tabIndex+1] - wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + wstore.SetActiveTab(ctx, windowId, newActiveTabId) + rtn.NewActiveTabId = newActiveTabId } else { newActiveTabId := ws.TabIds[tabIndex-1] - wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + wstore.SetActiveTab(ctx, windowId, newActiveTabId) + rtn.NewActiveTabId = newActiveTabId } } } - return waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + wps.Broker.SendUpdateEvents(updates) + }() + return rtn, updates, nil } func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta { @@ -148,7 +165,7 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId return waveobj.ContextGetUpdatesRtn(ctx), nil } -func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) error { +func (svc *WindowService) CloseWindow(ctx context.Context, windowId string, fromElectron bool) error { ctx = waveobj.ContextWithUpdates(ctx) window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) if err != nil { @@ -159,8 +176,7 @@ func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) erro return fmt.Errorf("error getting workspace: %w", err) } for _, tabId := range workspace.TabIds { - uiContext := waveobj.UIContext{WindowId: windowId} - _, err := svc.CloseTab(ctx, uiContext, tabId) + _, _, err := svc.CloseTab(ctx, windowId, tabId, fromElectron) if err != nil { return fmt.Errorf("error closing tab: %w", err) } diff --git a/pkg/shellexec/conninterface.go b/pkg/shellexec/conninterface.go index 96670e2fa..b16dffc9c 100644 --- a/pkg/shellexec/conninterface.go +++ b/pkg/shellexec/conninterface.go @@ -9,6 +9,7 @@ import ( "time" "github.com/creack/pty" + "github.com/wavetermdev/waveterm/pkg/wsl" "golang.org/x/crypto/ssh" ) @@ -135,3 +136,42 @@ func (sw SessionWrap) StderrPipe() (io.ReadCloser, error) { func (sw SessionWrap) SetSize(h int, w int) error { return sw.Session.WindowChange(h, w) } + +type WslCmdWrap struct { + *wsl.WslCmd + Tty pty.Tty + pty.Pty +} + +func (wcw WslCmdWrap) Kill() { + wcw.Tty.Close() + wcw.Close() +} + +func (wcw WslCmdWrap) KillGraceful(timeout time.Duration) { + process := wcw.WslCmd.GetProcess() + if process == nil { + return + } + processState := wcw.WslCmd.GetProcessState() + if processState != nil && processState.Exited() { + return + } + process.Signal(os.Interrupt) + go func() { + time.Sleep(timeout) + process := wcw.WslCmd.GetProcess() + processState := wcw.WslCmd.GetProcessState() + if processState == nil || !processState.Exited() { + process.Kill() // force kill if it is already not exited + } + }() +} + +/** + * SetSize does nothing for WslCmdWrap as there + * is no pty to manage. +**/ +func (wcw WslCmdWrap) SetSize(w int, h int) error { + return nil +} diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index a58433ded..ff985352d 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -5,6 +5,7 @@ package shellexec import ( "bytes" + "context" "fmt" "io" "log" @@ -25,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" ) const DefaultGracefulKillWait = 400 * time.Millisecond @@ -141,6 +143,96 @@ func (pp *PipePty) WriteString(s string) (n int, err error) { return pp.Write([]byte(s)) } +func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wsl.WslConn) (*ShellProc, error) { + client := conn.GetClient() + shellPath := cmdOpts.ShellPath + if shellPath == "" { + remoteShellPath, err := wsl.DetectShell(conn.Context, client) + if err != nil { + return nil, err + } + shellPath = remoteShellPath + } + var shellOpts []string + log.Printf("detected shell: %s", shellPath) + + err := wsl.InstallClientRcFiles(conn.Context, client) + if err != nil { + log.Printf("error installing rc files: %v", err) + return nil, err + } + + homeDir := wsl.GetHomeDir(conn.Context, client) + shellOpts = append(shellOpts, "~", "-d", client.Name()) + + if isZshShell(shellPath) { + shellOpts = append(shellOpts, fmt.Sprintf(`ZDOTDIR="%s/.waveterm/%s"`, homeDir, shellutil.ZshIntegrationDir)) + } + var subShellOpts []string + + if cmdStr == "" { + /* transform command in order to inject environment vars */ + if isBashShell(shellPath) { + log.Printf("recognized as bash shell") + // add --rcfile + // cant set -l or -i with --rcfile + subShellOpts = append(subShellOpts, "--rcfile", fmt.Sprintf(`%s/.waveterm/%s/.bashrc`, homeDir, shellutil.BashIntegrationDir)) + } else if isFishShell(shellPath) { + carg := fmt.Sprintf(`"set -x PATH \"%s\"/.waveterm/%s $PATH"`, homeDir, shellutil.WaveHomeBinDir) + subShellOpts = append(subShellOpts, "-C", carg) + } else if wsl.IsPowershell(shellPath) { + // powershell is weird about quoted path executables and requires an ampersand first + shellPath = "& " + shellPath + subShellOpts = append(subShellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", homeDir+fmt.Sprintf("/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir)) + } else { + if cmdOpts.Login { + subShellOpts = append(subShellOpts, "-l") + } + if cmdOpts.Interactive { + subShellOpts = append(subShellOpts, "-i") + } + // can't set environment vars this way + // will try to do later if possible + } + } else { + shellPath = cmdStr + if cmdOpts.Login { + subShellOpts = append(subShellOpts, "-l") + } + if cmdOpts.Interactive { + subShellOpts = append(subShellOpts, "-i") + } + subShellOpts = append(subShellOpts, "-c", cmdStr) + } + + jwtToken, ok := cmdOpts.Env[wshutil.WaveJwtTokenVarName] + if !ok { + return nil, fmt.Errorf("no jwt token provided to connection") + } + if remote.IsPowershell(shellPath) { + shellOpts = append(shellOpts, "--", fmt.Sprintf(`$env:%s=%s;`, wshutil.WaveJwtTokenVarName, jwtToken)) + } else { + shellOpts = append(shellOpts, "--", fmt.Sprintf(`%s=%s`, wshutil.WaveJwtTokenVarName, jwtToken)) + } + shellOpts = append(shellOpts, shellPath) + shellOpts = append(shellOpts, subShellOpts...) + log.Printf("full cmd is: %s %s", "wsl.exe", strings.Join(shellOpts, " ")) + + ecmd := exec.Command("wsl.exe", shellOpts...) + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) + if err != nil { + return nil, err + } + return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil +} + func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { client := conn.GetClient() shellPath := cmdOpts.ShellPath @@ -289,7 +381,7 @@ func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOpt // cant set -l or -i with --rcfile shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride()) } else if isFishShell(shellPath) { - wshBinDir := filepath.Join(wavebase.GetWaveHomeDir(), shellutil.WaveHomeBinDir) + wshBinDir := filepath.Join(wavebase.GetWaveDataDir(), shellutil.WaveHomeBinDir) quotedWshBinDir := utilfn.ShellQuote(wshBinDir, false, 300) shellOpts = append(shellOpts, "-C", fmt.Sprintf("set -x PATH %s $PATH", quotedWshBinDir)) } else if remote.IsPowershell(shellPath) { diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index cbde00941..503e4e890 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -42,9 +42,13 @@ var ExtraTypes = []any{ wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, userinput.UserInputRequest{}, - vdom.Elem{}, - vdom.VDomFuncType{}, - vdom.VDomRefType{}, + vdom.VDomCreateContext{}, + vdom.VDomElem{}, + vdom.VDomFunc{}, + vdom.VDomRef{}, + vdom.VDomBinding{}, + vdom.VDomFrontendUpdate{}, + vdom.VDomBackendUpdate{}, waveobj.MetaTSType{}, } diff --git a/pkg/util/packetparser/packetparser.go b/pkg/util/packetparser/packetparser.go new file mode 100644 index 000000000..51df1666d --- /dev/null +++ b/pkg/util/packetparser/packetparser.go @@ -0,0 +1,58 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package packetparser + +import ( + "bufio" + "bytes" + "fmt" + "io" +) + +type PacketParser struct { + Reader io.Reader + Ch chan []byte +} + +func Parse(input io.Reader, packetCh chan []byte, rawCh chan []byte) error { + bufReader := bufio.NewReader(input) + defer close(packetCh) + defer close(rawCh) + for { + line, err := bufReader.ReadBytes('\n') + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if len(line) <= 1 { + // just a blank line + continue + } + if bytes.HasPrefix(line, []byte{'#', '#', 'N', '{'}) && bytes.HasSuffix(line, []byte{'}', '\n'}) { + // strip off the leading "##" and trailing "\n" (single byte) + packetCh <- line[3 : len(line)-1] + } else { + rawCh <- line + } + } +} + +func WritePacket(output io.Writer, packet []byte) error { + if len(packet) < 2 { + return nil + } + if packet[0] != '{' || packet[len(packet)-1] != '}' { + return fmt.Errorf("invalid packet, must start with '{' and end with '}'") + } + fullPacket := make([]byte, 0, len(packet)+5) + // we add the extra newline to make sure the ## appears at the beginning of the line + // since writer isn't buffered, we want to send this all at once + fullPacket = append(fullPacket, '\n', '#', '#', 'N') + fullPacket = append(fullPacket, packet...) + fullPacket = append(fullPacket, '\n') + _, err := output.Write(fullPacket) + return err +} diff --git a/pkg/util/panic/panic.go b/pkg/util/panic/panic.go new file mode 100644 index 000000000..a7dde8bf5 --- /dev/null +++ b/pkg/util/panic/panic.go @@ -0,0 +1,15 @@ +package panic + +import ( + "log" + "os" +) + +var shouldPanic = len(os.Getenv("NO_PANIC")) == 0 + +// Wraps log.Panic, ignored if NO_PANIC is set +func Panic(message string) { + if shouldPanic { + log.Panic(message) + } +} diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index 1e6e6a3ab..91ac6d76b 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -149,7 +149,7 @@ func WaveshellLocalEnvVars(termType string) map[string]string { rtn["TERM_PROGRAM"] = "waveterm" rtn["WAVETERM"], _ = os.Executable() rtn["WAVETERM_VERSION"] = wavebase.WaveVersion - rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveHomeDir(), WaveHomeBinDir) + rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir) return rtn } @@ -202,15 +202,15 @@ func InitCustomShellStartupFiles() error { } func GetBashRcFileOverride() string { - return filepath.Join(wavebase.GetWaveHomeDir(), BashIntegrationDir, ".bashrc") + return filepath.Join(wavebase.GetWaveDataDir(), BashIntegrationDir, ".bashrc") } func GetWavePowershellEnv() string { - return filepath.Join(wavebase.GetWaveHomeDir(), PwshIntegrationDir, "wavepwsh.ps1") + return filepath.Join(wavebase.GetWaveDataDir(), PwshIntegrationDir, "wavepwsh.ps1") } func GetZshZDotDir() string { - return filepath.Join(wavebase.GetWaveHomeDir(), ZshIntegrationDir) + return filepath.Join(wavebase.GetWaveDataDir(), ZshIntegrationDir) } func GetWshBaseName(version string, goos string, goarch string) string { @@ -289,9 +289,9 @@ func InitRcFiles(waveHome string, wshBinDir string) error { func initCustomShellStartupFilesInternal() error { log.Printf("initializing wsh and shell startup files\n") - waveHome := wavebase.GetWaveHomeDir() - binDir := filepath.Join(waveHome, WaveHomeBinDir) - err := InitRcFiles(waveHome, `$WAVETERM_WSHBINDIR`) + waveDataHome := wavebase.GetWaveDataDir() + binDir := filepath.Join(waveDataHome, WaveHomeBinDir) + err := InitRcFiles(waveDataHome, `$WAVETERM_WSHBINDIR`) if err != nil { return err } diff --git a/pkg/util/utilfn/compare.go b/pkg/util/utilfn/compare.go new file mode 100644 index 000000000..d9c96a24e --- /dev/null +++ b/pkg/util/utilfn/compare.go @@ -0,0 +1,89 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "reflect" +) + +// this is a shallow equal, but with special handling for numeric types +// it will up convert to float64 and compare +func JsonValEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + typeA := reflect.TypeOf(a) + typeB := reflect.TypeOf(b) + if typeA == typeB && typeA.Comparable() { + return a == b + } + if IsNumericType(a) && IsNumericType(b) { + return CompareAsFloat64(a, b) + } + if typeA != typeB { + return false + } + // for slices and maps, compare their pointers + valA := reflect.ValueOf(a) + valB := reflect.ValueOf(b) + switch valA.Kind() { + case reflect.Slice, reflect.Map: + return valA.Pointer() == valB.Pointer() + } + return false +} + +// Helper to check if a value is a numeric type +func IsNumericType(val any) bool { + switch val.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +// Helper to handle numeric comparisons as float64 +func CompareAsFloat64(a, b any) bool { + valA, okA := ToFloat64(a) + valB, okB := ToFloat64(b) + return okA && okB && valA == valB +} + +// Convert various numeric types to float64 for comparison +func ToFloat64(val any) (float64, bool) { + switch v := val.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} diff --git a/pkg/vdom/cssparser/cssparser.go b/pkg/vdom/cssparser/cssparser.go new file mode 100644 index 000000000..2960d61d5 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser.go @@ -0,0 +1,159 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "strings" + "unicode" +) + +type Parser struct { + Input string + Pos int + Length int + InQuote bool + QuoteChar rune + OpenParens int + Debug bool +} + +func MakeParser(input string) *Parser { + return &Parser{ + Input: input, + Length: len(input), + } +} + +func (p *Parser) Parse() (map[string]string, error) { + result := make(map[string]string) + lastProp := "" + for { + p.skipWhitespace() + if p.eof() { + break + } + propName, err := p.parseIdentifierColon(lastProp) + if err != nil { + return nil, err + } + lastProp = propName + p.skipWhitespace() + value, err := p.parseValue(propName) + if err != nil { + return nil, err + } + result[propName] = value + p.skipWhitespace() + if p.eof() { + break + } + if !p.expectChar(';') { + break + } + } + p.skipWhitespace() + if !p.eof() { + return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1) + } + return result, nil +} + +func (p *Parser) parseIdentifierColon(lastProp string) (string, error) { + start := p.Pos + for !p.eof() { + c := p.peekChar() + if isIdentChar(c) || c == '-' { + p.advance() + } else { + break + } + } + attrName := p.Input[start:p.Pos] + p.skipWhitespace() + if p.eof() { + return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1) + } + if attrName == "" { + return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1) + } + if !p.expectChar(':') { + return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1) + } + return attrName, nil +} + +func (p *Parser) parseValue(propName string) (string, error) { + start := p.Pos + quotePos := 0 + parenPosStack := make([]int, 0) + for !p.eof() { + c := p.peekChar() + if p.InQuote { + if c == p.QuoteChar { + p.InQuote = false + } else if c == '\\' { + p.advance() + } + } else { + if c == '"' || c == '\'' { + p.InQuote = true + p.QuoteChar = c + quotePos = p.Pos + } else if c == '(' { + p.OpenParens++ + parenPosStack = append(parenPosStack, p.Pos) + } else if c == ')' { + if p.OpenParens == 0 { + return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1) + } + p.OpenParens-- + parenPosStack = parenPosStack[:len(parenPosStack)-1] + } else if c == ';' && p.OpenParens == 0 { + break + } + } + p.advance() + } + if p.eof() && p.InQuote { + return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1) + } + if p.eof() && p.OpenParens > 0 { + return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1) + } + return strings.TrimSpace(p.Input[start:p.Pos]), nil +} + +func isIdentChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func (p *Parser) skipWhitespace() { + for !p.eof() && unicode.IsSpace(p.peekChar()) { + p.advance() + } +} + +func (p *Parser) expectChar(expected rune) bool { + if !p.eof() && p.peekChar() == expected { + p.advance() + return true + } + return false +} + +func (p *Parser) peekChar() rune { + if p.Pos >= p.Length { + return 0 + } + return rune(p.Input[p.Pos]) +} + +func (p *Parser) advance() { + p.Pos++ +} + +func (p *Parser) eof() bool { + return p.Pos >= p.Length +} diff --git a/pkg/vdom/cssparser/cssparser_test.go b/pkg/vdom/cssparser/cssparser_test.go new file mode 100644 index 000000000..669d05aa2 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser_test.go @@ -0,0 +1,81 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "log" + "testing" +) + +func compareMaps(a, b map[string]string) error { + if len(a) != len(b) { + return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b)) + } + for k, v := range a { + if b[k] != v { + return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k]) + } + } + return nil +} + +func TestParse1(t *testing.T) { + style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";` + p := MakeParser(style) + parsed, err := p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected := map[string]string{ + "background": `url("example;with;semicolons.jpg")`, + "color": "red", + "margin-right": "5px", + "content": `"hello;world"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } + + style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";` + p = MakeParser(style) + parsed, err = p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected = map[string]string{ + "margin-right": `calc(10px + 5px)`, + "color": "red", + "font-family": `"Arial"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } +} + +func TestParserErrors(t *testing.T) { + style := `hello more: bad;` + p := MakeParser(style) + _, err := p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `background: url("example.jpg` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `foo: url(...` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) +} diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index 4cab277b7..0503c46b5 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -15,35 +15,6 @@ import ( // ReactNode types = nil | string | Elem -const TextTag = "#text" -const FragmentTag = "#fragment" - -const ChildrenPropKey = "children" -const KeyPropKey = "key" - -// doubles as VDOM structure -type Elem struct { - Id string `json:"id,omitempty"` // used for vdom - Tag string `json:"tag"` - Props map[string]any `json:"props,omitempty"` - Children []Elem `json:"children,omitempty"` - Text string `json:"text,omitempty"` -} - -type VDomRefType struct { - RefId string `json:"#ref"` - Current any `json:"current"` -} - -// can be used to set preventDefault/stopPropagation -type VDomFuncType struct { - Fn any `json:"-"` // the actual function to call (called via reflection) - FuncType string `json:"#func"` - StopPropagation bool `json:"#stopPropagation,omitempty"` - PreventDefault bool `json:"#preventDefault,omitempty"` - Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" -} - // generic hook structure type Hook struct { Init bool // is initialized @@ -56,7 +27,7 @@ type Hook struct { type CFunc = func(ctx context.Context, props map[string]any) any -func (e *Elem) Key() string { +func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { return "" @@ -68,8 +39,8 @@ func (e *Elem) Key() string { return "" } -func TextElem(text string) Elem { - return Elem{Tag: TextTag, Text: text} +func TextElem(text string) VDomElem { + return VDomElem{Tag: TextTag, Text: text} } func mergeProps(props *map[string]any, newProps map[string]any) { @@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) { } } -func E(tag string, parts ...any) *Elem { - rtn := &Elem{Tag: tag} +func E(tag string, parts ...any) *VDomElem { + rtn := &VDomElem{Tag: tag} for _, part := range parts { if part == nil { continue @@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { } setVal := func(newVal T) { hookVal.Val = newVal - vc.Root.AddRenderWork(vc.Comp.Id) + vc.Root.AddRenderWork(vc.Comp.WaveId) } return rtnVal, setVal } -func UseRef(ctx context.Context, initialVal any) *VDomRefType { +func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true - refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) - hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} + closedWaveId := vc.Comp.WaveId + hookVal.UnmountFn = func() { + atom := vc.Root.GetAtom(atomName) + delete(atom.UsedBy, closedWaveId) + } } - refVal, ok := hookVal.Val.(*VDomRefType) + atom := vc.Root.GetAtom(atomName) + atom.UsedBy[vc.Comp.WaveId] = true + atomVal, ok := atom.Val.(T) + if !ok { + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) + } + setVal := func(newVal T) { + atom.Val = newVal + for waveId := range atom.UsedBy { + vc.Root.AddRenderWork(waveId) + } + } + return atomVal, setVal +} + +func UseVDomRef(ctx context.Context) *VDomRef { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) + hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} + } + refVal, ok := hookVal.Val.(*VDomRef) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } @@ -159,7 +155,7 @@ func UseId(ctx context.Context) string { if vc == nil { panic("UseId must be called within a component (no context)") } - return vc.Comp.Id + return vc.Comp.WaveId } func depsEqual(deps1 []any, deps2 []any) bool { @@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) return } if depsEqual(hookVal.Deps, deps) { @@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { } hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) } func numToString[T any](value T) (string, bool) { @@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) { } } -func partToElems(part any) []Elem { +func partToElems(part any) []VDomElem { if part == nil { return nil } switch part := part.(type) { case string: - return []Elem{TextElem(part)} - case *Elem: + return []VDomElem{TextElem(part)} + case *VDomElem: if part == nil { return nil } - return []Elem{*part} - case Elem: - return []Elem{part} - case []Elem: + return []VDomElem{*part} + case VDomElem: + return []VDomElem{part} + case []VDomElem: return part - case []*Elem: - var rtn []Elem + case []*VDomElem: + var rtn []VDomElem for _, e := range part { if e == nil { continue @@ -235,11 +231,11 @@ func partToElems(part any) []Elem { } sval, ok := numToString(part) if ok { - return []Elem{TextElem(sval)} + return []VDomElem{TextElem(sval)} } partVal := reflect.ValueOf(part) if partVal.Kind() == reflect.Slice { - var rtn []Elem + var rtn []VDomElem for i := 0; i < partVal.Len(); i++ { subPart := partVal.Index(i).Interface() rtn = append(rtn, partToElems(subPart)...) @@ -248,14 +244,14 @@ func partToElems(part any) []Elem { } stringer, ok := part.(fmt.Stringer) if ok { - return []Elem{TextElem(stringer.String())} + return []VDomElem{TextElem(stringer.String())} } jsonStr, jsonErr := json.Marshal(part) if jsonErr == nil { - return []Elem{TextElem(string(jsonStr))} + return []VDomElem{TextElem(string(jsonStr))} } typeText := "invalid:" + reflect.TypeOf(part).String() - return []Elem{TextElem(typeText)} + return []VDomElem{TextElem(typeText)} } func isWaveTag(tag string) bool { diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go index 3b51701a5..118375b36 100644 --- a/pkg/vdom/vdom_comp.go +++ b/pkg/vdom/vdom_comp.go @@ -13,10 +13,10 @@ type ChildKey struct { } type Component struct { - Id string + WaveId string Tag string Key string - Elem *Elem + Elem *VDomElem Mounted bool // hooks diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index 6e8586d2e..f6ab228bb 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -4,17 +4,25 @@ package vdom import ( + "encoding/json" "errors" "fmt" "io" "strings" "github.com/wavetermdev/htmltoken" + "github.com/wavetermdev/waveterm/pkg/vdom/cssparser" ) // can tokenize and bind HTML to Elems -func appendChildToStack(stack []*Elem, child *Elem) { +const Html_BindPrefix = "#bind:" +const Html_ParamPrefix = "#param:" +const Html_GlobalEventPrefix = "#globalevent" +const Html_BindParamTagName = "bindparam" +const Html_BindTagName = "bind" + +func appendChildToStack(stack []*VDomElem, child *VDomElem) { if child == nil { return } @@ -25,14 +33,14 @@ func appendChildToStack(stack []*Elem, child *Elem) { parent.Children = append(parent.Children, *child) } -func pushElemStack(stack []*Elem, elem *Elem) []*Elem { +func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { if elem == nil { return stack } return append(stack, elem) } -func popElemStack(stack []*Elem) []*Elem { +func popElemStack(stack []*VDomElem) []*VDomElem { if len(stack) <= 1 { return stack } @@ -41,14 +49,14 @@ func popElemStack(stack []*Elem) []*Elem { return stack[:len(stack)-1] } -func curElemTag(stack []*Elem) string { +func curElemTag(stack []*VDomElem) string { if len(stack) == 0 { return "" } return stack[len(stack)-1].Tag } -func finalizeStack(stack []*Elem) *Elem { +func finalizeStack(stack []*VDomElem) *VDomElem { if len(stack) == 0 { return nil } @@ -65,7 +73,20 @@ func finalizeStack(stack []*Elem) *Elem { return rtnElem } -func getAttr(token htmltoken.Token, key string) string { +func attrVal(attr htmltoken.Attribute) (any, error) { + // if !attr.IsJson { + // return attr.Val, nil + // } + var val any + err := json.Unmarshal([]byte(attr.Val), &val) + if err != nil { + return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err) + } + return val, nil +} + +// returns value, isjson +func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { if attr.Key == key { return attr.Val @@ -74,8 +95,38 @@ func getAttr(token htmltoken.Token, key string) string { return "" } -func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { - elem := &Elem{Tag: token.Data} +func attrToProp(attrVal string, isJson bool, params map[string]any) any { + if strings.HasPrefix(attrVal, Html_ParamPrefix) { + bindKey := attrVal[len(Html_ParamPrefix):] + bindVal, ok := params[bindKey] + if !ok { + return nil + } + return bindVal + } + if strings.HasPrefix(attrVal, Html_BindPrefix) { + bindKey := attrVal[len(Html_BindPrefix):] + if bindKey == "" { + return nil + } + return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} + } + if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { + splitArr := strings.Split(attrVal, ":") + if len(splitArr) < 2 { + return nil + } + eventName := splitArr[1] + if eventName == "" { + return nil + } + return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} + } + return attrVal +} + +func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { + elem := &VDomElem{Tag: token.Data} if len(token.Attr) > 0 { elem.Props = make(map[string]any) } @@ -83,16 +134,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { if attr.Key == "" || attr.Val == "" { continue } - if strings.HasPrefix(attr.Val, "#bind:") { - bindKey := attr.Val[6:] - bindVal, ok := data[bindKey] - if !ok { - continue - } - elem.Props[attr.Key] = bindVal - continue - } - elem.Props[attr.Key] = attr.Val + propVal := attrToProp(attr.Val, false, params) + elem.Props[attr.Key] = propVal } return elem } @@ -177,12 +220,101 @@ func processTextStr(s string) string { return strings.TrimSpace(s) } -func Bind(htmlStr string, data map[string]any) *Elem { +func makePathStr(elemPath []string) string { + return strings.Join(elemPath, " ") +} + +func capitalizeAscii(s string) string { + if s == "" || s[0] < 'a' || s[0] > 'z' { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func toReactName(input string) string { + // Check for CSS custom properties (variables) which start with '--' + if strings.HasPrefix(input, "--") { + return input + } + parts := strings.Split(input, "-") + result := "" + index := 0 + if parts[0] == "" && len(parts) > 1 { + // handle vendor prefixes + prefix := parts[1] + if prefix == "ms" { + result += "ms" + } else { + result += capitalizeAscii(prefix) + } + index = 2 // Skip the empty string and prefix + } else { + result += parts[0] + index = 1 + } + // Convert remaining parts to CamelCase + for ; index < len(parts); index++ { + if parts[index] != "" { + result += capitalizeAscii(parts[index]) + } + } + return result +} + +func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { + if len(styleMap) == 0 { + return nil + } + rtn := make(map[string]any) + for key, val := range styleMap { + rtn[toReactName(key)] = attrToProp(val, false, params) + } + return rtn +} + +func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { + styleText, ok := elem.Props["style"].(string) + if !ok { + return nil + } + parser := cssparser.MakeParser(styleText) + m, err := parser.Parse() + if err != nil { + return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) + } + elem.Props["style"] = convertStyleToReactStyles(m, params) + return nil +} + +func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { + if elem == nil { + return + } + // call fixStyleAttribute, and walk children + elemCountMap := make(map[string]int) + if len(elemPath) == 0 { + elemPath = append(elemPath, elem.Tag) + } + fixStyleAttribute(elem, params, elemPath) + for i := range elem.Children { + child := &elem.Children[i] + elemCountMap[child.Tag]++ + subPath := child.Tag + if elemCountMap[child.Tag] > 1 { + subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) + } + elemPath = append(elemPath, subPath) + fixupStyleAttributes(&elem.Children[i], params, elemPath) + elemPath = elemPath[:len(elemPath)-1] + } +} + +func Bind(htmlStr string, params map[string]any) *VDomElem { htmlStr = processWhitespace(htmlStr) r := strings.NewReader(htmlStr) iter := htmltoken.NewTokenizer(r) - var elemStack []*Elem - elemStack = append(elemStack, &Elem{Tag: FragmentTag}) + var elemStack []*VDomElem + elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) var tokenErr error outer: for { @@ -190,15 +322,15 @@ outer: token := iter.Token() switch tokenType { case htmltoken.StartTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } - elem := tokenToElem(token, data) + elem := tokenToElem(token, params) elemStack = pushElemStack(elemStack, elem) case htmltoken.EndTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } if len(elemStack) <= 1 { @@ -211,16 +343,22 @@ outer: } elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: - if token.Data == "bind" { - keyAttr := getAttr(token, "key") - dataVal := data[keyAttr] + if token.Data == Html_BindParamTagName { + keyAttr := getAttrString(token, "key") + dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } continue } - elem := tokenToElem(token, data) + if token.Data == Html_BindTagName { + keyAttr := getAttrString(token, "key") + binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} + appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) + continue + } + elem := tokenToElem(token, params) appendChildToStack(elemStack, elem) case htmltoken.TextToken: if token.Data == "" { @@ -249,5 +387,7 @@ outer: errTextElem := TextElem(tokenErr.Error()) appendChildToStack(elemStack, &errTextElem) } - return finalizeStack(elemStack) + rtn := finalizeStack(elemStack) + fixupStyleAttributes(rtn, params, nil) + return rtn } diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index 898ab61f8..acbe67fd0 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type vdomContextKeyType struct{} @@ -22,13 +23,20 @@ type VDomContextVal struct { HookIdx int } +type Atom struct { + Val any + Dirty bool + UsedBy map[string]bool // component waveid -> true +} + type RootElem struct { OuterCtx context.Context Root *Component CFuncs map[string]CFunc - CompMap map[string]*Component // component id -> component + CompMap map[string]*Component // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool + Atoms map[string]*Atom } const ( @@ -57,9 +65,49 @@ func MakeRoot() *RootElem { Root: nil, CFuncs: make(map[string]CFunc), CompMap: make(map[string]*Component), + Atoms: make(map[string]*Atom), } } +func (r *RootElem) GetAtom(name string) *Atom { + atom, ok := r.Atoms[name] + if !ok { + atom = &Atom{UsedBy: make(map[string]bool)} + r.Atoms[name] = atom + } + return atom +} + +func (r *RootElem) GetAtomVal(name string) any { + atom := r.GetAtom(name) + return atom.Val +} + +func (r *RootElem) GetStateSync(full bool) []VDomStateSync { + stateSync := make([]VDomStateSync, 0) + for atomName, atom := range r.Atoms { + if atom.Dirty || full { + stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) + atom.Dirty = false + } + } + return stateSync +} + +func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { + atom := r.GetAtom(name) + if !markDirty { + atom.Val = val + return + } + // try to avoid setting the value and marking as dirty if it's the "same" + if utilfn.JsonValEqual(val, atom.Val) { + return + } + atom.Val = val + atom.Dirty = true +} + func (r *RootElem) SetOuterCtx(ctx context.Context) { r.OuterCtx = ctx } @@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { r.CFuncs[name] = cfunc } -func (r *RootElem) Render(elem *Elem) { +func (r *RootElem) Render(elem *VDomElem) { log.Printf("Render %s\n", elem.Tag) r.render(elem, &r.Root) } -func (r *RootElem) Event(id string, propName string) { +func (vdf *VDomFunc) CallFn() { + if vdf.Fn == nil { + return + } + rval := reflect.ValueOf(vdf.Fn) + if rval.Kind() != reflect.Func { + return + } + rval.Call(nil) +} + +func callVDomFn(fnVal any, data any) { + if fnVal == nil { + return + } + fn := fnVal + if vdf, ok := fnVal.(*VDomFunc); ok { + fn = vdf.Fn + } + if fn == nil { + return + } + rval := reflect.ValueOf(fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + return + } + if rtype.NumIn() == 1 { + rval.Call([]reflect.Value{reflect.ValueOf(data)}) + return + } +} + +func (r *RootElem) Event(id string, propName string, data any) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return } fnVal := comp.Elem.Props[propName] - if fnVal == nil { - return - } - fn, ok := fnVal.(func()) - if !ok { - return - } - fn() + callVDomFn(fnVal, data) } // this will be called by the frontend to say the DOM has been mounted // it will eventually send any updated "refs" to the backend as well -func (r *RootElem) runWork() { +func (r *RootElem) RunWork() { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups @@ -123,7 +201,7 @@ func (r *RootElem) runWork() { } } -func (r *RootElem) render(elem *Elem, comp **Component) { +func (r *RootElem) render(elem *VDomElem, comp **Component) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) { r.unmount(&child) } } - delete(r.CompMap, (*comp).Id) + delete(r.CompMap, (*comp).WaveId) *comp = nil } func (r *RootElem) createComp(tag string, key string, comp **Component) { - *comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} - r.CompMap[(*comp).Id] = *comp + *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key} + r.CompMap[(*comp).WaveId] = *comp } func (r *RootElem) renderText(text string, comp **Component) { @@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) { } } -func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { +func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component { newChildren := make([]*Component, len(elems)) curCM := make(map[ChildKey]*Component) usedMap := make(map[*Component]bool) @@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com return newChildren } -func (r *RootElem) renderSimple(elem *Elem, comp **Component) { +func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } @@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal { return v.(*VDomContextVal) } -func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { +func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { r.unmount(&(*comp).Comp) return } - var rtnElem *Elem + var rtnElem *VDomElem if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { - rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} + rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).Comp) } @@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any { } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { - vdomProps[k] = VDomFuncType{FuncType: "server"} + vdomProps[k] = VDomFunc{Type: ObjectType_Func} continue } vdomProps[k] = v @@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any { return vdomProps } -func convertBaseToVDom(c *Component) *Elem { - elem := &Elem{Id: c.Id, Tag: c.Tag} +func convertBaseToVDom(c *Component) *VDomElem { + elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } @@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem { return elem } -func convertToVDom(c *Component) *Elem { +func convertToVDom(c *Component) *VDomElem { if c == nil { return nil } if c.Tag == TextTag { - return &Elem{Tag: TextTag, Text: c.Text} + return &VDomElem{Tag: TextTag, Text: c.Text} } if isBaseTag(c.Tag) { return convertBaseToVDom(c) @@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem { } } -func (r *RootElem) makeVDom(comp *Component) *Elem { +func (r *RootElem) makeVDom(comp *Component) *VDomElem { vdomElem := convertToVDom(comp) return vdomElem } -func (r *RootElem) MakeVDom() *Elem { +func (r *RootElem) MakeVDom() *VDomElem { return r.makeVDom(r.Root) } diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go index 430e07ff3..2be63fa41 100644 --- a/pkg/vdom/vdom_test.go +++ b/pkg/vdom/vdom_test.go @@ -18,7 +18,7 @@ type TestContext struct { func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := UseState(ctx, false) - var clickedDiv *Elem + var clickedDiv *VDomElem if clicked { clickedDiv = Bind(`
clicked
`, nil) } @@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any { `

hello world

- - + +
`, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, @@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any { } func Button(ctx context.Context, props map[string]any) any { - ref := UseRef(ctx, nil) + ref := UseVDomRef(ctx) clName, setClName := UseState(ctx, "button") UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") @@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any { testContext.ButtonId = compId } return Bind(` -
- +
+
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) } @@ -85,10 +85,10 @@ func Test1(t *testing.T) { t.Fatalf("root.Root is nil") } printVDom(root) - root.runWork() + root.RunWork() printVDom(root) - root.Event(testContext.ButtonId, "onClick") - root.runWork() + root.Event(testContext.ButtonId, "onClick", nil) + root.RunWork() printVDom(root) } @@ -111,8 +111,8 @@ func TestBind(t *testing.T) { elem = Bind(`

hello world

- - + +
`, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go new file mode 100644 index 000000000..1c09d2817 --- /dev/null +++ b/pkg/vdom/vdom_types.go @@ -0,0 +1,201 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "time" + + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const TextTag = "#text" +const WaveTextTag = "wave:text" +const WaveNullTag = "wave:null" +const FragmentTag = "#fragment" +const BindTag = "#bind" + +const ChildrenPropKey = "children" +const KeyPropKey = "key" + +const ObjectType_Ref = "ref" +const ObjectType_Binding = "binding" +const ObjectType_Func = "func" + +// vdom element +type VDomElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []VDomElem `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +//// protocol messages + +type VDomCreateContext struct { + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta waveobj.MetaMapType `json:"meta,omitempty"` + Target *VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +type VDomAsyncInitiationRequest struct { + Type string `json:"type" tstype:"\"asyncinitiationrequest\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid,omitempty"` +} + +func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { + return VDomAsyncInitiationRequest{ + Type: "asyncinitiationrequest", + Ts: time.Now().UnixMilli(), + BlockId: blockId, + } +} + +type VDomFrontendUpdate struct { + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext VDomRenderContext `json:"rendercontext,omitempty"` + Events []VDomEvent `json:"events,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +type VDomBackendUpdate struct { + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *VDomBackendOpts `json:"opts,omitempty"` + RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefOperations []VDomRefOperation `json:"refoperations,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +///// prop types + +// used in props +type VDomBinding struct { + Type string `json:"type" tstype:"\"binding\""` + Bind string `json:"bind"` +} + +// used in props +type VDomFunc struct { + Fn any `json:"-"` // server side function (called with reflection) + Type string `json:"type" tstype:"\"func\""` + StopPropagation bool `json:"stoppropagation,omitempty"` + PreventDefault bool `json:"preventdefault,omitempty"` + GlobalEvent string `json:"globalevent,omitempty"` + Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture" +} + +// used in props +type VDomRef struct { + Type string `json:"type" tstype:"\"ref\""` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + Position *VDomRefPosition `json:"position,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` +} + +type DomRect struct { + Top float64 `json:"top"` + Left float64 `json:"left"` + Right float64 `json:"right"` + Bottom float64 `json:"bottom"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type VDomRefPosition struct { + OffsetHeight int `json:"offsetheight"` + OffsetWidth int `json:"offsetwidth"` + ScrollHeight int `json:"scrollheight"` + ScrollWidth int `json:"scrollwidth"` + ScrollTop int `json:"scrolltop"` + BoundingClientRect DomRect `json:"boundingclientrect"` +} + +///// subbordinate protocol types + +type VDomEvent struct { + WaveId string `json:"waveid"` // empty for global events + EventType string `json:"eventtype"` + EventData any `json:"eventdata"` +} + +type VDomRenderContext struct { + BlockId string `json:"blockid"` + Focused bool `json:"focused"` + Width int `json:"width"` + Height int `json:"height"` + RootRefId string `json:"rootrefid"` + Background bool `json:"background,omitempty"` +} + +type VDomStateSync struct { + Atom string `json:"atom"` + Value any `json:"value"` +} + +type VDomRefUpdate struct { + RefId string `json:"refid"` + HasCurrent bool `json:"hascurrent"` + Position *VDomRefPosition `json:"position,omitempty"` +} + +type VDomBackendOpts struct { + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` +} + +type VDomRenderUpdate struct { + UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` + WaveId string `json:"waveid,omitempty"` + VDom VDomElem `json:"vdom"` + Index *int `json:"index,omitempty"` +} + +type VDomRefOperation struct { + RefId string `json:"refid"` + Op string `json:"op" tsype:"\"focus\""` + Params []any `json:"params,omitempty"` +} + +type VDomMessage struct { + MessageType string `json:"messagetype"` + Message string `json:"message"` + StackTrace string `json:"stacktrace,omitempty"` + Params []any `json:"params,omitempty"` +} + +// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. +// default is vdom context inside of a terminal block +type VDomTarget struct { + NewBlock bool `json:"newblock,omitempty"` + Magnified bool `json:"magnified,omitempty"` +} + +// matches WaveKeyboardEvent +type VDomKeyboardEvent struct { + Type string `json:"type"` + Key string `json:"key"` + Code string `json:"code"` + Shift bool `json:"shift,omitempty"` + Control bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` + Option bool `json:"option,omitempty"` + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` +} diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go new file mode 100644 index 000000000..79ee5d743 --- /dev/null +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -0,0 +1,238 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdomclient + +import ( + "context" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +type Client struct { + Lock *sync.Mutex + Root *vdom.RootElem + RootElem *vdom.VDomElem + RpcClient *wshutil.WshRpc + RpcContext *wshrpc.RpcContext + ServerImpl *VDomServerImpl + IsDone bool + RouteId string + VDomContextBlockId string + DoneReason string + DoneCh chan struct{} + Opts vdom.VDomBackendOpts + GlobalEventHandler func(client *Client, event vdom.VDomEvent) +} + +type VDomServerImpl struct { + Client *Client + BlockId string +} + +func (*VDomServerImpl) WshServerImpl() {} + +func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) { + if feUpdate.Dispose { + log.Printf("got dispose from frontend\n") + impl.Client.doShutdown("got dispose from frontend") + return nil, nil + } + if impl.Client.GetIsDone() { + return nil, nil + } + // set atoms + for _, ss := range feUpdate.StateSync { + impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.WaveId == "" { + if impl.Client.GlobalEventHandler != nil { + impl.Client.GlobalEventHandler(impl.Client, event) + } + } else { + impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) + } + } + if feUpdate.Resync { + return impl.Client.fullRender() + } + return impl.Client.incrementalRender() +} + +func (c *Client) GetIsDone() bool { + c.Lock.Lock() + defer c.Lock.Unlock() + return c.IsDone +} + +func (c *Client) doShutdown(reason string) { + c.Lock.Lock() + defer c.Lock.Unlock() + if c.IsDone { + return + } + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) +} + +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { + c.GlobalEventHandler = handler +} + +func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { + client := &Client{ + Lock: &sync.Mutex{}, + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), + } + if opts != nil { + client.Opts = *opts + } + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName) + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + client.RpcContext = rpcCtx + if client.RpcContext == nil || client.RpcContext.BlockId == "" { + return nil, fmt.Errorf("no block id in rpc context") + } + client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client} + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl) + if err != nil { + return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err) + } + client.RpcClient = rpcClient + authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return nil, fmt.Errorf("error authenticating rpc connection: %v", err) + } + client.RouteId = authRtn.RouteId + return client, nil +} + +func (c *Client) SetRootElem(elem *vdom.VDomElem) { + c.RootElem = elem +} + +func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { + blockORef, err := wshclient.VDomCreateContextCommand( + c.RpcClient, + vdom.VDomCreateContext{Target: target}, + &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}, + ) + if err != nil { + return err + } + c.VDomContextBlockId = blockORef.OID + log.Printf("created vdom context: %v\n", blockORef) + gotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{ + RouteId: wshutil.MakeFeBlockRouteId(blockORef.OID), + WaitMs: 4000, + }, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("error waiting for vdom context route: %v", err) + } + if !gotRoute { + return fmt.Errorf("vdom context route could not be established") + } + wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{ + blockORef.String(), + }}, nil) + c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + c.doShutdown("got blockclose event") + }) + return nil +} + +func (c *Client) SendAsyncInitiation() error { + if c.VDomContextBlockId == "" { + return fmt.Errorf("no vdom context block id") + } + if c.GetIsDone() { + return fmt.Errorf("client is done") + } + return wshclient.VDomAsyncInitiationCommand( + c.RpcClient, + vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), + &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)}, + ) +} + +func (c *Client) SetAtomVals(m map[string]any) { + for k, v := range m { + c.Root.SetAtomVal(k, v, true) + } +} + +func (c *Client) SetAtomVal(name string, val any) { + c.Root.SetAtomVal(name, val, true) +} + +func (c *Client) GetAtomVal(name string) any { + return c.Root.GetAtomVal(name) +} + +func makeNullVDom() *vdom.VDomElem { + return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} +} + +func (c *Client) RegisterComponent(name string, cfunc vdom.CFunc) { + c.Root.RegisterComponent(name, cfunc) +} + +func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + c.Root.Render(c.RootElem) + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + Opts: &c.Opts, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(true), + }, nil +} + +func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(false), + }, nil +} diff --git a/pkg/wavebase/wavebase-posix.go b/pkg/wavebase/wavebase-posix.go index 352302221..12d2e7f75 100644 --- a/pkg/wavebase/wavebase-posix.go +++ b/pkg/wavebase/wavebase-posix.go @@ -14,8 +14,8 @@ import ( ) func AcquireWaveLock() (FDLock, error) { - homeDir := GetWaveHomeDir() - lockFileName := filepath.Join(homeDir, WaveLockFile) + dataHomeDir := GetWaveDataDir() + lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) fd, err := os.OpenFile(lockFileName, os.O_RDWR|os.O_CREATE, 0600) if err != nil { diff --git a/pkg/wavebase/wavebase-win.go b/pkg/wavebase/wavebase-win.go index a22ac2f85..31bdab821 100644 --- a/pkg/wavebase/wavebase-win.go +++ b/pkg/wavebase/wavebase-win.go @@ -14,8 +14,8 @@ import ( ) func AcquireWaveLock() (FDLock, error) { - homeDir := GetWaveHomeDir() - lockFileName := filepath.Join(homeDir, WaveLockFile) + dataHomeDir := GetWaveDataDir() + lockFileName := filepath.Join(dataHomeDir, WaveLockFile) log.Printf("[base] acquiring lock on %s\n", lockFileName) m, err := filemutex.New(lockFileName) if err != nil { diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index fd0cb5738..805386d52 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -17,22 +17,26 @@ import ( "strings" "sync" "time" + + "github.com/wavetermdev/waveterm/pkg/util/panic" ) // set by main-server.go var WaveVersion = "0.0.0" var BuildTime = "0" -const DefaultWaveHome = "~/.waveterm" -const DevWaveHome = "~/.waveterm-dev" -const WaveHomeVarName = "WAVETERM_HOME" +const WaveConfigHomeEnvVar = "WAVETERM_CONFIG_HOME" +const WaveDataHomeEnvVar = "WAVETERM_DATA_HOME" const WaveDevVarName = "WAVETERM_DEV" const WaveLockFile = "wave.lock" const DomainSocketBaseName = "wave.sock" +const RemoteDomainSocketBaseName = "wave-remote.sock" const WaveDBDir = "db" const JwtSecret = "waveterm" // TODO generate and store this const ConfigDir = "config" +var RemoteWaveHome = ExpandHomeDirSafe("~/.waveterm") + const WaveAppPathVarName = "WAVETERM_APP_PATH" const AppPathBinDir = "bin" @@ -97,30 +101,43 @@ func ReplaceHomeDir(pathStr string) string { } func GetDomainSocketName() string { - return filepath.Join(GetWaveHomeDir(), DomainSocketBaseName) + return filepath.Join(GetWaveDataDir(), DomainSocketBaseName) } -func GetWaveHomeDir() string { - homeVar := os.Getenv(WaveHomeVarName) - if homeVar != "" { - return ExpandHomeDirSafe(homeVar) - } - if IsDevMode() { - return ExpandHomeDirSafe(DevWaveHome) - } - return ExpandHomeDirSafe(DefaultWaveHome) +func GetRemoteDomainSocketName() string { + return filepath.Join(RemoteWaveHome, RemoteDomainSocketBaseName) } -func EnsureWaveHomeDir() error { - return CacheEnsureDir(GetWaveHomeDir(), "wavehome", 0700, "wave home directory") +func GetWaveDataDir() string { + retVal, found := os.LookupEnv(WaveDataHomeEnvVar) + if !found { + panic.Panic(WaveDataHomeEnvVar + " not set") + } + return retVal +} + +func GetWaveConfigDir() string { + retVal, found := os.LookupEnv(WaveConfigHomeEnvVar) + if !found { + panic.Panic(WaveConfigHomeEnvVar + " not set") + } + return retVal +} + +func EnsureWaveDataDir() error { + return CacheEnsureDir(GetWaveDataDir(), "wavehome", 0700, "wave home directory") } func EnsureWaveDBDir() error { - return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") + return CacheEnsureDir(filepath.Join(GetWaveDataDir(), WaveDBDir), "wavedb", 0700, "wave db directory") } func EnsureWaveConfigDir() error { - return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), ConfigDir), "waveconfig", 0700, "wave config directory") + return CacheEnsureDir(GetWaveConfigDir(), "waveconfig", 0700, "wave config directory") +} + +func EnsureWavePresetsDir() error { + return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory") } func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index e904c3def..fc3a73584 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -79,6 +79,13 @@ const ( MetaKey_TermLocalShellPath = "term:localshellpath" MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" + MetaKey_TermVDomSubBlockId = "term:vdomblockid" + + MetaKey_VDomClear = "vdom:*" + MetaKey_VDomInitialized = "vdom:initialized" + MetaKey_VDomCorrelationId = "vdom:correlationid" + MetaKey_VDomRoute = "vdom:route" + MetaKey_VDomPersist = "vdom:persist" MetaKey_Count = "count" ) diff --git a/pkg/waveobj/waveobj.go b/pkg/waveobj/waveobj.go index ba5931316..13111b54a 100644 --- a/pkg/waveobj/waveobj.go +++ b/pkg/waveobj/waveobj.go @@ -94,6 +94,14 @@ func ParseORef(orefStr string) (ORef, error) { return ORef{OType: otype, OID: oid}, nil } +func ParseORefNoErr(orefStr string) *ORef { + oref, err := ParseORef(orefStr) + if err != nil { + return nil + } + return &oref +} + type WaveObj interface { GetOType() string // should not depend on object state (should work with nil value) } diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 2f3e87717..5953a22b3 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -118,6 +118,7 @@ type Client struct { Meta MetaMapType `json:"meta"` TosAgreed int64 `json:"tosagreed,omitempty"` HasOldHistory bool `json:"hasoldhistory,omitempty"` + NextTabId int `json:"nexttabid,omitempty"` } func (*Client) GetOType() string { @@ -252,11 +253,13 @@ type WinSize struct { type Block struct { OID string `json:"oid"` + ParentORef string `json:"parentoref,omitempty"` Version int `json:"version"` BlockDef *BlockDef `json:"blockdef"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` Stickers []*StickerType `json:"stickers,omitempty"` Meta MetaMapType `json:"meta"` + SubBlockIds []string `json:"subblockids,omitempty"` } func (*Block) GetOType() string { diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 93c1919f0..367f44bc4 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -80,6 +80,13 @@ type MetaTSType struct { TermLocalShellPath string `json:"term:localshellpath,omitempty"` // matches settings TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` + TermVDomSubBlockId string `json:"term:vdomblockid,omitempty"` + + VDomClear bool `json:"vdom:*,omitempty"` + VDomInitialized bool `json:"vdom:initialized,omitempty"` + VDomCorrelationId string `json:"vdom:correlationid,omitempty"` + VDomRoute string `json:"vdom:route,omitempty"` + VDomPersist bool `json:"vdom:persist,omitempty"` Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } diff --git a/pkg/wconfig/defaultconfig/defaultconfig.go b/pkg/wconfig/defaultconfig/defaultconfig.go index bc28a9557..9527a069c 100644 --- a/pkg/wconfig/defaultconfig/defaultconfig.go +++ b/pkg/wconfig/defaultconfig/defaultconfig.go @@ -5,5 +5,5 @@ package defaultconfig import "embed" -//go:embed *.json +//go:embed *.json all:*/*.json var ConfigFS embed.FS diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json index 30dc05942..3f1a38135 100644 --- a/pkg/wconfig/defaultconfig/presets.json +++ b/pkg/wconfig/defaultconfig/presets.json @@ -94,23 +94,5 @@ "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", "bg:blendmode": "overlay", "bg:text": "rgb(200, 200, 200)" - }, - "ai@global": { - "display:name": "Global default", - "display:order": -1, - "ai:*": true - }, - "ai@wave": { - "display:name": "Wave Proxy - gpt-4o-mini", - "display:order": 0, - "ai:*": true, - "ai:apitype": "", - "ai:baseurl": "", - "ai:apitoken": "", - "ai:name": "", - "ai:orgid": "", - "ai:model": "gpt-4o-mini", - "ai:maxtokens": 2048, - "ai:timeoutms": 60000 } } diff --git a/pkg/wconfig/defaultconfig/presets/ai.json b/pkg/wconfig/defaultconfig/presets/ai.json new file mode 100644 index 000000000..11c0b848e --- /dev/null +++ b/pkg/wconfig/defaultconfig/presets/ai.json @@ -0,0 +1,20 @@ +{ + "ai@global": { + "display:name": "Global default", + "display:order": -1, + "ai:*": true + }, + "ai@wave": { + "display:name": "Wave Proxy - gpt-4o-mini", + "display:order": 0, + "ai:*": true, + "ai:apitype": "", + "ai:baseurl": "", + "ai:apitoken": "", + "ai:name": "", + "ai:orgid": "", + "ai:model": "gpt-4o-mini", + "ai:maxtokens": 2048, + "ai:timeoutms": 60000 + } +} diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index b5c821ecf..a8f591d0a 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -11,6 +11,7 @@ "web:defaulturl": "https://github.com/wavetermdev/waveterm", "web:defaultsearch": "https://www.google.com/search?q={query}", "window:tilegapsize": 3, + "window:maxtabcachesize": 10, "telemetry:enabled": true, "term:copyonselect": true } diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index f8ce9fd93..3478b4a4a 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -14,8 +14,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" ) -var configDirAbsPath = filepath.Join(wavebase.GetWaveHomeDir(), wavebase.ConfigDir) - var instance *Watcher var once sync.Once @@ -38,10 +36,20 @@ func GetWatcher() *Watcher { log.Printf("failed to create file watcher: %v", err) return } + configDirAbsPath := wavebase.GetWaveConfigDir() instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) + const failedStr = "failed to add path %s to watcher: %v" if err != nil { - log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err) + log.Printf(failedStr, configDirAbsPath, err) + } + + subdirs := GetConfigSubdirs() + for _, dir := range subdirs { + err = instance.watcher.Add(dir) + if err != nil { + log.Printf(failedStr, dir, err) + } } }) return instance diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 57518e734..16442a692 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -60,6 +60,7 @@ const ( ConfigKey_WindowShowMenuBar = "window:showmenubar" ConfigKey_WindowNativeTitleBar = "window:nativetitlebar" ConfigKey_WindowDisableHardwareAcceleration = "window:disablehardwareacceleration" + ConfigKey_WindowMaxTabCacheSize = "window:maxtabcachesize" ConfigKey_TelemetryClear = "telemetry:*" ConfigKey_TelemetryEnabled = "telemetry:enabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index c54de90e9..8593af40b 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -7,6 +7,8 @@ import ( "bytes" "encoding/json" "fmt" + "io/fs" + "log" "os" "path/filepath" "reflect" @@ -14,6 +16,7 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) @@ -101,6 +104,7 @@ type SettingsType struct { WindowShowMenuBar bool `json:"window:showmenubar,omitempty"` WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"` WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"` + WindowMaxTabCacheSize int `json:"window:maxtabcachesize,omitempty"` TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` @@ -180,18 +184,23 @@ func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.Meta return rtn, cerrs } +func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) { + barr, readErr := fs.ReadFile(fsys, fileName) + return readConfigHelper(logPrefix+fileName, barr, readErr) +} + func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - barr, readErr := defaultconfig.ConfigFS.ReadFile(fileName) - return readConfigHelper("defaults:"+fileName, barr, readErr) + return readConfigFileFS(defaultconfig.ConfigFS, "defaults:", fileName) } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - fullFileName := filepath.Join(configDirAbsPath, fileName) - barr, err := os.ReadFile(fullFileName) - return readConfigHelper(fullFileName, barr, err) + configDirAbsPath := wavebase.GetWaveConfigDir() + configDirFsys := os.DirFS(configDirAbsPath) + return readConfigFileFS(configDirFsys, "", fileName) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { + configDirAbsPath := wavebase.GetWaveConfigDir() fullFileName := filepath.Join(configDirAbsPath, fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { @@ -221,17 +230,71 @@ func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) wave return m } -func ReadConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { - defConfig, cerrs1 := ReadDefaultsConfigFile(partName) - userConfig, cerrs2 := ReadWaveHomeConfigFile(partName) - allErrs := append(cerrs1, cerrs2...) +func mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType { if simpleMerge { - return mergeMetaMapSimple(defConfig, userConfig), allErrs + return mergeMetaMapSimple(m, toMerge) } else { - return waveobj.MergeMeta(defConfig, userConfig, true), allErrs + return waveobj.MergeMeta(m, toMerge, true) } } +func selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry { + var rtn []fs.DirEntry + for _, ent := range dirEnts { + if ent.IsDir() { + continue + } + if !strings.HasSuffix(ent.Name(), fileNameSuffix) { + continue + } + rtn = append(rtn, ent) + } + return rtn +} + +func SortFileNameDescend(files []fs.DirEntry) { + sort.Slice(files, func(i, j int) bool { + return files[i].Name() > files[j].Name() + }) +} + +// Read and merge all files in the specified directory matching the supplied suffix +func readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + dirEnts, _ := fs.ReadDir(fsys, dirName) + suffixEnts := selectDirEntsBySuffix(dirEnts, fileName+".json") + SortFileNameDescend(suffixEnts) + var rtn waveobj.MetaMapType + var errs []ConfigError + for _, ent := range suffixEnts { + fileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name())) + rtn = mergeMetaMap(rtn, fileVal, simpleMerge) + errs = append(errs, cerrs...) + } + return rtn, errs +} + +// Read and merge all files in the specified config filesystem matching the patterns `.json` and `/*.json` +func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + config, errs := readConfigFilesForDir(fsys, logPrefix, partName, "", simpleMerge) + allErrs := errs + rtn := config + config, errs = readConfigFileFS(fsys, logPrefix, partName+".json") + allErrs = append(allErrs, errs...) + return mergeMetaMap(rtn, config, simpleMerge), allErrs +} + +// Combine files from the defaults and home directory for the specified config part name +func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + configDirAbsPath := wavebase.GetWaveConfigDir() + configDirFsys := os.DirFS(configDirAbsPath) + defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge) + homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge) + + rtn := defaultConfigs + allErrs := append(cerrs, cerrs1...) + return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs +} + func ReadFullConfig() FullConfigType { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) @@ -246,13 +309,15 @@ func ReadFullConfig() FullConfigType { continue } jsonTag := utilfn.GetJsonTag(field) + simpleMerge := field.Tag.Get("merge") == "" + var configPart waveobj.MetaMapType + var errs []ConfigError if jsonTag == "-" || jsonTag == "" { continue + } else { + configPart, errs = readConfigPart(jsonTag, simpleMerge) } - simpleMerge := field.Tag.Get("merge") == "" - fileName := jsonTag + ".json" - configPart, cerrs := ReadConfigPart(fileName, simpleMerge) - fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, cerrs...) + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...) if configPart != nil { fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() utilfn.ReUnmarshal(fieldPtr, configPart) @@ -261,6 +326,29 @@ func ReadFullConfig() FullConfigType { return fullConfig } +func GetConfigSubdirs() []string { + var fullConfig FullConfigType + configRType := reflect.TypeOf(fullConfig) + var retVal []string + configDirAbsPath := wavebase.GetWaveConfigDir() + for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { + field := configRType.Field(fieldIdx) + if field.PkgPath != "" { + continue + } + configFile := field.Tag.Get("configfile") + if configFile == "-" { + continue + } + jsonTag := utilfn.GetJsonTag(field) + if jsonTag != "-" && jsonTag != "" && jsonTag != "settings" { + retVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag)) + } + } + log.Printf("subdirs: %v\n", retVal) + return retVal +} + func getConfigKeyType(configKey string) reflect.Type { ctype := reflect.TypeOf(SettingsType{}) for i := 0; i < ctype.NumField(); i++ { diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index e2967b7f0..78761bc63 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -26,21 +26,35 @@ import ( const DefaultTimeout = 2 * time.Second const DefaultActivateBlockTimeout = 60 * time.Second -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { - err := wstore.DeleteBlock(ctx, tabId, blockId) +func DeleteBlock(ctx context.Context, blockId string) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + if block == nil { + return nil + } + if len(block.SubBlockIds) > 0 { + for _, subBlockId := range block.SubBlockIds { + err := DeleteBlock(ctx, subBlockId) + if err != nil { + return fmt.Errorf("error deleting subblock %s: %w", subBlockId, err) + } + } + } + err = wstore.DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } go blockcontroller.StopBlockController(blockId) - sendBlockCloseEvent(tabId, blockId) + sendBlockCloseEvent(blockId) return nil } -func sendBlockCloseEvent(tabId string, blockId string) { +func sendBlockCloseEvent(blockId string) { waveEvent := wps.WaveEvent{ Event: wps.Event_BlockClose, Scopes: []string{ - waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), waveobj.MakeORef(waveobj.OType_Block, blockId).String(), }, Data: blockId, @@ -58,7 +72,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { } // close blocks (sends events + stops block controllers) for _, blockId := range tabData.BlockIds { - err := DeleteBlock(ctx, tabId, blockId) + err := DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block %s: %w", blockId, err) } @@ -78,6 +92,18 @@ func CreateTab(ctx context.Context, windowId string, tabName string, activateTab if err != nil { return "", fmt.Errorf("error getting window: %w", err) } + if tabName == "" { + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + tabName = "T" + fmt.Sprint(client.NextTabId) + client.NextTabId++ + err = wstore.DBUpdate(ctx, client) + if err != nil { + return "", fmt.Errorf("error updating client: %w", err) + } + } tab, err := wstore.CreateTab(ctx, windowData.WorkspaceId, tabName) if err != nil { return "", fmt.Errorf("error creating tab: %w", err) @@ -122,7 +148,7 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize) (*waveobj.Windo if err != nil { return nil, fmt.Errorf("error inserting workspace: %w", err) } - _, err = CreateTab(ctx, windowId, "T1", true) + _, err = CreateTab(ctx, windowId, "", true) if err != nil { return nil, fmt.Errorf("error inserting tab: %w", err) } @@ -151,7 +177,7 @@ func checkAndFixWindow(ctx context.Context, windowId string) { } if len(workspace.TabIds) == 0 { log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", workspace.OID) - _, err = CreateTab(ctx, windowId, "T1", true) + _, err = CreateTab(ctx, windowId, "", true) if err != nil { log.Printf("error creating tab (in checkAndFixWindow): %v\n", err) } @@ -172,6 +198,17 @@ func EnsureInitialData() (*waveobj.Window, bool, error) { } firstRun = true } + if client.NextTabId == 0 { + tabCount, err := wstore.DBGetCount[*waveobj.Tab](ctx) + if err != nil { + return nil, false, fmt.Errorf("error getting tab count: %w", err) + } + client.NextTabId = tabCount + 1 + err = wstore.DBUpdate(ctx, client) + if err != nil { + return nil, false, fmt.Errorf("error updating client: %w", err) + } + } log.Printf("clientid: %s\n", client.OID) if len(client.WindowIds) == 1 { checkAndFixWindow(ctx, client.WindowIds[0]) @@ -190,6 +227,7 @@ func CreateClient(ctx context.Context) (*waveobj.Client, error) { client := &waveobj.Client{ OID: uuid.NewString(), WindowIds: []string{}, + NextTabId: 1, } err := wstore.DBInsert(ctx, client) if err != nil { @@ -198,6 +236,20 @@ func CreateClient(ctx context.Context) (*waveobj.Client, error) { return client, nil } +func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + if blockDef == nil { + return nil, fmt.Errorf("blockDef is nil") + } + if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" { + return nil, fmt.Errorf("no view provided for new block") + } + blockData, err := wstore.CreateSubBlock(ctx, blockId, blockDef) + if err != nil { + return nil, fmt.Errorf("error creating sub block: %w", err) + } + return blockData, nil +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { if blockDef == nil { return nil, fmt.Errorf("blockDef is nil") diff --git a/pkg/web/web.go b/pkg/web/web.go index 0e470490b..695c73bbf 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -431,7 +431,7 @@ func MakeTCPListener(serviceName string) (net.Listener, error) { } func MakeUnixListener() (net.Listener, error) { - serverAddr := wavebase.GetWaveHomeDir() + "/wave.sock" + serverAddr := wavebase.GetDomainSocketName() os.Remove(serverAddr) // ignore error rtn, err := net.Listen("unix", serverAddr) if err != nil { diff --git a/pkg/web/ws.go b/pkg/web/ws.go index 5ef0c0344..c0374efff 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -252,7 +252,7 @@ func registerConn(wsConnId string, routeId string, wproxy *wshutil.WshRpcProxy) wshutil.DefaultRouter.UnregisterRoute(routeId) } RouteToConnMap[routeId] = wsConnId - wshutil.DefaultRouter.RegisterRoute(routeId, wproxy) + wshutil.DefaultRouter.RegisterRoute(routeId, wproxy, true) } func unregisterConn(wsConnId string, routeId string) { @@ -269,11 +269,10 @@ func unregisterConn(wsConnId string, routeId string) { } func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { - windowId := r.URL.Query().Get("windowid") - if windowId == "" { - return fmt.Errorf("windowid is required") + tabId := r.URL.Query().Get("tabid") + if tabId == "" { + return fmt.Errorf("tabid is required") } - err := authkey.ValidateIncomingRequest(r) if err != nil { w.WriteHeader(http.StatusUnauthorized) @@ -290,13 +289,13 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { outputCh := make(chan any, 100) closeCh := make(chan any) var routeId string - if windowId == wshutil.ElectronRoute { + if tabId == wshutil.ElectronRoute { routeId = wshutil.ElectronRoute } else { - routeId = wshutil.MakeWindowRouteId(windowId) + routeId = wshutil.MakeTabRouteId(tabId) } - log.Printf("[websocket] new connection: windowid:%s connid:%s routeid:%s\n", windowId, wsConnId, routeId) - eventbus.RegisterWSChannel(wsConnId, windowId, outputCh) + log.Printf("[websocket] new connection: tabid:%s connid:%s routeid:%s\n", tabId, wsConnId, routeId) + eventbus.RegisterWSChannel(wsConnId, tabId, outputCh) defer eventbus.UnregisterWSChannel(wsConnId) wproxy := wshutil.MakeRpcProxy() // we create a wshproxy to handle rpc messages to/from the window defer close(wproxy.ToRemoteCh) diff --git a/pkg/wps/wps.go b/pkg/wps/wps.go index 87e00faa2..c742dc3fd 100644 --- a/pkg/wps/wps.go +++ b/pkg/wps/wps.go @@ -5,7 +5,6 @@ package wps import ( - "log" "strings" "sync" @@ -76,7 +75,7 @@ func (b *BrokerType) GetClient() Client { // if already subscribed, this will *resubscribe* with the new subscription (remove the old one, and replace with this one) func (b *BrokerType) Subscribe(subRouteId string, sub SubscriptionRequest) { - log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) + // log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) if sub.Event == "" { return } @@ -138,7 +137,7 @@ func addStrToScopeMap(scopeMap map[string][]string, scope string, routeId string } func (b *BrokerType) Unsubscribe(subRouteId string, eventName string) { - log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) + // log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) b.Lock.Lock() defer b.Lock.Unlock() b.unsubscribe_nolock(subRouteId, eventName) diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index d925f3eeb..d0e6d4202 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -11,6 +11,7 @@ const ( Event_BlockFile = "blockfile" Event_Config = "config" Event_UserInput = "userinput" + Event_RouteGone = "route:gone" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 382f1041a..2539742e7 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/vdom" ) // command "authenticate", wshserver.AuthenticateCommand @@ -85,12 +86,30 @@ func CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, o return resp, err } +// command "createsubblock", wshserver.CreateSubBlockCommand +func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createsubblock", data, opts) + return resp, err +} + // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) return err } +// command "deletesubblock", wshserver.DeleteSubBlockCommand +func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) + return err +} + +// command "dispose", wshserver.DisposeCommand +func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) + return err +} + // command "eventpublish", wshserver.EventPublishCommand func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts) @@ -260,10 +279,52 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { return err } +// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand +func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) + return err +} + +// command "vdomcreatecontext", wshserver.VDomCreateContextCommand +func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) (*waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[*waveobj.ORef](w, "vdomcreatecontext", data, opts) + return resp, err +} + +// command "vdomrender", wshserver.VDomRenderCommand +func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) { + resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts) + return resp, err +} + +// command "waitforroute", wshserver.WaitForRouteCommand +func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) { + resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", data, opts) + return resp, err +} + // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) return resp, err } +// command "wsldefaultdistro", wshserver.WslDefaultDistroCommand +func WslDefaultDistroCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "wsldefaultdistro", nil, opts) + return resp, err +} + +// command "wsllist", wshserver.WslListCommand +func WslListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "wsllist", nil, opts) + return resp, err +} + +// command "wslstatus", wshserver.WslStatusCommand +func WslStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, "wslstatus", nil, opts) + return resp, err +} + diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index fbd06a27d..19754bec7 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -11,6 +11,7 @@ import ( "reflect" "github.com/wavetermdev/waveterm/pkg/ijson" + "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" @@ -27,6 +28,7 @@ const ( const ( Command_Authenticate = "authenticate" // special + Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only) Command_RouteAnnounce = "routeannounce" // special (for routing) Command_RouteUnannounce = "routeunannounce" // special (for routing) Command_Message = "message" @@ -61,14 +63,22 @@ const ( Command_RemoteFileDelete = "remotefiledelete" Command_RemoteFileJoiin = "remotefilejoin" + Command_ConnStatus = "connstatus" + Command_WslStatus = "wslstatus" Command_ConnEnsure = "connensure" Command_ConnReinstallWsh = "connreinstallwsh" Command_ConnConnect = "connconnect" Command_ConnDisconnect = "conndisconnect" Command_ConnList = "connlist" + Command_WslList = "wsllist" + Command_WslDefaultDistro = "wsldefaultdistro" Command_WebSelector = "webselector" Command_Notify = "notify" + + Command_VDomCreateContext = "vdomcreatecontext" + Command_VDomAsyncInitiation = "vdomasyncinitiation" + Command_VDomRender = "vdomrender" ) type RespOrErrorUnion[T any] struct { @@ -78,6 +88,7 @@ type RespOrErrorUnion[T any] struct { type WshRpcInterface interface { AuthenticateCommand(ctx context.Context, data string) (CommandAuthenticateRtnData, error) + DisposeCommand(ctx context.Context, data CommandDisposeData) error RouteAnnounceCommand(ctx context.Context) error // (special) announces a new route to the main router RouteUnannounceCommand(ctx context.Context) error // (special) unannounces a route to the main router @@ -92,7 +103,10 @@ type WshRpcInterface interface { FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) + CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + DeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + WaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error) FileWriteCommand(ctx context.Context, data CommandFileData) error FileReadCommand(ctx context.Context, data CommandFileData) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error @@ -109,11 +123,14 @@ type WshRpcInterface interface { // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) + WslStatusCommand(ctx context.Context) ([]ConnStatus, error) ConnEnsureCommand(ctx context.Context, connName string) error ConnReinstallWshCommand(ctx context.Context, connName string) error ConnConnectCommand(ctx context.Context, connName string) error ConnDisconnectCommand(ctx context.Context, connName string) error ConnListCommand(ctx context.Context) ([]string, error) + WslListCommand(ctx context.Context) ([]string, error) + WslDefaultDistroCommand(ctx context.Context) (string, error) // eventrecv is special, it's handled internally by WshRpc with EventListener EventRecvCommand(ctx context.Context, data wps.WaveEvent) error @@ -126,8 +143,16 @@ type WshRpcInterface interface { RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] + // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error + + // terminal + VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error) + VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error + + // proc + VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) } // for frontend @@ -187,7 +212,13 @@ func HackRpcContextIntoData(dataPtr any, rpcContext RpcContext) { } type CommandAuthenticateRtnData struct { + RouteId string `json:"routeid"` + AuthToken string `json:"authtoken,omitempty"` +} + +type CommandDisposeData struct { RouteId string `json:"routeid"` + // auth token travels in the packet directly } type CommandMessageData struct { @@ -220,6 +251,11 @@ type CommandCreateBlockData struct { Magnified bool `json:"magnified,omitempty"` } +type CommandCreateSubBlockData struct { + ParentBlockId string `json:"parentblockid"` + BlockDef *waveobj.BlockDef `json:"blockdef"` +} + type CommandBlockSetViewData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` View string `json:"view"` @@ -251,6 +287,11 @@ type CommandAppendIJsonData struct { Data ijson.Command `json:"data"` } +type CommandWaitForRouteData struct { + RouteId string `json:"routeid"` + WaitMs int `json:"waitms"` +} + type CommandDeleteBlockData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` } @@ -374,10 +415,10 @@ type CommandWebSelectorData struct { } type BlockInfoData struct { - BlockId string `json:"blockid"` - TabId string `json:"tabid"` - WindowId string `json:"windowid"` - Meta waveobj.MetaMapType `json:"meta"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + WindowId string `json:"windowid"` + Block *waveobj.Block `json:"block"` } type WaveNotificationOptions struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 91facb368..b08de1f45 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -21,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -29,6 +30,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -36,6 +38,7 @@ const SimpleId_This = "this" const SimpleId_Tab = "tab" var SimpleId_BlockNum_Regex = regexp.MustCompile(`^\d+$`) +var InvalidWslDistroNames = []string{"docker-desktop", "docker-desktop-data"} type WshServer struct{} @@ -120,7 +123,7 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM } func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { - log.Printf("SETMETA: %s | %v\n", data.ORef, data.Meta) + log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef err := wstore.UpdateObjectMeta(ctx, oref, data.Meta) if err != nil { @@ -247,6 +250,16 @@ func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.Command return &waveobj.ORef{OType: waveobj.OType_Block, OID: blockRef.OID}, nil } +func (ws *WshServer) CreateSubBlockCommand(ctx context.Context, data wshrpc.CommandCreateSubBlockData) (*waveobj.ORef, error) { + parentBlockId := data.ParentBlockId + blockData, err := wcore.CreateSubBlock(ctx, parentBlockId, data.BlockDef) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + blockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID} + return blockRef, nil +} + func (ws *WshServer) SetViewCommand(ctx context.Context, data wshrpc.CommandBlockSetViewData) error { log.Printf("SETVIEW: %s | %q\n", data.BlockId, data.View) ctx = waveobj.ContextWithUpdates(ctx) @@ -353,10 +366,10 @@ func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandF func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.CommandAppendIJsonData) error { tryCreate := true - if data.FileName == blockcontroller.BlockFile_Html && tryCreate { + if data.FileName == blockcontroller.BlockFile_VDom && tryCreate { err := filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, filestore.FileOptsType{MaxSize: blockcontroller.DefaultHtmlMaxFileSize, IJson: true}) if err != nil && err != fs.ErrExist { - return fmt.Errorf("error creating blockfile[html]: %w", err) + return fmt.Errorf("error creating blockfile[vdom]: %w", err) } } err := filestore.WFS.AppendIJson(ctx, data.ZoneId, data.FileName, data.Data) @@ -376,6 +389,14 @@ func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.Com return nil } +func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { + err := wcore.DeleteBlock(ctx, data.BlockId) + if err != nil { + return fmt.Errorf("error deleting block: %w", err) + } + return nil +} + func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { ctx = waveobj.ContextWithUpdates(ctx) tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) @@ -392,7 +413,7 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command if windowId == "" { return fmt.Errorf("no window found for tab") } - err = wcore.DeleteBlock(ctx, tabId, data.BlockId) + err = wcore.DeleteBlock(ctx, data.BlockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } @@ -405,6 +426,13 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command return nil } +func (ws *WshServer) WaitForRouteCommand(ctx context.Context, data wshrpc.CommandWaitForRouteData) (bool, error) { + waitCtx, cancelFn := context.WithTimeout(ctx, time.Duration(data.WaitMs)*time.Millisecond) + defer cancelFn() + err := wshutil.DefaultRouter.WaitForRegister(waitCtx, data.RouteId) + return err == nil, nil +} + func (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error { return nil } @@ -463,11 +491,28 @@ func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus return rtn, nil } +func (ws *WshServer) WslStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { + rtn := wsl.GetAllConnStatus() + return rtn, nil +} + func (ws *WshServer) ConnEnsureCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + return wsl.EnsureConnection(ctx, distroName) + } return conncontroller.EnsureConnection(ctx, connName) } func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("distro not found: %s", connName) + } + return conn.Close() + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -480,6 +525,14 @@ func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) } func (ws *WshServer) ConnConnectCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.Connect(ctx) + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -492,6 +545,14 @@ func (ws *WshServer) ConnConnectCommand(ctx context.Context, connName string) er } func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName string) error { + if strings.HasPrefix(connName, "wsl://") { + distroName := strings.TrimPrefix(connName, "wsl://") + conn := wsl.GetWslConn(ctx, distroName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.CheckAndInstallWsh(ctx, connName, &wsl.WshInstallOpts{Force: true, NoUserPrompt: true}) + } connOpts, err := remote.ParseOpts(connName) if err != nil { return fmt.Errorf("error parsing connection name: %w", err) @@ -507,6 +568,33 @@ func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) { return conncontroller.GetConnectionsList() } +func (ws *WshServer) WslListCommand(ctx context.Context) ([]string, error) { + distros, err := wsl.RegisteredDistros(ctx) + if err != nil { + return nil, err + } + var distroNames []string + for _, distro := range distros { + distroName := distro.Name() + if utilfn.ContainsStr(InvalidWslDistroNames, distroName) { + continue + } + distroNames = append(distroNames, distroName) + } + return distroNames, nil +} + +func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error) { + distro, ok, err := wsl.DefaultDistro(ctx) + if err != nil { + return "", fmt.Errorf("unable to determine default distro: %w", err) + } + if !ok { + return "", fmt.Errorf("unable to determine default distro") + } + return distro.Name(), nil +} + func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) { blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { @@ -524,6 +612,6 @@ func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wsh BlockId: blockId, TabId: tabId, WindowId: windowId, - Meta: blockData.Meta, + Block: blockData, }, nil } diff --git a/pkg/wshutil/wshmultiproxy.go b/pkg/wshutil/wshmultiproxy.go new file mode 100644 index 000000000..be2888bf1 --- /dev/null +++ b/pkg/wshutil/wshmultiproxy.go @@ -0,0 +1,151 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type multiProxyRouteInfo struct { + RouteId string + AuthToken string + Proxy *WshRpcProxy + RpcContext *wshrpc.RpcContext +} + +// handles messages from multiple unauthenitcated clients +type WshRpcMultiProxy struct { + Lock *sync.Mutex + RouteInfo map[string]*multiProxyRouteInfo // authtoken to info + ToRemoteCh chan []byte + FromRemoteRawCh chan []byte // raw message from the remote +} + +func MakeRpcMultiProxy() *WshRpcMultiProxy { + return &WshRpcMultiProxy{ + Lock: &sync.Mutex{}, + RouteInfo: make(map[string]*multiProxyRouteInfo), + ToRemoteCh: make(chan []byte, DefaultInputChSize), + FromRemoteRawCh: make(chan []byte, DefaultOutputChSize), + } +} + +func (p *WshRpcMultiProxy) DisposeRoutes() { + p.Lock.Lock() + defer p.Lock.Unlock() + for authToken, routeInfo := range p.RouteInfo { + DefaultRouter.UnregisterRoute(routeInfo.RouteId) + delete(p.RouteInfo, authToken) + } +} + +func (p *WshRpcMultiProxy) getRouteInfo(authToken string) *multiProxyRouteInfo { + p.Lock.Lock() + defer p.Lock.Unlock() + return p.RouteInfo[authToken] +} + +func (p *WshRpcMultiProxy) setRouteInfo(authToken string, routeInfo *multiProxyRouteInfo) { + p.Lock.Lock() + defer p.Lock.Unlock() + p.RouteInfo[authToken] = routeInfo +} + +func (p *WshRpcMultiProxy) removeRouteInfo(authToken string) { + p.Lock.Lock() + defer p.Lock.Unlock() + delete(p.RouteInfo, authToken) +} + +func (p *WshRpcMultiProxy) sendResponseError(msg RpcMessage, sendErr error) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Error: sendErr.Error(), + } + respBytes, _ := json.Marshal(resp) + p.ToRemoteCh <- respBytes +} + +func (p *WshRpcMultiProxy) sendAuthResponse(msg RpcMessage, routeId string, authToken string) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Data: wshrpc.CommandAuthenticateRtnData{RouteId: routeId, AuthToken: authToken}, + } + respBytes, _ := json.Marshal(resp) + p.ToRemoteCh <- respBytes +} + +func (p *WshRpcMultiProxy) handleUnauthMessage(msgBytes []byte) { + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + // nothing to do here, malformed message + return + } + if msg.Command == wshrpc.Command_Authenticate { + rpcContext, routeId, err := handleAuthenticationCommand(msg) + if err != nil { + p.sendResponseError(msg, err) + return + } + routeInfo := &multiProxyRouteInfo{ + RouteId: routeId, + AuthToken: uuid.New().String(), + RpcContext: rpcContext, + } + routeInfo.Proxy = MakeRpcProxy() + routeInfo.Proxy.SetRpcContext(rpcContext) + p.setRouteInfo(routeInfo.AuthToken, routeInfo) + p.sendAuthResponse(msg, routeId, routeInfo.AuthToken) + go func() { + for msgBytes := range routeInfo.Proxy.ToRemoteCh { + p.ToRemoteCh <- msgBytes + } + }() + DefaultRouter.RegisterRoute(routeId, routeInfo.Proxy, true) + return + } + if msg.AuthToken == "" { + p.sendResponseError(msg, fmt.Errorf("no auth token")) + return + } + routeInfo := p.getRouteInfo(msg.AuthToken) + if routeInfo == nil { + p.sendResponseError(msg, fmt.Errorf("invalid auth token")) + return + } + if msg.Command != "" && msg.Source != routeInfo.RouteId { + p.sendResponseError(msg, fmt.Errorf("invalid source route for auth token")) + return + } + if msg.Command == wshrpc.Command_Dispose { + DefaultRouter.UnregisterRoute(routeInfo.RouteId) + p.removeRouteInfo(msg.AuthToken) + close(routeInfo.Proxy.ToRemoteCh) + close(routeInfo.Proxy.FromRemoteCh) + return + } + routeInfo.Proxy.FromRemoteCh <- msgBytes +} + +func (p *WshRpcMultiProxy) RunUnauthLoop() { + // loop over unauthenticated message + // handle Authenicate commands, and pass authenticated messages to the AuthCh + for msgBytes := range p.FromRemoteRawCh { + p.handleUnauthMessage(msgBytes) + } +} diff --git a/pkg/wshutil/wshproxy.go b/pkg/wshutil/wshproxy.go index c6a1ecf9f..c919b5d07 100644 --- a/pkg/wshutil/wshproxy.go +++ b/pkg/wshutil/wshproxy.go @@ -6,7 +6,6 @@ package wshutil import ( "encoding/json" "fmt" - "log" "sync" "github.com/google/uuid" @@ -18,6 +17,7 @@ type WshRpcProxy struct { RpcContext *wshrpc.RpcContext ToRemoteCh chan []byte FromRemoteCh chan []byte + AuthToken string } func MakeRpcProxy() *WshRpcProxy { @@ -40,6 +40,18 @@ func (p *WshRpcProxy) GetRpcContext() *wshrpc.RpcContext { return p.RpcContext } +func (p *WshRpcProxy) SetAuthToken(authToken string) { + p.Lock.Lock() + defer p.Lock.Unlock() + p.AuthToken = authToken +} + +func (p *WshRpcProxy) GetAuthToken() string { + p.Lock.Lock() + defer p.Lock.Unlock() + return p.AuthToken +} + func (p *WshRpcProxy) sendResponseError(msg RpcMessage, sendErr error) { if msg.ReqId == "" { // no response needed @@ -54,7 +66,7 @@ func (p *WshRpcProxy) sendResponseError(msg RpcMessage, sendErr error) { p.SendRpcMessage(respBytes) } -func (p *WshRpcProxy) sendResponse(msg RpcMessage, routeId string) { +func (p *WshRpcProxy) sendAuthenticateResponse(msg RpcMessage, routeId string) { if msg.ReqId == "" { // no response needed return @@ -98,6 +110,49 @@ func handleAuthenticationCommand(msg RpcMessage) (*wshrpc.RpcContext, string, er return newCtx, routeId, nil } +// runs on the client (stdio client) +func (p *WshRpcProxy) HandleClientProxyAuth(router *WshRouter) (string, error) { + for { + msgBytes, ok := <-p.FromRemoteCh + if !ok { + return "", fmt.Errorf("remote closed, not authenticated") + } + var origMsg RpcMessage + err := json.Unmarshal(msgBytes, &origMsg) + if err != nil { + // nothing to do, can't even send a response since we don't have Source or ReqId + continue + } + if origMsg.Command == "" { + // this message is not allowed (protocol error at this point), ignore + continue + } + // we only allow one command "authenticate", everything else returns an error + if origMsg.Command != wshrpc.Command_Authenticate { + respErr := fmt.Errorf("connection not authenticated") + p.sendResponseError(origMsg, respErr) + continue + } + authRtn, err := router.HandleProxyAuth(origMsg.Data) + if err != nil { + respErr := fmt.Errorf("error handling proxy auth: %w", err) + p.sendResponseError(origMsg, respErr) + return "", respErr + } + p.SetAuthToken(authRtn.AuthToken) + announceMsg := RpcMessage{ + Command: wshrpc.Command_RouteAnnounce, + Source: authRtn.RouteId, + AuthToken: authRtn.AuthToken, + } + announceBytes, _ := json.Marshal(announceMsg) + router.InjectMessage(announceBytes, authRtn.RouteId) + p.sendAuthenticateResponse(origMsg, authRtn.RouteId) + return authRtn.RouteId, nil + } +} + +// runs on the server func (p *WshRpcProxy) HandleAuthentication() (*wshrpc.RpcContext, error) { for { msgBytes, ok := <-p.FromRemoteCh @@ -122,11 +177,10 @@ func (p *WshRpcProxy) HandleAuthentication() (*wshrpc.RpcContext, error) { } newCtx, routeId, err := handleAuthenticationCommand(msg) if err != nil { - log.Printf("error handling authentication: %v\n", err) p.sendResponseError(msg, err) continue } - p.sendResponse(msg, routeId) + p.sendAuthenticateResponse(msg, routeId) return newCtx, nil } } @@ -136,9 +190,10 @@ func (p *WshRpcProxy) SendRpcMessage(msg []byte) { } func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { - msgBytes, ok := <-p.FromRemoteCh - if !ok || p.RpcContext == nil { - return msgBytes, ok + msgBytes, more := <-p.FromRemoteCh + authToken := p.GetAuthToken() + if !more || (p.RpcContext == nil && authToken == "") { + return msgBytes, more } var msg RpcMessage err := json.Unmarshal(msgBytes, &msg) @@ -146,10 +201,15 @@ func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { // nothing to do here -- will error out at another level return msgBytes, true } - msg.Data, err = recodeCommandData(msg.Command, msg.Data, p.RpcContext) - if err != nil { - // nothing to do here -- will error out at another level - return msgBytes, true + if p.RpcContext != nil { + msg.Data, err = recodeCommandData(msg.Command, msg.Data, p.RpcContext) + if err != nil { + // nothing to do here -- will error out at another level + return msgBytes, true + } + } + if msg.AuthToken == "" { + msg.AuthToken = authToken } newBytes, err := json.Marshal(msg) if err != nil { diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 6389e3d99..10b5df517 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -12,11 +12,14 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" ) const DefaultRoute = "wavesrv" +const UpstreamRoute = "upstream" const SysRoute = "sys" // this route doesn't exist, just a placeholder for system messages const ElectronRoute = "electron" @@ -36,12 +39,13 @@ type msgAndRoute struct { } type WshRouter struct { - Lock *sync.Mutex - RouteMap map[string]AbstractRpcClient // routeid => client - UpstreamClient AbstractRpcClient // upstream client (if we are not the terminal router) - AnnouncedRoutes map[string]string // routeid => local routeid - RpcMap map[string]*routeInfo // rpcid => routeinfo - InputCh chan msgAndRoute + Lock *sync.Mutex + RouteMap map[string]AbstractRpcClient // routeid => client + UpstreamClient AbstractRpcClient // upstream client (if we are not the terminal router) + AnnouncedRoutes map[string]string // routeid => local routeid + RpcMap map[string]*routeInfo // rpcid => routeinfo + SimpleRequestMap map[string]chan *RpcMessage // simple reqid => response channel + InputCh chan msgAndRoute } func MakeConnectionRouteId(connId string) string { @@ -52,23 +56,28 @@ func MakeControllerRouteId(blockId string) string { return "controller:" + blockId } -func MakeWindowRouteId(windowId string) string { - return "window:" + windowId -} - func MakeProcRouteId(procId string) string { return "proc:" + procId } +func MakeTabRouteId(tabId string) string { + return "tab:" + tabId +} + +func MakeFeBlockRouteId(blockId string) string { + return "feblock:" + blockId +} + var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { rtn := &WshRouter{ - Lock: &sync.Mutex{}, - RouteMap: make(map[string]AbstractRpcClient), - AnnouncedRoutes: make(map[string]string), - RpcMap: make(map[string]*routeInfo), - InputCh: make(chan msgAndRoute, DefaultInputChSize), + Lock: &sync.Mutex{}, + RouteMap: make(map[string]AbstractRpcClient), + AnnouncedRoutes: make(map[string]string), + RpcMap: make(map[string]*routeInfo), + SimpleRequestMap: make(map[string]chan *RpcMessage), + InputCh: make(chan msgAndRoute, DefaultInputChSize), } go rtn.runServer() return rtn @@ -233,6 +242,10 @@ func (router *WshRouter) runServer() { router.sendRoutedMessage(msgBytes, routeInfo.DestRouteId) continue } else if msg.ResId != "" { + ok := router.trySimpleResponse(&msg) + if ok { + continue + } routeInfo := router.getRouteInfo(msg.ResId) if routeInfo == nil { // no route info, nothing to do @@ -255,6 +268,9 @@ func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) er if router.GetRpc(routeId) != nil { return nil } + if router.getAnnouncedRoute(routeId) != "" { + return nil + } select { case <-ctx.Done(): return ctx.Err() @@ -265,10 +281,10 @@ func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) er } // this will also consume the output channel of the abstract client -func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) { - if routeId == SysRoute { +func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient, shouldAnnounce bool) { + if routeId == SysRoute || routeId == UpstreamRoute { // cannot register sys route - log.Printf("error: WshRouter cannot register sys route\n") + log.Printf("error: WshRouter cannot register %s route\n", routeId) return } log.Printf("[router] registering wsh route %q\n", routeId) @@ -281,7 +297,7 @@ func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) { router.RouteMap[routeId] = rpc go func() { // announce - if !alreadyExists && router.GetUpstreamClient() != nil { + if shouldAnnounce && !alreadyExists && router.GetUpstreamClient() != nil { announceMsg := RpcMessage{Command: wshrpc.Command_RouteAnnounce, Source: routeId} announceBytes, _ := json.Marshal(announceMsg) router.GetUpstreamClient().SendRpcMessage(announceBytes) @@ -326,6 +342,7 @@ func (router *WshRouter) UnregisterRoute(routeId string) { } go func() { wps.Broker.UnsubscribeAll(routeId) + wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteGone, Scopes: []string{routeId}}) }() } @@ -347,3 +364,97 @@ func (router *WshRouter) GetUpstreamClient() AbstractRpcClient { defer router.Lock.Unlock() return router.UpstreamClient } + +func (router *WshRouter) InjectMessage(msgBytes []byte, fromRouteId string) { + router.InputCh <- msgAndRoute{msgBytes: msgBytes, fromRouteId: fromRouteId} +} + +func (router *WshRouter) registerSimpleRequest(reqId string) chan *RpcMessage { + router.Lock.Lock() + defer router.Lock.Unlock() + rtn := make(chan *RpcMessage, 1) + router.SimpleRequestMap[reqId] = rtn + return rtn +} + +func (router *WshRouter) trySimpleResponse(msg *RpcMessage) bool { + router.Lock.Lock() + defer router.Lock.Unlock() + respCh := router.SimpleRequestMap[msg.ResId] + if respCh == nil { + return false + } + respCh <- msg + delete(router.SimpleRequestMap, msg.ResId) + return true +} + +func (router *WshRouter) clearSimpleRequest(reqId string) { + router.Lock.Lock() + defer router.Lock.Unlock() + delete(router.SimpleRequestMap, reqId) +} + +func (router *WshRouter) RunSimpleRawCommand(ctx context.Context, msg RpcMessage, fromRouteId string) (*RpcMessage, error) { + if msg.Command == "" { + return nil, errors.New("no command") + } + msgBytes, err := json.Marshal(msg) + if err != nil { + return nil, err + } + var respCh chan *RpcMessage + if msg.ReqId != "" { + respCh = router.registerSimpleRequest(msg.ReqId) + } + router.InjectMessage(msgBytes, fromRouteId) + if respCh == nil { + return nil, nil + } + select { + case <-ctx.Done(): + router.clearSimpleRequest(msg.ReqId) + return nil, ctx.Err() + case resp := <-respCh: + if resp.Error != "" { + return nil, errors.New(resp.Error) + } + return resp, nil + } +} + +func (router *WshRouter) HandleProxyAuth(jwtTokenAny any) (*wshrpc.CommandAuthenticateRtnData, error) { + if jwtTokenAny == nil { + return nil, errors.New("no jwt token") + } + jwtToken, ok := jwtTokenAny.(string) + if !ok { + return nil, errors.New("jwt token not a string") + } + if jwtToken == "" { + return nil, errors.New("empty jwt token") + } + msg := RpcMessage{ + Command: wshrpc.Command_Authenticate, + ReqId: uuid.New().String(), + Data: jwtToken, + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeoutMs*time.Millisecond) + defer cancelFn() + resp, err := router.RunSimpleRawCommand(ctx, msg, "") + if err != nil { + return nil, err + } + if resp == nil || resp.Data == nil { + return nil, errors.New("no data in authenticate response") + } + var respData wshrpc.CommandAuthenticateRtnData + err = utilfn.ReUnmarshal(&respData, resp.Data) + if err != nil { + return nil, fmt.Errorf("error unmarshalling authenticate response: %v", err) + } + if respData.AuthToken == "" { + return nil, errors.New("no auth token in authenticate response") + } + return &respData, nil +} diff --git a/pkg/wshutil/wshrpc.go b/pkg/wshutil/wshrpc.go index cccc353e7..7d31246ac 100644 --- a/pkg/wshutil/wshrpc.go +++ b/pkg/wshutil/wshrpc.go @@ -45,10 +45,13 @@ type WshRpc struct { InputCh chan []byte OutputCh chan []byte RpcContext *atomic.Pointer[wshrpc.RpcContext] + AuthToken string RpcMap map[string]*rpcData ServerImpl ServerImpl EventListener *EventListener ResponseHandlerMap map[string]*RpcResponseHandler // reqId => handler + Debug bool + DebugName string } type wshRpcContextKey struct{} @@ -104,17 +107,18 @@ func (w *WshRpc) RecvRpcMessage() ([]byte, bool) { } type RpcMessage struct { - Command string `json:"command,omitempty"` - ReqId string `json:"reqid,omitempty"` - ResId string `json:"resid,omitempty"` - Timeout int `json:"timeout,omitempty"` - Route string `json:"route,omitempty"` // to route/forward requests to alternate servers - Source string `json:"source,omitempty"` // source route id - Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming - Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) - Error string `json:"error,omitempty"` - DataType string `json:"datatype,omitempty"` - Data any `json:"data,omitempty"` + Command string `json:"command,omitempty"` + ReqId string `json:"reqid,omitempty"` + ResId string `json:"resid,omitempty"` + Timeout int `json:"timeout,omitempty"` + Route string `json:"route,omitempty"` // to route/forward requests to alternate servers + AuthToken string `json:"authtoken,omitempty"` // needed for routing unauthenticated requests (WshRpcMultiProxy) + Source string `json:"source,omitempty"` // source route id + Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming + Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) + Error string `json:"error,omitempty"` + DataType string `json:"datatype,omitempty"` + Data any `json:"data,omitempty"` } func (r *RpcMessage) IsRpcRequest() bool { @@ -226,6 +230,14 @@ func (w *WshRpc) SetRpcContext(ctx wshrpc.RpcContext) { w.RpcContext.Store(&ctx) } +func (w *WshRpc) SetAuthToken(token string) { + w.AuthToken = token +} + +func (w *WshRpc) GetAuthToken() string { + return w.AuthToken +} + func (w *WshRpc) registerResponseHandler(reqId string, handler *RpcResponseHandler) { w.Lock.Lock() defer w.Lock.Unlock() @@ -323,6 +335,9 @@ func (w *WshRpc) handleRequest(req *RpcMessage) { func (w *WshRpc) runServer() { defer close(w.OutputCh) for msgBytes := range w.InputCh { + if w.Debug { + log.Printf("[%s] received message: %s\n", w.DebugName, string(msgBytes)) + } var msg RpcMessage err := json.Unmarshal(msgBytes, &msg) if err != nil { @@ -455,8 +470,9 @@ func (handler *RpcRequestHandler) SendCancel() { } }() msg := &RpcMessage{ - Cancel: true, - ReqId: handler.reqId, + Cancel: true, + ReqId: handler.reqId, + AuthToken: handler.w.GetAuthToken(), } barr, _ := json.Marshal(msg) // will never fail handler.w.OutputCh <- barr @@ -550,6 +566,7 @@ func (handler *RpcResponseHandler) SendMessage(msg string) { Data: wshrpc.CommandMessageData{ Message: msg, }, + AuthToken: handler.w.GetAuthToken(), } msgBytes, _ := json.Marshal(rpcMsg) // will never fail handler.w.OutputCh <- msgBytes @@ -573,9 +590,10 @@ func (handler *RpcResponseHandler) SendResponse(data any, done bool) error { defer handler.close() } msg := &RpcMessage{ - ResId: handler.reqId, - Data: data, - Cont: !done, + ResId: handler.reqId, + Data: data, + Cont: !done, + AuthToken: handler.w.GetAuthToken(), } barr, err := json.Marshal(msg) if err != nil { @@ -598,8 +616,9 @@ func (handler *RpcResponseHandler) SendResponseError(err error) { } defer handler.close() msg := &RpcMessage{ - ResId: handler.reqId, - Error: err.Error(), + ResId: handler.reqId, + Error: err.Error(), + AuthToken: handler.w.GetAuthToken(), } barr, _ := json.Marshal(msg) // will never fail handler.w.OutputCh <- barr @@ -660,11 +679,12 @@ func (w *WshRpc) SendComplexRequest(command string, data any, opts *wshrpc.RpcOp handler.reqId = uuid.New().String() } req := &RpcMessage{ - Command: command, - ReqId: handler.reqId, - Data: data, - Timeout: timeoutMs, - Route: opts.Route, + Command: command, + ReqId: handler.reqId, + Data: data, + Timeout: timeoutMs, + Route: opts.Route, + AuthToken: w.GetAuthToken(), } barr, err := json.Marshal(req) if err != nil { diff --git a/pkg/wshutil/wshutil.go b/pkg/wshutil/wshutil.go index 79cdc6080..8be9c908a 100644 --- a/pkg/wshutil/wshutil.go +++ b/pkg/wshutil/wshutil.go @@ -19,6 +19,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/packetparser" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "golang.org/x/term" @@ -204,11 +205,26 @@ func SetupTerminalRpcClient(serverImpl ServerImpl) (*WshRpc, io.Reader) { continue } os.Stdout.Write(barr) + os.Stdout.Write([]byte{'\n'}) } }() return rpcClient, ptyBuf } +func SetupPacketRpcClient(input io.Reader, output io.Writer, serverImpl ServerImpl) (*WshRpc, chan []byte) { + messageCh := make(chan []byte, DefaultInputChSize) + outputCh := make(chan []byte, DefaultOutputChSize) + rawCh := make(chan []byte, DefaultOutputChSize) + rpcClient := MakeWshRpc(messageCh, outputCh, wshrpc.RpcContext{}, serverImpl) + go packetparser.Parse(input, messageCh, rawCh) + go func() { + for msg := range outputCh { + packetparser.WritePacket(output, msg) + } + }() + return rpcClient, rawCh +} + func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl) (*WshRpc, chan error, error) { inputCh := make(chan []byte, DefaultInputChSize) outputCh := make(chan []byte, DefaultOutputChSize) @@ -229,10 +245,22 @@ func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl) (*WshRpc, chan err return rtn, writeErrCh, nil } -func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl) (*WshRpc, error) { - conn, err := net.Dial("unix", sockName) +func tryTcpSocket(sockName string) (net.Conn, error) { + addr, err := net.ResolveTCPAddr("tcp", sockName) if err != nil { - return nil, fmt.Errorf("failed to connect to Unix domain socket: %w", err) + return nil, err + } + return net.DialTCP("tcp", nil, addr) +} + +func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl) (*WshRpc, error) { + conn, tcpErr := tryTcpSocket(sockName) + var unixErr error + if tcpErr != nil { + conn, unixErr = net.Dial("unix", sockName) + } + if tcpErr != nil && unixErr != nil { + return nil, fmt.Errorf("failed to connect to tcp or unix domain socket: tcp err:%w: unix socket err: %w", tcpErr, unixErr) } rtn, errCh, err := SetupConnRpcClient(conn, serverImpl) go func() { @@ -363,6 +391,46 @@ func MakeRouteIdFromCtx(rpcCtx *wshrpc.RpcContext) (string, error) { return MakeProcRouteId(procId), nil } +type WriteFlusher interface { + Write([]byte) (int, error) + Flush() error +} + +// blocking, returns if there is an error, or on EOF of input +func HandleStdIOClient(logName string, input io.Reader, output io.Writer) { + proxy := MakeRpcMultiProxy() + rawCh := make(chan []byte, DefaultInputChSize) + go packetparser.Parse(input, proxy.FromRemoteRawCh, rawCh) + doneCh := make(chan struct{}) + var doneOnce sync.Once + closeDoneCh := func() { + doneOnce.Do(func() { + close(doneCh) + }) + proxy.DisposeRoutes() + } + go func() { + proxy.RunUnauthLoop() + }() + go func() { + defer closeDoneCh() + for msg := range proxy.ToRemoteCh { + err := packetparser.WritePacket(output, msg) + if err != nil { + log.Printf("[%s] error writing to output: %v\n", logName, err) + break + } + } + }() + go func() { + defer closeDoneCh() + for msg := range rawCh { + log.Printf("[%s:stdout] %s", logName, msg) + } + }() + <-doneCh +} + func handleDomainSocketClient(conn net.Conn) { var routeIdContainer atomic.Pointer[string] proxy := MakeRpcProxy() @@ -399,7 +467,7 @@ func handleDomainSocketClient(conn net.Conn) { return } routeIdContainer.Store(&routeId) - DefaultRouter.RegisterRoute(routeId, proxy) + DefaultRouter.RegisterRoute(routeId, proxy, true) } // only for use on client @@ -433,5 +501,6 @@ func ExtractUnverifiedSocketName(tokenStr string) (string, error) { if !ok { return "", fmt.Errorf("sock claim is missing or invalid") } + sockName = wavebase.ExpandHomeDirSafe(sockName) return sockName, nil } diff --git a/pkg/wsl/wsl-unix.go b/pkg/wsl/wsl-unix.go new file mode 100644 index 000000000..055e46669 --- /dev/null +++ b/pkg/wsl/wsl-unix.go @@ -0,0 +1,67 @@ +//go:build !windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" +) + +func RegisteredDistros(ctx context.Context) (distros []Distro, err error) { + return nil, fmt.Errorf("RegisteredDistros not implemented on this system") +} + +func DefaultDistro(ctx context.Context) (d Distro, ok bool, err error) { + return d, false, fmt.Errorf("DefaultDistro not implemented on this system") +} + +type Distro struct{} + +func (d *Distro) Name() string { + return "" +} + +func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { + return nil +} + +// just use the regular cmd since it's +// similar enough to not cause issues +// type WslCmd = exec.Cmd +type WslCmd struct { + exec.Cmd +} + +func (wc *WslCmd) GetProcess() *os.Process { + return nil +} + +func (wc *WslCmd) GetProcessState() *os.ProcessState { + return nil +} + +func (c *WslCmd) SetStdin(stdin io.Reader) { + c.Stdin = stdin +} + +func (c *WslCmd) SetStdout(stdout io.Writer) { + c.Stdout = stdout +} + +func (c *WslCmd) SetStderr(stderr io.Writer) { + c.Stdout = stderr +} + +func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { + return nil, fmt.Errorf("GetDistroCmd not implemented on this system") +} + +func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { + return nil, fmt.Errorf("GetDistro not implemented on this system") +} diff --git a/pkg/wsl/wsl-util.go b/pkg/wsl/wsl-util.go new file mode 100644 index 000000000..5d1f70d35 --- /dev/null +++ b/pkg/wsl/wsl-util.go @@ -0,0 +1,296 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +func DetectShell(ctx context.Context, client *Distro) (string, error) { + wshPath := GetWshPath(ctx, client) + + cmd := client.WslCommand(ctx, wshPath+" shell") + log.Printf("shell detecting using command: %s shell", wshPath) + out, err := cmd.Output() + if err != nil { + log.Printf("unable to determine shell. defaulting to /bin/bash: %s", err) + return "/bin/bash", nil + } + log.Printf("detecting shell: %s", out) + + // quoting breaks this particular case + return strings.TrimSpace(string(out)), nil +} + +func GetWshVersion(ctx context.Context, client *Distro) (string, error) { + wshPath := GetWshPath(ctx, client) + + cmd := client.WslCommand(ctx, wshPath+" version") + out, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func GetWshPath(ctx context.Context, client *Distro) string { + defaultPath := "~/.waveterm/bin/wsh" + + cmd := client.WslCommand(ctx, "which wsh") + out, whichErr := cmd.Output() + if whichErr == nil { + return strings.TrimSpace(string(out)) + } + + cmd = client.WslCommand(ctx, "where.exe wsh") + out, whereErr := cmd.Output() + if whereErr == nil { + return strings.TrimSpace(string(out)) + } + + // check cmd on windows since it requires an absolute path with backslashes + cmd = client.WslCommand(ctx, "(dir 2>&1 *``|echo %userprofile%\\.waveterm%\\.waveterm\\bin\\wsh.exe);&<# rem #>echo none") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "none" { + return strings.TrimSpace(string(out)) + } + + // no custom install, use default path + return defaultPath +} + +func hasBashInstalled(ctx context.Context, client *Distro) (bool, error) { + cmd := client.WslCommand(ctx, "which bash") + out, whichErr := cmd.Output() + if whichErr == nil && len(out) != 0 { + return true, nil + } + + cmd = client.WslCommand(ctx, "where.exe bash") + out, whereErr := cmd.Output() + if whereErr == nil && len(out) != 0 { + return true, nil + } + + // note: we could also check in /bin/bash explicitly + // just in case that wasn't added to the path. but if + // that's true, we will most likely have worse + // problems going forward + + return false, nil +} + +func GetClientOs(ctx context.Context, client *Distro) (string, error) { + cmd := client.WslCommand(ctx, "uname -s") + out, unixErr := cmd.Output() + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return formatted, nil + } + + cmd = client.WslCommand(ctx, "echo %OS%") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "%OS%" { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + + cmd = client.WslCommand(ctx, "echo $env:OS") + out, psErr := cmd.Output() + if psErr == nil && strings.TrimSpace(string(out)) != "$env:OS" { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + return "", fmt.Errorf("unable to determine os: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +func GetClientArch(ctx context.Context, client *Distro) (string, error) { + cmd := client.WslCommand(ctx, "uname -m") + out, unixErr := cmd.Output() + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + if formatted == "x86_64" { + return "x64", nil + } + return formatted, nil + } + + cmd = client.WslCommand(ctx, "echo %PROCESSOR_ARCHITECTURE%") + out, cmdErr := cmd.Output() + if cmdErr == nil && strings.TrimSpace(string(out)) != "%PROCESSOR_ARCHITECTURE%" { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + + cmd = client.WslCommand(ctx, "echo $env:PROCESSOR_ARCHITECTURE") + out, psErr := cmd.Output() + if psErr == nil && strings.TrimSpace(string(out)) != "$env:PROCESSOR_ARCHITECTURE" { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + return "", fmt.Errorf("unable to determine architecture: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +type CancellableCmd struct { + Cmd *WslCmd + Cancel func() +} + +var installTemplatesRawBash = map[string]string{ + "mkdir": `bash -c 'mkdir -p {{.installDir}}'`, + "cat": `bash -c 'cat > {{.tempPath}}'`, + "mv": `bash -c 'mv {{.tempPath}} {{.installPath}}'`, + "chmod": `bash -c 'chmod a+x {{.installPath}}'`, +} + +var installTemplatesRawDefault = map[string]string{ + "mkdir": `mkdir -p {{.installDir}}`, + "cat": `cat > {{.tempPath}}`, + "mv": `mv {{.tempPath}} {{.installPath}}`, + "chmod": `chmod a+x {{.installPath}}`, +} + +func makeCancellableCommand(ctx context.Context, client *Distro, cmdTemplateRaw string, words map[string]string) (*CancellableCmd, error) { + cmdContext, cmdCancel := context.WithCancel(ctx) + + cmdStr := &bytes.Buffer{} + cmdTemplate, err := template.New("").Parse(cmdTemplateRaw) + if err != nil { + cmdCancel() + return nil, err + } + cmdTemplate.Execute(cmdStr, words) + + cmd := client.WslCommand(cmdContext, cmdStr.String()) + return &CancellableCmd{cmd, cmdCancel}, nil +} + +func CpHostToRemote(ctx context.Context, client *Distro, sourcePath string, destPath string) error { + // warning: does not work on windows remote yet + bashInstalled, err := hasBashInstalled(ctx, client) + if err != nil { + return err + } + + var selectedTemplatesRaw map[string]string + if bashInstalled { + selectedTemplatesRaw = installTemplatesRawBash + } else { + log.Printf("bash is not installed on remote. attempting with default shell") + selectedTemplatesRaw = installTemplatesRawDefault + } + + // I need to use toSlash here to force unix keybindings + // this means we can't guarantee it will work on a remote windows machine + var installWords = map[string]string{ + "installDir": filepath.ToSlash(filepath.Dir(destPath)), + "tempPath": destPath + ".temp", + "installPath": destPath, + } + + installStepCmds := make(map[string]*CancellableCmd) + for cmdName, selectedTemplateRaw := range selectedTemplatesRaw { + cancellableCmd, err := makeCancellableCommand(ctx, client, selectedTemplateRaw, installWords) + if err != nil { + return err + } + installStepCmds[cmdName] = cancellableCmd + } + + _, err = installStepCmds["mkdir"].Cmd.Output() + if err != nil { + return err + } + + // the cat part of this is complicated since it requires stdin + catCmd := installStepCmds["cat"].Cmd + catStdin, err := catCmd.StdinPipe() + if err != nil { + return err + } + err = catCmd.Start() + if err != nil { + return err + } + input, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("cannot open local file %s to send to host: %v", sourcePath, err) + } + go func() { + io.Copy(catStdin, input) + installStepCmds["cat"].Cancel() + + // backup just in case something weird happens + // could cause potential race condition, but very + // unlikely + time.Sleep(time.Second * 1) + process := catCmd.GetProcess() + if process != nil { + process.Kill() + } + }() + catErr := catCmd.Wait() + if catErr != nil && !errors.Is(catErr, context.Canceled) { + return catErr + } + + _, err = installStepCmds["mv"].Cmd.Output() + if err != nil { + return err + } + + _, err = installStepCmds["chmod"].Cmd.Output() + if err != nil { + return err + } + + return nil +} + +func InstallClientRcFiles(ctx context.Context, client *Distro) error { + path := GetWshPath(ctx, client) + log.Printf("path to wsh searched is: %s", path) + + cmd := client.WslCommand(ctx, path+" rcfiles") + _, err := cmd.Output() + return err +} + +func GetHomeDir(ctx context.Context, client *Distro) string { + // note: also works for powershell + cmd := client.WslCommand(ctx, `echo "$HOME"`) + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + + cmd = client.WslCommand(ctx, `echo %userprofile%`) + out, err = cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)) + } + + return "~" +} + +func IsPowershell(shellPath string) bool { + // get the base path, and then check contains + shellBase := filepath.Base(shellPath) + return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") +} diff --git a/pkg/wsl/wsl-win.go b/pkg/wsl/wsl-win.go new file mode 100644 index 000000000..782e15719 --- /dev/null +++ b/pkg/wsl/wsl-win.go @@ -0,0 +1,125 @@ +//go:build windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "os" + "sync" + + "github.com/ubuntu/gowsl" +) + +var RegisteredDistros = gowsl.RegisteredDistros +var DefaultDistro = gowsl.DefaultDistro + +type Distro struct { + gowsl.Distro +} + +type WslCmd struct { + c *gowsl.Cmd + wg *sync.WaitGroup + once *sync.Once + lock *sync.Mutex + waitErr error +} + +func (d *Distro) WslCommand(ctx context.Context, cmd string) *WslCmd { + if ctx == nil { + panic("nil Context") + } + innerCmd := d.Command(ctx, cmd) + var wg sync.WaitGroup + var lock *sync.Mutex + return &WslCmd{innerCmd, &wg, new(sync.Once), lock, nil} +} + +func (c *WslCmd) CombinedOutput() (out []byte, err error) { + return c.c.CombinedOutput() +} +func (c *WslCmd) Output() (out []byte, err error) { + return c.c.Output() +} +func (c *WslCmd) Run() error { + return c.c.Run() +} +func (c *WslCmd) Start() (err error) { + return c.c.Start() +} +func (c *WslCmd) StderrPipe() (r io.ReadCloser, err error) { + return c.c.StderrPipe() +} +func (c *WslCmd) StdinPipe() (w io.WriteCloser, err error) { + return c.c.StdinPipe() +} +func (c *WslCmd) StdoutPipe() (r io.ReadCloser, err error) { + return c.c.StdoutPipe() +} +func (c *WslCmd) Wait() (err error) { + c.wg.Add(1) + c.once.Do(func() { + c.waitErr = c.c.Wait() + }) + c.wg.Done() + c.wg.Wait() + if c.waitErr != nil && c.waitErr.Error() == "not started" { + c.once = new(sync.Once) + return c.waitErr + } + return c.waitErr +} +func (c *WslCmd) GetProcess() *os.Process { + return c.c.Process +} + +func (c *WslCmd) GetProcessState() *os.ProcessState { + return c.c.ProcessState +} + +func (c *WslCmd) SetStdin(stdin io.Reader) { + c.c.Stdin = stdin +} + +func (c *WslCmd) SetStdout(stdout io.Writer) { + c.c.Stdout = stdout +} + +func (c *WslCmd) SetStderr(stderr io.Writer) { + c.c.Stdout = stderr +} + +func GetDistroCmd(ctx context.Context, wslDistroName string, cmd string) (*WslCmd, error) { + distros, err := RegisteredDistros(ctx) + if err != nil { + return nil, err + } + for _, distro := range distros { + if distro.Name() != wslDistroName { + continue + } + wrappedDistro := Distro{distro} + return wrappedDistro.WslCommand(ctx, cmd), nil + } + return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) +} + +func GetDistro(ctx context.Context, wslDistroName WslName) (*Distro, error) { + distros, err := RegisteredDistros(ctx) + if err != nil { + return nil, err + } + for _, distro := range distros { + if distro.Name() != wslDistroName.Distro { + continue + } + wrappedDistro := Distro{distro} + return &wrappedDistro, nil + } + return nil, fmt.Errorf("wsl distro %s not found", wslDistroName) +} diff --git a/pkg/wsl/wsl.go b/pkg/wsl/wsl.go new file mode 100644 index 000000000..0f5927ebb --- /dev/null +++ b/pkg/wsl/wsl.go @@ -0,0 +1,494 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wsl + +import ( + "context" + "fmt" + "io" + "log" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + Status_Init = "init" + Status_Connecting = "connecting" + Status_Connected = "connected" + Status_Disconnected = "disconnected" + Status_Error = "error" +) + +const DefaultConnectionTimeout = 60 * time.Second + +var globalLock = &sync.Mutex{} +var clientControllerMap = make(map[string]*WslConn) +var activeConnCounter = &atomic.Int32{} + +type WslConn struct { + Lock *sync.Mutex + Status string + Name WslName + Client *Distro + SockName string + DomainSockListener net.Listener + ConnController *WslCmd + Error string + HasWaiter *atomic.Bool + LastConnectTime int64 + ActiveConnNum int + Context context.Context + cancelFn func() +} + +type WslName struct { + Distro string `json:"distro"` +} + +func GetAllConnStatus() []wshrpc.ConnStatus { + globalLock.Lock() + defer globalLock.Unlock() + + var connStatuses []wshrpc.ConnStatus + for _, conn := range clientControllerMap { + connStatuses = append(connStatuses, conn.DeriveConnStatus()) + } + return connStatuses +} + +func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return wshrpc.ConnStatus{ + Status: conn.Status, + Connected: conn.Status == Status_Connected, + Connection: conn.GetName(), + HasConnected: (conn.LastConnectTime > 0), + ActiveConnNum: conn.ActiveConnNum, + Error: conn.Error, + } +} + +func (conn *WslConn) FireConnChangeEvent() { + status := conn.DeriveConnStatus() + event := wps.WaveEvent{ + Event: wps.Event_ConnChange, + Scopes: []string{ + fmt.Sprintf("connection:%s", conn.GetName()), + }, + Data: status, + } + log.Printf("sending event: %+#v", event) + wps.Broker.Publish(event) +} + +func (conn *WslConn) Close() error { + defer conn.FireConnChangeEvent() + conn.WithLock(func() { + if conn.Status == Status_Connected || conn.Status == Status_Connecting { + // if status is init, disconnected, or error don't change it + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) + // we must wait for the waiter to complete + startTime := time.Now() + for conn.HasWaiter.Load() { + time.Sleep(10 * time.Millisecond) + if time.Since(startTime) > 2*time.Second { + return fmt.Errorf("timeout waiting for waiter to complete") + } + } + return nil +} + +func (conn *WslConn) close_nolock() { + // does not set status (that should happen at another level) + if conn.DomainSockListener != nil { + conn.DomainSockListener.Close() + conn.DomainSockListener = nil + } + if conn.ConnController != nil { + conn.cancelFn() // this suspends the conn controller + conn.ConnController = nil + } + if conn.Client != nil { + // conn.Client.Close() is not relevant here + // we do not want to completely close the wsl in case + // other applications are using it + conn.Client = nil + } +} + +func (conn *WslConn) GetDomainSocketName() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.SockName +} + +func (conn *WslConn) GetStatus() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Status +} + +func (conn *WslConn) GetName() string { + // no lock required because opts is immutable + return "wsl://" + conn.Name.Distro +} + +/** + * This function is does not set a listener for WslConn + * It is still required in order to set SockName +**/ +func (conn *WslConn) OpenDomainSocketListener() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + conn.WithLock(func() { + conn.SockName = "~/.waveterm/wave-remote.sock" + }) + return nil +} + +func (conn *WslConn) StartConnServer() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot start conn server for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + client := conn.GetClient() + wshPath := GetWshPath(conn.Context, client) + rpcCtx := wshrpc.RpcContext{ + ClientType: wshrpc.ClientType_ConnServer, + Conn: conn.GetName(), + } + sockName := conn.GetDomainSocketName() + jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx, sockName) + if err != nil { + return fmt.Errorf("unable to create jwt token for conn controller: %w", err) + } + shellPath, err := DetectShell(conn.Context, client) + if err != nil { + return err + } + var cmdStr string + if IsPowershell(shellPath) { + cmdStr = fmt.Sprintf("$env:%s=\"%s\"; %s connserver --router", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } else { + cmdStr = fmt.Sprintf("%s=\"%s\" %s connserver --router", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } + log.Printf("starting conn controller: %s\n", cmdStr) + cmd := client.WslCommand(conn.Context, cmdStr) + pipeRead, pipeWrite := io.Pipe() + inputPipeRead, inputPipeWrite := io.Pipe() + cmd.SetStdout(pipeWrite) + cmd.SetStderr(pipeWrite) + cmd.SetStdin(inputPipeRead) + err = cmd.Start() + if err != nil { + return fmt.Errorf("unable to start conn controller: %w", err) + } + conn.WithLock(func() { + conn.ConnController = cmd + }) + // service the I/O + go func() { + // wait for termination, clear the controller + defer conn.WithLock(func() { + conn.ConnController = nil + }) + waitErr := cmd.Wait() + log.Printf("conn controller (%q) terminated: %v", conn.GetName(), waitErr) + }() + go func() { + logName := fmt.Sprintf("conncontroller:%s", conn.GetName()) + wshutil.HandleStdIOClient(logName, pipeRead, inputPipeWrite) + }() + regCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err = wshutil.DefaultRouter.WaitForRegister(regCtx, wshutil.MakeConnectionRouteId(rpcCtx.Conn)) + if err != nil { + return fmt.Errorf("timeout waiting for connserver to register") + } + time.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is "ready") + return nil +} + +type WshInstallOpts struct { + Force bool + NoUserPrompt bool +} + +func (conn *WslConn) CheckAndInstallWsh(ctx context.Context, clientDisplayName string, opts *WshInstallOpts) error { + if opts == nil { + opts = &WshInstallOpts{} + } + client := conn.GetClient() + if client == nil { + return fmt.Errorf("client is nil") + } + // check that correct wsh extensions are installed + expectedVersion := fmt.Sprintf("wsh v%s", wavebase.WaveVersion) + clientVersion, err := GetWshVersion(ctx, client) + if err == nil && clientVersion == expectedVersion && !opts.Force { + return nil + } + var queryText string + var title string + if opts.Force { + queryText = fmt.Sprintf("ReInstalling Wave Shell Extensions (%s) on `%s`\n", wavebase.WaveVersion, clientDisplayName) + title = "Install Wave Shell Extensions" + } else if err != nil { + queryText = fmt.Sprintf("Wave requires Wave Shell Extensions to be \n"+ + "installed on `%s` \n"+ + "to ensure a seamless experience. \n\n"+ + "Would you like to install them?", clientDisplayName) + title = "Install Wave Shell Extensions" + } else { + // don't ask for upgrading the version + opts.NoUserPrompt = true + } + if !opts.NoUserPrompt { + request := &userinput.UserInputRequest{ + ResponseType: "confirm", + QueryText: queryText, + Title: title, + Markdown: true, + CheckBoxMsg: "Don't show me this again", + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil || !response.Confirm { + return err + } + if response.CheckboxStat { + meta := waveobj.MetaMapType{ + wconfig.ConfigKey_ConnAskBeforeWshInstall: false, + } + err := wconfig.SetBaseConfigValue(meta) + if err != nil { + return fmt.Errorf("error setting conn:askbeforewshinstall value: %w", err) + } + } + } + log.Printf("attempting to install wsh to `%s`", clientDisplayName) + clientOs, err := GetClientOs(ctx, client) + if err != nil { + return err + } + clientArch, err := GetClientArch(ctx, client) + if err != nil { + return err + } + // attempt to install extension + wshLocalPath := shellutil.GetWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch) + err = CpHostToRemote(ctx, client, wshLocalPath, "~/.waveterm/bin/wsh") + if err != nil { + return err + } + log.Printf("successfully installed wsh on %s\n", conn.GetName()) + return nil +} + +func (conn *WslConn) GetClient() *Distro { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Client +} + +func (conn *WslConn) Reconnect(ctx context.Context) error { + err := conn.Close() + if err != nil { + return err + } + return conn.Connect(ctx) +} + +func (conn *WslConn) WaitForConnect(ctx context.Context) error { + for { + status := conn.DeriveConnStatus() + if status.Status == Status_Connected { + return nil + } + if status.Status == Status_Connecting { + select { + case <-ctx.Done(): + return fmt.Errorf("context timeout") + case <-time.After(100 * time.Millisecond): + continue + } + } + if status.Status == Status_Init || status.Status == Status_Disconnected { + return fmt.Errorf("disconnected") + } + if status.Status == Status_Error { + return fmt.Errorf("error: %v", status.Error) + } + return fmt.Errorf("unknown status: %q", status.Status) + } +} + +// does not return an error since that error is stored inside of WslConn +func (conn *WslConn) Connect(ctx context.Context) error { + var connectAllowed bool + conn.WithLock(func() { + if conn.Status == Status_Connecting || conn.Status == Status_Connected { + connectAllowed = false + } else { + conn.Status = Status_Connecting + conn.Error = "" + connectAllowed = true + } + }) + log.Printf("Connect %s\n", conn.GetName()) + if !connectAllowed { + return fmt.Errorf("cannot connect to %q when status is %q", conn.GetName(), conn.GetStatus()) + } + conn.FireConnChangeEvent() + err := conn.connectInternal(ctx) + conn.WithLock(func() { + if err != nil { + conn.Status = Status_Error + conn.Error = err.Error() + conn.close_nolock() + } else { + conn.Status = Status_Connected + conn.LastConnectTime = time.Now().UnixMilli() + if conn.ActiveConnNum == 0 { + conn.ActiveConnNum = int(activeConnCounter.Add(1)) + } + } + }) + conn.FireConnChangeEvent() + return err +} + +func (conn *WslConn) WithLock(fn func()) { + conn.Lock.Lock() + defer conn.Lock.Unlock() + fn() +} + +func (conn *WslConn) connectInternal(ctx context.Context) error { + client, err := GetDistro(ctx, conn.Name) + if err != nil { + return err + } + conn.WithLock(func() { + conn.Client = client + }) + err = conn.OpenDomainSocketListener() + if err != nil { + return err + } + config := wconfig.ReadFullConfig() + installErr := conn.CheckAndInstallWsh(ctx, conn.GetName(), &WshInstallOpts{NoUserPrompt: !config.Settings.ConnAskBeforeWshInstall}) + if installErr != nil { + return fmt.Errorf("conncontroller %s wsh install error: %v", conn.GetName(), installErr) + } + csErr := conn.StartConnServer() + if csErr != nil { + return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr) + } + conn.HasWaiter.Store(true) + go conn.waitForDisconnect() + return nil +} + +func (conn *WslConn) waitForDisconnect() { + defer conn.FireConnChangeEvent() + defer conn.HasWaiter.Store(false) + err := conn.ConnController.Wait() + conn.WithLock(func() { + // disconnects happen for a variety of reasons (like network, etc. and are typically transient) + // so we just set the status to "disconnected" here (not error) + // don't overwrite any existing error (or error status) + if err != nil && conn.Error == "" { + conn.Error = err.Error() + } + if conn.Status != Status_Error { + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) +} + +func getConnInternal(name string) *WslConn { + globalLock.Lock() + defer globalLock.Unlock() + connName := WslName{Distro: name} + rtn := clientControllerMap[name] + if rtn == nil { + ctx, cancelFn := context.WithCancel(context.Background()) + rtn = &WslConn{Lock: &sync.Mutex{}, Status: Status_Init, Name: connName, HasWaiter: &atomic.Bool{}, Context: ctx, cancelFn: cancelFn} + clientControllerMap[name] = rtn + } + return rtn +} + +func GetWslConn(ctx context.Context, name string, shouldConnect bool) *WslConn { + conn := getConnInternal(name) + if conn.Client == nil && shouldConnect { + conn.Connect(ctx) + } + return conn +} + +// Convenience function for ensuring a connection is established +func EnsureConnection(ctx context.Context, connName string) error { + if connName == "" { + return nil + } + conn := GetWslConn(ctx, connName, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + connStatus := conn.DeriveConnStatus() + switch connStatus.Status { + case Status_Connected: + return nil + case Status_Connecting: + return conn.WaitForConnect(ctx) + case Status_Init, Status_Disconnected: + return conn.Connect(ctx) + case Status_Error: + return fmt.Errorf("connection error: %s", connStatus.Error) + default: + return fmt.Errorf("unknown connection status %q", connStatus.Status) + } +} + +func DisconnectClient(connName string) error { + conn := getConnInternal(connName) + if conn == nil { + return fmt.Errorf("client %q not found", connName) + } + err := conn.Close() + return err +} diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 1c9824350..0872ec45b 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -95,6 +95,27 @@ func UpdateTabName(ctx context.Context, tabId, name string) error { }) } +func CreateSubBlock(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentBlockId) + if parentBlock == nil { + return nil, fmt.Errorf("parent block not found: %q", parentBlockId) + } + blockId := uuid.NewString() + blockData := &waveobj.Block{ + OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(), + BlockDef: blockDef, + RuntimeOpts: nil, + Meta: blockDef.Meta, + } + DBInsert(tx.Context(), blockData) + parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + return blockData, nil + }) +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) @@ -104,6 +125,7 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, blockId := uuid.NewString() blockData := &waveobj.Block{ OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), BlockDef: blockDef, RuntimeOpts: rtOpts, Meta: blockDef.Meta, @@ -124,18 +146,34 @@ func findStringInSlice(slice []string, val string) int { return -1 } -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { +func DeleteBlock(ctx context.Context, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { - tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) - if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + block, err := DBGet[*waveobj.Block](tx.Context(), blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) } - blockIdx := findStringInSlice(tab.BlockIds, blockId) - if blockIdx == -1 { + if block == nil { return nil } - tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...) - DBUpdate(tx.Context(), tab) + if len(block.SubBlockIds) > 0 { + return fmt.Errorf("block has subblocks, must delete subblocks first") + } + parentORef := waveobj.ParseORefNoErr(block.ParentORef) + if parentORef != nil { + if parentORef.OType == waveobj.OType_Tab { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), parentORef.OID) + if tab != nil { + tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId) + DBUpdate(tx.Context(), tab) + } + } else if parentORef.OType == waveobj.OType_Block { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentORef.OID) + if parentBlock != nil { + parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + } + } + } DBDelete(tx.Context(), waveobj.OType_Block, blockId) return nil }) @@ -145,23 +183,18 @@ func DeleteBlock(ctx context.Context, tabId string, blockId string) error { // also deletes LayoutState func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { return WithTx(ctx, func(tx *TxWrap) error { - ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) - if ws == nil { - return fmt.Errorf("workspace not found: %q", workspaceId) - } tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + return nil } if len(tab.BlockIds) != 0 { return fmt.Errorf("tab has blocks, must delete blocks first") } - tabIdx := findStringInSlice(ws.TabIds, tabId) - if tabIdx == -1 { - return nil + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws != nil { + ws.TabIds = utilfn.RemoveElemFromSlice(ws.TabIds, tabId) + DBUpdate(tx.Context(), ws) } - ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...) - DBUpdate(tx.Context(), ws) DBDelete(tx.Context(), waveobj.OType_Tab, tabId) DBDelete(tx.Context(), waveobj.OType_LayoutState, tab.LayoutState) return nil @@ -190,6 +223,10 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { + block, _ := DBGet[*waveobj.Block](tx.Context(), blockId) + if block == nil { + return fmt.Errorf("block not found: %q", blockId) + } currentTab, _ := DBGet[*waveobj.Tab](tx.Context(), currentTabId) if currentTab == nil { return fmt.Errorf("current tab not found: %q", currentTabId) @@ -204,6 +241,8 @@ func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, b } currentTab.BlockIds = utilfn.RemoveElemFromSlice(currentTab.BlockIds, blockId) newTab.BlockIds = append(newTab.BlockIds, blockId) + block.ParentORef = waveobj.MakeORef(waveobj.OType_Tab, newTabId).String() + DBUpdate(tx.Context(), block) DBUpdate(tx.Context(), currentTab) DBUpdate(tx.Context(), newTab) return nil diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index c4cd17ba2..de7aa5c59 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -270,10 +270,39 @@ func DBFindWindowForTabId(ctx context.Context, tabId string) (string, error) { func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { - query := ` - SELECT t.oid - FROM db_tab t, json_each(data->'blockids') je - WHERE je.value = ?;` - return tx.GetString(query, blockId), nil + iterNum := 1 + for { + if iterNum > 5 { + return "", fmt.Errorf("too many iterations looking for tab in block parents") + } + query := ` + SELECT json_extract(b.data, '$.parentoref') AS parentoref + FROM db_block b + WHERE b.oid = ?;` + parentORef := tx.GetString(query, blockId) + oref, err := waveobj.ParseORef(parentORef) + if err != nil { + return "", fmt.Errorf("bad block parent oref: %v", err) + } + if oref.OType == "tab" { + return oref.OID, nil + } + if oref.OType == "block" { + blockId = oref.OID + iterNum++ + continue + } + return "", fmt.Errorf("bad parent oref type: %v", oref.OType) + } + }) +} + +func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { + query := ` + SELECT w.oid + FROM db_workspace w, json_each(data->'tabids') je + WHERE je.value = ?` + return tx.GetString(query, tabId), nil }) } diff --git a/pkg/wstore/wstore_dbsetup.go b/pkg/wstore/wstore_dbsetup.go index 7df15a021..3a4f83585 100644 --- a/pkg/wstore/wstore_dbsetup.go +++ b/pkg/wstore/wstore_dbsetup.go @@ -42,7 +42,7 @@ func InitWStore() error { } func GetDBName() string { - waveHome := wavebase.GetWaveHomeDir() + waveHome := wavebase.GetWaveDataDir() return filepath.Join(waveHome, wavebase.WaveDBDir, WStoreDBName) } diff --git a/yarn.lock b/yarn.lock index 0343ad213..79a3eae44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5329,6 +5329,13 @@ __metadata: languageName: node linkType: hard +"env-paths@npm:^3.0.0": + version: 3.0.0 + resolution: "env-paths@npm:3.0.0" + checksum: 10c0/76dec878cee47f841103bacd7fae03283af16f0702dad65102ef0a556f310b98a377885e0f32943831eb08b5ab37842a323d02529f3dfd5d0a40ca71b01b435f + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -11717,6 +11724,7 @@ __metadata: electron-builder: "npm:^25.1.8" electron-updater: "npm:6.3.9" electron-vite: "npm:^2.3.0" + env-paths: "npm:^3.0.0" eslint: "npm:^9.13.0" eslint-config-prettier: "npm:^9.1.0" fast-average-color: "npm:^9.4.0"