Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0284bd652a | ||
|
|
c82251169f | ||
|
|
f065464e2c | ||
|
|
534e7da40d | ||
|
|
96ce8cef8d | ||
|
|
052945f9f8 | ||
|
|
aff8e0572a | ||
|
|
f70f18fc3f | ||
|
|
8d9bc2eba0 | ||
|
|
4a40090a5f | ||
|
|
b71a9c5824 | ||
|
|
2939a82eca | ||
|
|
c582e52c15 | ||
|
|
ad1792735e | ||
|
|
1694f8c993 | ||
|
|
ecc46b8df2 | ||
|
|
83b9ce0de5 | ||
|
|
37159d31bd | ||
|
|
30eb6a82a5 | ||
|
|
cc1f22485d | ||
|
|
d8d48e4269 | ||
|
|
5f8cade139 | ||
|
|
bbe416444b |
@@ -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),
|
||||
@@ -77,6 +83,71 @@ func Dial(host string, port int, key string) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DialConnManual creates a mosh client without an internal sendLoop.
|
||||
// The caller must call Tick() periodically (e.g., every 8-16ms) to flush
|
||||
// outgoing datagrams. Use this in WASM where Go timers are unreliable.
|
||||
func DialConnManual(conn Conn, ocb *OCB) (*Client, error) {
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
transport: NewTransport(ocb, false),
|
||||
ocb: ocb,
|
||||
outputC: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
sentActionCounts: make(map[uint64]int),
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.recvLoop()
|
||||
|
||||
// Send initial keepalive to associate with the server.
|
||||
c.transport.ForceNextSend()
|
||||
c.tick()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DialConnRaw creates a mosh client with no internal goroutines.
|
||||
// The caller must call Tick() for sending AND RecvRaw() for receiving.
|
||||
// Use this when the caller needs raw transport diffs (e.g., for
|
||||
// framebuffer state tracking in the WASM client).
|
||||
func DialConnRaw(conn Conn, ocb *OCB) (*Client, error) {
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
transport: NewTransport(ocb, false),
|
||||
ocb: ocb,
|
||||
outputC: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
sentActionCounts: make(map[uint64]int),
|
||||
}
|
||||
|
||||
c.transport.ForceNextSend()
|
||||
c.tick()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// RecvRaw reads one datagram from the connection and processes it
|
||||
// through the transport. Returns the raw diff payload, or nil if
|
||||
// no complete message was received (timeout, fragment, replay).
|
||||
// The caller can use Transport().LastRecvOldNum()/LastRecvNewNum()
|
||||
// to get the state numbers for this diff.
|
||||
func (c *Client) RecvRaw(timeout time.Duration) []byte {
|
||||
buf := make([]byte, maxPayload+64)
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n < minDatagram {
|
||||
return nil
|
||||
}
|
||||
|
||||
data := make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
return c.transport.Recv(data)
|
||||
}
|
||||
|
||||
// Send sends keystrokes to the server.
|
||||
func (c *Client) Send(keys []byte) {
|
||||
c.actionsMu.Lock()
|
||||
@@ -141,10 +212,23 @@ func (c *Client) Transport() *Transport {
|
||||
return c.transport
|
||||
}
|
||||
|
||||
// Tick flushes pending actions as mosh datagrams. Call this periodically
|
||||
// from an external timer (e.g., JS setInterval in WASM) when the internal
|
||||
// sendLoop is not used.
|
||||
func (c *Client) Tick() {
|
||||
c.tick()
|
||||
}
|
||||
|
||||
func (c *Client) tick() {
|
||||
// Batch: compute diff from accumulated actions.
|
||||
// Only create a new state when the server has acked the previous one.
|
||||
// This matches the Dart mosh client's behavior: at most one unacked
|
||||
// state in flight. Keystrokes accumulate until the ack arrives, then
|
||||
// the next tick sends them all as a single new state.
|
||||
// Without this check, SetPending resets diffSent, causing Tick() to
|
||||
// increment sentNum for the same cumulative payload — the server
|
||||
// applies duplicate keystrokes.
|
||||
c.actionsMu.Lock()
|
||||
if c.dirty {
|
||||
if c.dirty && c.transport.AckedByRemote() >= c.transport.SentNum() {
|
||||
c.dirty = false
|
||||
c.processAcksLocked()
|
||||
newActions := c.actions[c.ackedActionCount:]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//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, cols, rows) → Promise<MoshSession>
|
||||
func moshConnect(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) < 4 {
|
||||
return reject("moshConnect requires (url, key, cols, rows)")
|
||||
}
|
||||
|
||||
url := args[0].String()
|
||||
key := args[1].String()
|
||||
cols := args[2].Int()
|
||||
rows := args[3].Int()
|
||||
|
||||
handler := js.FuncOf(func(this js.Value, pargs []js.Value) interface{} {
|
||||
resolve := pargs[0]
|
||||
rejectFn := pargs[1]
|
||||
|
||||
go func() {
|
||||
session, err := newSession(url, key, cols, rows)
|
||||
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
|
||||
tracker *stateTracker
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newSession(url, key string, cols, rows int) (*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.DialConnRaw(conn, ocb)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &session{
|
||||
client: client,
|
||||
conn: conn,
|
||||
tracker: newStateTracker(cols, rows),
|
||||
}
|
||||
|
||||
// Background goroutine: read from mosh client, feed to state tracker.
|
||||
go s.recvLoop()
|
||||
|
||||
return s, 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
|
||||
}
|
||||
s.client.Send([]byte(args[0].String()))
|
||||
s.client.Tick()
|
||||
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))
|
||||
s.tracker.resize(cols, rows)
|
||||
s.client.Tick()
|
||||
return nil
|
||||
}))
|
||||
|
||||
// tick() — flush pending keystrokes + keepalive/retransmit.
|
||||
obj.Set("tick", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
s.client.Tick()
|
||||
return nil
|
||||
}))
|
||||
|
||||
// poll() — returns pre-diffed ANSI output from the state tracker.
|
||||
obj.Set("poll", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
out := s.tracker.poll()
|
||||
if out == nil {
|
||||
return js.Null()
|
||||
}
|
||||
return string(out)
|
||||
}))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// recvLoop reads raw transport diffs and feeds them to the state
|
||||
// tracker for framebuffer-based processing.
|
||||
func (s *session) recvLoop() {
|
||||
for {
|
||||
diff := s.client.RecvRaw(1 * time.Second)
|
||||
if diff == nil {
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
t := s.client.Transport()
|
||||
oldNum := t.LastRecvOldNum()
|
||||
newNum := t.LastRecvNewNum()
|
||||
throwawayNum := t.ThrowawayNum()
|
||||
s.tracker.applyDiff(diff, oldNum, newNum, throwawayNum)
|
||||
}
|
||||
}
|
||||
|
||||
func reject(msg string) js.Value {
|
||||
return js.Global().Get("Promise").Call("reject", msg)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
mosh "github.com/unixshells/mosh-go"
|
||||
vt "github.com/unixshells/vt-go"
|
||||
)
|
||||
|
||||
// stateTracker implements Dart-style received state management.
|
||||
// Each incoming diff is applied to a copy of the base state (looked up
|
||||
// by oldNum), producing a new state stored by newNum. The display
|
||||
// always shows the latest received state, diffed against what's
|
||||
// currently on screen. This eliminates garbled output from missed
|
||||
// diffs and overlapping state transitions.
|
||||
type stateTracker struct {
|
||||
mu sync.Mutex
|
||||
|
||||
shadow *vt.Emulator
|
||||
shadowState uint64
|
||||
cols, rows int
|
||||
|
||||
// Received states: stateNum → framebuffer snapshot.
|
||||
states map[uint64]*mosh.Framebuffer
|
||||
latestState uint64
|
||||
displayedFB *mosh.Framebuffer
|
||||
|
||||
// Output buffer: pre-diffed ANSI ready for the terminal.
|
||||
output []byte
|
||||
}
|
||||
|
||||
func newStateTracker(cols, rows int) *stateTracker {
|
||||
return &stateTracker{
|
||||
shadow: vt.NewEmulator(cols, rows),
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
states: make(map[uint64]*mosh.Framebuffer),
|
||||
}
|
||||
}
|
||||
|
||||
// applyDiff processes an incoming server diff using state tracking.
|
||||
// throwawayNum is the server's indication of which states it no longer references.
|
||||
func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint64) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
// Already have this state — skip.
|
||||
if _, ok := st.states[newNum]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot current shadow as oldNum if we don't have it.
|
||||
if oldNum == st.shadowState {
|
||||
if _, ok := st.states[oldNum]; !ok {
|
||||
st.states[oldNum] = mosh.SnapshotEmulator(st.shadow, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Need the base state to apply the diff.
|
||||
base, ok := st.states[oldNum]
|
||||
if !ok {
|
||||
// After a resize we have no states. Start fresh with a
|
||||
// clean emulator so the first post-resize diff works.
|
||||
st.shadow = vt.NewEmulator(st.cols, st.rows)
|
||||
st.shadowState = oldNum
|
||||
base = mosh.SnapshotEmulator(st.shadow, true)
|
||||
st.states[oldNum] = base
|
||||
} else if st.shadowState != oldNum {
|
||||
st.shadow = vt.NewEmulator(st.cols, st.rows)
|
||||
// Write the base framebuffer as ANSI to restore state.
|
||||
if base != nil {
|
||||
st.shadow.Write(base.Diff(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply diff: feed hoststrings to shadow.
|
||||
instrs, err := mosh.UnmarshalHostMessage(diff)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, hi := range instrs {
|
||||
if len(hi.Hoststring) > 0 {
|
||||
st.shadow.Write(hi.Hoststring)
|
||||
}
|
||||
}
|
||||
st.shadowState = newNum
|
||||
|
||||
// Snapshot result.
|
||||
snap := mosh.SnapshotEmulator(st.shadow, true)
|
||||
st.states[newNum] = snap
|
||||
if newNum > st.latestState {
|
||||
st.latestState = newNum
|
||||
}
|
||||
|
||||
// Prune states the server no longer references.
|
||||
if throwawayNum > 0 {
|
||||
for n := range st.states {
|
||||
if n < throwawayNum {
|
||||
delete(st.states, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display diff: compute ANSI difference between displayed and latest.
|
||||
latest := st.states[st.latestState]
|
||||
if latest != nil {
|
||||
ansi := latest.Diff(st.displayedFB)
|
||||
if len(ansi) > 0 {
|
||||
st.output = append(st.output, ansi...)
|
||||
}
|
||||
st.displayedFB = latest
|
||||
}
|
||||
}
|
||||
|
||||
// poll returns accumulated ANSI output, or nil.
|
||||
func (st *stateTracker) poll() []byte {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
if len(st.output) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := st.output
|
||||
st.output = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// resize resets the state tracker for new dimensions.
|
||||
func (st *stateTracker) resize(cols, rows int) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
st.cols = cols
|
||||
st.rows = rows
|
||||
st.shadow = vt.NewEmulator(cols, rows)
|
||||
st.shadowState = 0
|
||||
st.states = make(map[uint64]*mosh.Framebuffer)
|
||||
st.displayedFB = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wtConn wraps a browser WebTransport connection as a mosh.Conn.
|
||||
type wtConn struct {
|
||||
transport js.Value
|
||||
writer js.Value // persistent writable stream 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)
|
||||
|
||||
readyCh := make(chan error, 1)
|
||||
onReady := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
readyCh <- nil
|
||||
return nil
|
||||
})
|
||||
onFail := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
msg := "WebTransport connection failed"
|
||||
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
|
||||
if m := args[0].Get("message"); !m.IsUndefined() {
|
||||
msg = m.String()
|
||||
}
|
||||
}
|
||||
readyCh <- errors.New(msg)
|
||||
return nil
|
||||
})
|
||||
wt.Get("ready").Call("then", onReady, onFail)
|
||||
|
||||
select {
|
||||
case err := <-readyCh:
|
||||
if err != nil {
|
||||
onReady.Release()
|
||||
onFail.Release()
|
||||
return nil, err
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
onReady.Release()
|
||||
onFail.Release()
|
||||
wt.Call("close")
|
||||
return nil, errors.New("WebTransport connect timeout")
|
||||
}
|
||||
onReady.Release()
|
||||
onFail.Release()
|
||||
|
||||
writer := wt.Get("datagrams").Get("writable").Call("getWriter")
|
||||
|
||||
c := &wtConn{
|
||||
transport: wt,
|
||||
writer: writer,
|
||||
incoming: make(chan []byte, 256),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start reading datagrams via a persistent JS callback.
|
||||
c.startReader()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// startReader sets up a self-chaining read loop using persistent callbacks.
|
||||
func (c *wtConn) startReader() {
|
||||
reader := c.transport.Get("datagrams").Get("readable").Call("getReader")
|
||||
|
||||
var onData, onErr js.Func
|
||||
var readNext js.Func
|
||||
|
||||
readNext = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
reader.Call("read").Call("then", onData, onErr)
|
||||
return nil
|
||||
})
|
||||
|
||||
onData = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
result := args[0]
|
||||
if result.Get("done").Bool() {
|
||||
return nil
|
||||
}
|
||||
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:
|
||||
}
|
||||
// Defer next read to next event loop tick so Go goroutines can process.
|
||||
js.Global().Call("setTimeout", readNext, 0)
|
||||
return nil
|
||||
})
|
||||
|
||||
onErr = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
// Stream error or closed — stop reading.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start first read.
|
||||
readNext.Invoke()
|
||||
}
|
||||
|
||||
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.transport.Call("close")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -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 <host> <port> <base64key>\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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
+22
-11
@@ -5,9 +5,20 @@ import (
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/vt"
|
||||
"github.com/unixshells/vt-go"
|
||||
)
|
||||
|
||||
// Cell attribute bitmasks (matching charmbracelet/ultraviolet values).
|
||||
const (
|
||||
attrBold = 1 << 0
|
||||
attrFaint = 1 << 1
|
||||
attrItalic = 1 << 2
|
||||
attrBlink = 1 << 3
|
||||
attrRapidBlink = 1 << 4
|
||||
attrReverse = 1 << 5
|
||||
attrConceal = 1 << 6
|
||||
attrStrikethrough = 1 << 7
|
||||
)
|
||||
|
||||
// Framebuffer represents a terminal screen state for mosh SSP.
|
||||
@@ -163,11 +174,11 @@ func (fb *Framebuffer) fullRedraw() []byte {
|
||||
buf = append(buf, "\r\n"...)
|
||||
}
|
||||
rowOff := y * fb.W
|
||||
// Track trailing spaces to avoid sending them.
|
||||
// Track trailing default-attr spaces to avoid sending them.
|
||||
lastNonSpace := -1
|
||||
for x := fb.W - 1; x >= 0; x-- {
|
||||
c := &fb.Cells[rowOff+x]
|
||||
if c.Rune != ' ' || c.Rune == 0 || c.Attr != (Attr{}) {
|
||||
if (c.Rune != ' ' && c.Rune != 0) || c.Attr != (Attr{}) {
|
||||
lastNonSpace = x
|
||||
break
|
||||
}
|
||||
@@ -401,13 +412,13 @@ func SnapshotEmulator(emu *vt.Emulator, cursorVisible bool) *Framebuffer {
|
||||
}
|
||||
|
||||
// Attributes.
|
||||
cell.Attr.Bold = c.Style.Attrs&uv.AttrBold != 0
|
||||
cell.Attr.Dim = c.Style.Attrs&uv.AttrFaint != 0
|
||||
cell.Attr.Italic = c.Style.Attrs&uv.AttrItalic != 0
|
||||
cell.Attr.Blink = c.Style.Attrs&uv.AttrBlink != 0
|
||||
cell.Attr.Reverse = c.Style.Attrs&uv.AttrReverse != 0
|
||||
cell.Attr.Strike = c.Style.Attrs&uv.AttrStrikethrough != 0
|
||||
cell.Attr.Under = c.Style.Underline != uv.UnderlineNone
|
||||
cell.Attr.Bold = c.Style.Attrs&attrBold != 0
|
||||
cell.Attr.Dim = c.Style.Attrs&attrFaint != 0
|
||||
cell.Attr.Italic = c.Style.Attrs&attrItalic != 0
|
||||
cell.Attr.Blink = c.Style.Attrs&attrBlink != 0
|
||||
cell.Attr.Reverse = c.Style.Attrs&attrReverse != 0
|
||||
cell.Attr.Strike = c.Style.Attrs&attrStrikethrough != 0
|
||||
cell.Attr.Under = c.Style.Underline != ansi.UnderlineNone
|
||||
|
||||
cell.Attr.FG = convertColor(c.Style.Fg)
|
||||
cell.Attr.BG = convertColor(c.Style.Bg)
|
||||
|
||||
@@ -3,16 +3,16 @@ module github.com/unixshells/mosh-go
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/unixshells/vt-go v0.1.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/term v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
|
||||
@@ -10,8 +10,6 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185 h1:KYS9lWBROkdkuyc0bdQuS8C86Me3OAgjUAKcB4zArlg=
|
||||
github.com/charmbracelet/x/vt v0.0.0-20260305213658-fe36e8c10185/go.mod h1:2djWW5EeP5QlpztEYYbbyyzUyyxVD71mWVQguNISHcM=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
@@ -30,6 +28,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/unixshells/vt-go v0.1.0 h1:HWILcExV8MHc6o2YqIRMIM/KrjH31kPzZrlvLS9BG6Y=
|
||||
github.com/unixshells/vt-go v0.1.0/go.mod h1:dZpOXxVyO7LG1uxkVMixX7HwUf2ZbUkEKhtHHJTTIBI=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -1,3 +1,5 @@
|
||||
//go:build !js
|
||||
|
||||
package mosh
|
||||
|
||||
import (
|
||||
@@ -13,25 +15,11 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/vt"
|
||||
"github.com/unixshells/vt-go"
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -270,6 +258,9 @@ func (s *Server) readIO(r io.Reader, out chan<- []byte) {
|
||||
}
|
||||
|
||||
func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutput <-chan []byte, userInput <-chan UserInstruction) {
|
||||
// Wait for the first client datagram. If no client connects within
|
||||
// 60 seconds, this was a failed setup — shut down to release resources.
|
||||
deadline := time.After(associationTimeout)
|
||||
for {
|
||||
s.mu.Lock()
|
||||
addr := s.clientAddr
|
||||
@@ -280,6 +271,9 @@ func (s *Server) mainLoopRW(rw io.Writer, resize func(cols, rows uint16), ioOutp
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-deadline:
|
||||
close(s.done)
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -369,7 +363,9 @@ func (s *Server) Close() {
|
||||
// snapshots the framebuffer, diffs against the last-acked state, and
|
||||
// sends CUP-based updates via the transport.
|
||||
func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruction) {
|
||||
// Wait for client to connect before processing.
|
||||
// Wait for the first client datagram. If no client connects within
|
||||
// 60 seconds, this was a failed setup — shut down to release resources.
|
||||
deadline := time.After(associationTimeout)
|
||||
for {
|
||||
s.mu.Lock()
|
||||
addr := s.clientAddr
|
||||
@@ -380,6 +376,9 @@ func (s *Server) mainLoop(ptyOutput <-chan []byte, userInput <-chan UserInstruct
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-deadline:
|
||||
close(s.done)
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !js
|
||||
|
||||
package mosh
|
||||
|
||||
import (
|
||||
|
||||
+32
-4
@@ -34,10 +34,12 @@ type Transport struct {
|
||||
pendingDataAck bool // true = send ack ASAP (received data, not just ack)
|
||||
|
||||
// Incoming state — list of received state nums for old_num validation.
|
||||
receivedNums []uint64 // ordered list of state nums we have
|
||||
ackNum uint64 // latest received state num
|
||||
sentAckNum uint64 // last ackNum we actually sent on wire
|
||||
throwawayNum uint64 // oldest state we still hold
|
||||
receivedNums []uint64 // ordered list of state nums we have
|
||||
ackNum uint64 // latest received state num
|
||||
sentAckNum uint64 // last ackNum we actually sent on wire
|
||||
throwawayNum uint64 // oldest state we still hold
|
||||
lastRecvOldNum uint64 // oldNum from most recently received diff
|
||||
lastRecvNewNum uint64 // newNum from most recently received diff
|
||||
|
||||
// Sequence counter for the crypto layer (independent of SSP state numbering).
|
||||
seqOut uint64
|
||||
@@ -326,6 +328,10 @@ func (t *Transport) Recv(wire []byte) []byte {
|
||||
t.receivedNums = filtered
|
||||
}
|
||||
|
||||
// Track oldNum/newNum for state management.
|
||||
t.lastRecvOldNum = ti.OldNum
|
||||
t.lastRecvNewNum = ti.NewNum
|
||||
|
||||
// Add new state.
|
||||
t.receivedNums = append(t.receivedNums, ti.NewNum)
|
||||
|
||||
@@ -359,6 +365,28 @@ func (t *Transport) SentNum() uint64 {
|
||||
return t.sentNum
|
||||
}
|
||||
|
||||
// LastRecvOldNum returns the oldNum from the most recently received diff.
|
||||
func (t *Transport) LastRecvOldNum() uint64 {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.lastRecvOldNum
|
||||
}
|
||||
|
||||
// LastRecvNewNum returns the newNum from the most recently received diff.
|
||||
func (t *Transport) LastRecvNewNum() uint64 {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.lastRecvNewNum
|
||||
}
|
||||
|
||||
// ThrowawayNum returns the server's throwaway number — states below this
|
||||
// are no longer referenced by the server and can be safely pruned.
|
||||
func (t *Transport) ThrowawayNum() uint64 {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.throwawayNum
|
||||
}
|
||||
|
||||
// LastRecv returns the time of the last received datagram.
|
||||
func (t *Transport) LastRecv() time.Time {
|
||||
t.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user