diff --git a/client.go b/client.go index f4825ff..96f65c3 100644 --- a/client.go +++ b/client.go @@ -12,7 +12,7 @@ import ( // Client is a mosh client. It connects to a mosh server over UDP, // handles the SSP transport, and provides send/recv for terminal I/O. type Client struct { - conn *net.UDPConn + conn Conn transport *Transport ocb *OCB @@ -32,8 +32,8 @@ type Client struct { wg sync.WaitGroup } -// Dial connects to a mosh server. The key is the base64-encoded mosh key -// (with or without padding). +// Dial connects to a mosh server over UDP. The key is the base64-encoded +// mosh key (with or without padding). func Dial(host string, port int, key string) (*Client, error) { // Pad key for base64 if needed. for len(key)%4 != 0 { @@ -57,6 +57,12 @@ func Dial(host string, port int, key string) (*Client, error) { return nil, err } + return DialConn(conn, ocb) +} + +// DialConn creates a mosh client over an existing datagram connection. +// Use this with WebTransport or other non-UDP transports. +func DialConn(conn Conn, ocb *OCB) (*Client, error) { c := &Client{ conn: conn, transport: NewTransport(ocb, false), diff --git a/cmd/mosh-wasm/main.go b/cmd/mosh-wasm/main.go new file mode 100644 index 0000000..0bb0bb7 --- /dev/null +++ b/cmd/mosh-wasm/main.go @@ -0,0 +1,143 @@ +//go:build js && wasm + +package main + +import ( + "encoding/base64" + "fmt" + "sync" + "syscall/js" + "time" + + mosh "github.com/unixshells/mosh-go" +) + +func main() { + js.Global().Set("moshConnect", js.FuncOf(moshConnect)) + select {} // keep alive +} + +// moshConnect(url, key) → Promise +// url: WebTransport URL (e.g., "https://relay.example.com/mosh/user/device") +// key: base64-encoded mosh key +func moshConnect(this js.Value, args []js.Value) interface{} { + if len(args) < 2 { + return reject("moshConnect requires (url, key)") + } + + url := args[0].String() + key := args[1].String() + + handler := js.FuncOf(func(this js.Value, pargs []js.Value) interface{} { + resolve := pargs[0] + rejectFn := pargs[1] + + go func() { + session, err := newSession(url, key) + if err != nil { + rejectFn.Invoke(err.Error()) + return + } + resolve.Invoke(session.jsObject()) + }() + + return nil + }) + + return js.Global().Get("Promise").New(handler) +} + +type session struct { + client *mosh.Client + conn *wtConn + mu sync.Mutex + closed bool +} + +func newSession(url, key string) (*session, error) { + for len(key)%4 != 0 { + key += "=" + } + rawKey, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("bad key: %w", err) + } + ocb, err := mosh.NewOCB(rawKey) + if err != nil { + return nil, err + } + + conn, err := dialWebTransport(url) + if err != nil { + return nil, err + } + + client, err := mosh.DialConn(conn, ocb) + if err != nil { + conn.Close() + return nil, err + } + + return &session{client: client, conn: conn}, nil +} + +func (s *session) jsObject() js.Value { + obj := js.Global().Get("Object").New() + + obj.Set("send", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return nil + } + data := args[0].String() + s.client.Send([]byte(data)) + return nil + })) + + obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) < 2 { + return nil + } + cols := args[0].Int() + rows := args[1].Int() + s.client.Resize(uint16(cols), uint16(rows)) + return nil + })) + + // recv(callback) — calls callback(string) whenever output is available + obj.Set("recv", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return nil + } + cb := args[0] + go func() { + for { + out := s.client.Recv(60 * time.Second) + if out == nil { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return + } + continue + } + cb.Invoke(string(out)) + } + }() + return nil + })) + + obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + s.mu.Lock() + s.closed = true + s.mu.Unlock() + s.client.Close() + return nil + })) + + return obj +} + +func reject(msg string) js.Value { + return js.Global().Get("Promise").Call("reject", msg) +} diff --git a/cmd/mosh-wasm/webtransport.go b/cmd/mosh-wasm/webtransport.go new file mode 100644 index 0000000..f36ea1f --- /dev/null +++ b/cmd/mosh-wasm/webtransport.go @@ -0,0 +1,159 @@ +//go:build js && wasm + +package main + +import ( + "errors" + "sync" + "syscall/js" + "time" +) + +// wtConn wraps a browser WebTransport connection as a mosh.Conn. +// Uses WebTransport datagrams for unreliable, unordered delivery — +// matching UDP semantics for the mosh protocol. +type wtConn struct { + transport js.Value // WebTransport instance + reader js.Value // datagrams.readable reader + writer js.Value // datagrams.writable writer + + incoming chan []byte + done chan struct{} + once sync.Once + + mu sync.Mutex + deadline time.Time +} + +func dialWebTransport(url string) (*wtConn, error) { + wt := js.Global().Get("WebTransport").New(url) + + // Wait for .ready promise + readyCh := make(chan error, 1) + wt.Get("ready").Call("then", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + readyCh <- nil + return nil + }), + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + msg := "WebTransport connection failed" + if len(args) > 0 { + msg = args[0].Get("message").String() + } + readyCh <- errors.New(msg) + return nil + }), + ) + + select { + case err := <-readyCh: + if err != nil { + return nil, err + } + case <-time.After(10 * time.Second): + wt.Call("close") + return nil, errors.New("WebTransport connect timeout") + } + + datagrams := wt.Get("datagrams") + reader := datagrams.Get("readable").Call("getReader") + writer := datagrams.Get("writable").Call("getWriter") + + c := &wtConn{ + transport: wt, + reader: reader, + writer: writer, + incoming: make(chan []byte, 256), + done: make(chan struct{}), + } + + // Start reading datagrams in background + go c.readLoop() + + return c, nil +} + +func (c *wtConn) readLoop() { + for { + ch := make(chan js.Value, 1) + c.reader.Call("read").Call("then", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + ch <- args[0] + return nil + }), + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + close(ch) + return nil + }), + ) + + select { + case <-c.done: + return + case result, ok := <-ch: + if !ok { + return + } + if result.Get("done").Bool() { + return + } + value := result.Get("value") + buf := make([]byte, value.Get("byteLength").Int()) + js.CopyBytesToGo(buf, js.Global().Get("Uint8Array").New(value)) + select { + case c.incoming <- buf: + default: + // drop if channel full + } + } + } +} + +func (c *wtConn) Read(b []byte) (int, error) { + c.mu.Lock() + deadline := c.deadline + c.mu.Unlock() + + var timer <-chan time.Time + if !deadline.IsZero() { + d := time.Until(deadline) + if d <= 0 { + return 0, errors.New("i/o timeout") + } + timer = time.After(d) + } + + select { + case <-c.done: + return 0, errors.New("connection closed") + case data := <-c.incoming: + n := copy(b, data) + return n, nil + case <-timer: + return 0, errors.New("i/o timeout") + } +} + +func (c *wtConn) Write(b []byte) (int, error) { + arr := js.Global().Get("Uint8Array").New(len(b)) + js.CopyBytesToJS(arr, b) + c.writer.Call("write", arr) + return len(b), nil +} + +func (c *wtConn) SetReadDeadline(t time.Time) error { + c.mu.Lock() + c.deadline = t + c.mu.Unlock() + return nil +} + +func (c *wtConn) Close() error { + c.once.Do(func() { + close(c.done) + c.reader.Call("cancel") + c.writer.Call("close") + c.transport.Call("close") + }) + return nil +} diff --git a/cmd/remote-test/main.go b/cmd/remote-test/main.go new file mode 100644 index 0000000..c670208 --- /dev/null +++ b/cmd/remote-test/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/base64" + "fmt" + "net" + "os" + "strconv" + "time" + "unicode/utf8" + + mosh "github.com/unixshells/mosh-go" +) + +func main() { + if len(os.Args) != 4 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + host := os.Args[1] + port, _ := strconv.Atoi(os.Args[2]) + key, err := base64.StdEncoding.DecodeString(os.Args[3]) + if err != nil { + // Try raw base64 without padding. + key, err = base64.RawStdEncoding.DecodeString(os.Args[2+1]) + if err != nil { + fmt.Fprintf(os.Stderr, "bad key: %v\n", err) + os.Exit(1) + } + } + + ocb, err := mosh.NewOCB(key) + if err != nil { + fmt.Fprintf(os.Stderr, "ocb: %v\n", err) + os.Exit(1) + } + + transport := mosh.NewTransport(ocb, false) // client mode + + // Resolve host. + addrs, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve: %v\n", err) + os.Exit(1) + } + + conn, err := net.DialUDP("udp", nil, addrs) + if err != nil { + fmt.Fprintf(os.Stderr, "dial: %v\n", err) + os.Exit(1) + } + defer conn.Close() + + flush := func() { + datagrams := transport.Tick() + for _, dg := range datagrams { + conn.Write(dg) + } + } + + // Read incoming in background. + go func() { + buf := make([]byte, 65536) + for { + n, err := conn.Read(buf) + if err != nil { + return + } + diff := transport.Recv(buf[:n]) + if diff == nil || len(diff) == 0 { + continue + } + + instrs, err := mosh.UnmarshalHostMessage(diff) + if err != nil { + fmt.Printf("unmarshal error: %v\n", err) + continue + } + for _, hi := range instrs { + if len(hi.Hoststring) > 0 { + text := string(hi.Hoststring) + if !utf8.ValidString(text) { + text = fmt.Sprintf("(%d raw bytes)", len(hi.Hoststring)) + } + fmt.Printf("=== hoststring (%d bytes) ===\n%s\n=== end ===\n", len(hi.Hoststring), text) + } + if hi.Width > 0 || hi.Height > 0 { + fmt.Printf("=== resize: %dx%d ===\n", hi.Width, hi.Height) + } + } + } + }() + + // Tick timer. + ticker := time.NewTicker(20 * time.Millisecond) + go func() { + for range ticker.C { + flush() + } + }() + + // Initial keepalive. + fmt.Println("Sending initial keepalive...") + transport.ForceNextSend() + flush() + + // Resize. + fmt.Println("Sending resize 80x24...") + resize := mosh.MarshalUserMessage([]mosh.UserInstruction{ + {Width: 80, Height: 24}, + }) + transport.SetPending(resize) + flush() + + // Wait for initial output. + fmt.Println("Waiting 5 seconds for initial output...") + time.Sleep(5 * time.Second) + + // Send ls + Enter. + fmt.Println("\nSending ls + Enter...") + keystroke := mosh.MarshalUserMessage([]mosh.UserInstruction{ + {Keys: []byte("ls\r")}, + }) + transport.SetPending(keystroke) + flush() + + // Wait for response. + time.Sleep(3 * time.Second) + + fmt.Println("\nDone.") + ticker.Stop() +} diff --git a/conn.go b/conn.go new file mode 100644 index 0000000..8612558 --- /dev/null +++ b/conn.go @@ -0,0 +1,36 @@ +package mosh + +import "time" + +// Conn is a datagram-oriented connection used by the mosh transport. +// UDP, WebTransport, and other datagram transports implement this interface. +type Conn interface { + // Read reads a datagram. Returns the number of bytes read. + // Must respect deadlines set by SetReadDeadline. + Read(b []byte) (int, error) + + // Write writes a datagram. + Write(b []byte) (int, error) + + // SetReadDeadline sets the deadline for Read calls. + SetReadDeadline(t time.Time) error + + // Close closes the connection. + Close() error +} + +const ( + // Direction bits in the 64-bit nonce header. + dirToServer = uint64(0) // client → server + dirToClient = uint64(1) << 63 // server → client + seqMask = ^(uint64(1) << 63) // lower 63 bits + + // Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes. + minDatagram = 24 + + // Maximum payload per UDP datagram. + maxPayload = 16384 + + // Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching. + tickInterval = 8 * time.Millisecond +) diff --git a/framebuffer.go b/framebuffer.go index cb27341..e37688b 100644 --- a/framebuffer.go +++ b/framebuffer.go @@ -1,3 +1,5 @@ +//go:build !js + package mosh import ( diff --git a/remote-test b/remote-test new file mode 100755 index 0000000..8249965 Binary files /dev/null and b/remote-test differ diff --git a/server.go b/server.go index 5a09d54..1c9c8b6 100644 --- a/server.go +++ b/server.go @@ -1,3 +1,5 @@ +//go:build !js + package mosh import ( @@ -18,20 +20,6 @@ import ( ) const ( - // Direction bits in the 64-bit nonce header. - dirToServer = uint64(0) // client → server - dirToClient = uint64(1) << 63 // server → client - seqMask = ^(uint64(1) << 63) // lower 63 bits - - // Minimum wire datagram: 8 (nonce) + 16 (tag) = 24 bytes. - minDatagram = 24 - - // Maximum payload per UDP datagram. - maxPayload = 16384 - - // Tick interval matches mosh's SEND_MINDELAY (8ms) for keystroke batching. - tickInterval = 8 * time.Millisecond - // Server gives up waiting for client after this. associationTimeout = 60 * time.Second diff --git a/server_test.go b/server_test.go index 8667ce3..daf7119 100644 --- a/server_test.go +++ b/server_test.go @@ -1,3 +1,5 @@ +//go:build !js + package mosh import (