Files
scrawl/main.go
Fredrik Johansson 2b8c3ae6ee Initial build: scrawl, a shared ASCII canvas over SSH
ssh in and draw on a live, shared doodle wall -- everyone connected
sees everyone else's edits in real time. Same wish+bubbletea security
model as delve-term (no shell, no exec, structurally can't reach a
real shell on the host), extended with the one thing delve-term didn't
need: multiple sessions sharing live state. wish's bm.Middleware helper
hides the *tea.Program it creates, so this builds the program directly
instead, keeping a registry (game.Canvas) that broadcasts a redraw
signal to every other connected session the instant one of them paints.

Banner generated via `figlet -f slant SCRAWL` rather than hand-drawn --
slant felt right for a doodle/scribble tool.

Two real bugs caught by actually running this with real SSH sessions,
not just unit-testing the game logic in isolation:

1. Program.Send() blocks until that program's Run() event loop is
   actively reading from it. Broadcasting synchronously from inside
   Join()/Paint() (including a session broadcasting to its own,
   not-yet-running program on join) deadlocked every session before it
   ever reached Run() -- the very first connection just hung with
   nothing rendered. Fixed by sending asynchronously (go p.Send(...))
   everywhere the canvas notifies sessions of a change.

2. Subtler: lipgloss's default package-level styles detect color
   support from the *server process's* os.Stdout, not any given
   session's actual terminal -- and a server's stdout is typically
   redirected (a log file, systemd journal), so every connected session
   silently lost all color/background styling at once, server-wide.
   Manifested as painting a cell your own cursor already sat on being
   invisible (the cursor glyph before/after looked identical, so
   bubbletea's diffing renderer correctly sent zero bytes for a change
   that produced no visual diff) -- confirmed via server-side debug
   logging that painting itself worked correctly every time, isolating
   the bug to rendering, then confirmed via a Go test that forcing a
   real color profile was the difference between 0 and 1327 runes of
   diff between two frames that should look different. Fixed with a
   lipgloss.Renderer created per-session, bound to that session's
   actual output, forced to TrueColor; cursors now highlight whatever's
   already painted at that cell (background tint) rather than
   replacing the character, so a session's own paint is never masked
   by its own cursor marker sitting on top of it.

Verified end-to-end with two real, simultaneous SSH sessions (scripted
via pexpect): peer count syncs correctly, and one session's paint
genuinely arrives at the other via the live broadcast -- not just
unit-tested in isolation. Also verified the actual Docker image builds
and serves correctly over real SSH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:28:17 +02:00

124 lines
3.3 KiB
Go

package main
import (
"context"
"errors"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/logging"
"github.com/muesli/termenv"
"scrawl/game"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "23235"
}
hostKeyPath := os.Getenv("HOST_KEY_PATH")
if hostKeyPath == "" {
hostKeyPath = ".ssh/scrawl_host_key"
}
canvas := game.NewCanvas()
// Not wish/bubbletea's bm.Middleware helper -- that hides the
// *tea.Program behind its own closure, and the whole point of this
// app is a shared canvas that broadcasts a redraw to every OTHER
// connected session the moment one of them paints. Building the
// program directly keeps that reference so canvas.Join/Leave can
// register it. Same "no shell, no exec" guarantee as delve-term:
// this program is the entire session handler, nothing else is ever
// reachable through it.
scrawlMiddleware := func(next ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
pty, winCh, isPty := s.Pty()
if !isPty {
next(s)
return
}
id := game.RandomID()
label := s.User()
if label == "" {
label = id
}
// A renderer bound to lipgloss's package-level default detects
// color support from the *server process's* os.Stdout -- not
// this session's actual terminal, and that stdout is typically
// redirected (a log file, systemd journal), so every session
// would silently lose all color/background styling at once.
// Confirmed by hitting exactly that: a cursor's background
// highlight change produced zero output bytes over live SSH
// until styles were rebound to a per-session renderer forced
// to color output.
renderer := lipgloss.NewRenderer(s)
renderer.SetColorProfile(termenv.TrueColor)
m := game.NewModel(canvas, id, label, renderer)
p := tea.NewProgram(m,
tea.WithInput(s),
tea.WithOutput(s),
tea.WithAltScreen(),
)
canvas.Join(id, label, p)
defer canvas.Leave(id)
go func() {
for w := range winCh {
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}()
// Async, same reason as canvas.go's broadcastLocked: Program.Send
// blocks until Run()'s event loop is reading from it, which hasn't
// started yet at this line -- an inline Send here deadlocked every
// session before it ever reached Run(), confirmed by testing.
go p.Send(tea.WindowSizeMsg{Width: pty.Window.Width, Height: pty.Window.Height})
if _, err := p.Run(); err != nil {
log.Printf("session error: %v", err)
}
}
}
s, err := wish.NewServer(
wish.WithAddress(net.JoinHostPort("0.0.0.0", port)),
wish.WithHostKeyPath(hostKeyPath),
wish.WithMiddleware(
scrawlMiddleware,
logging.Middleware(),
),
)
if err != nil {
log.Fatalln(err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
log.Printf("scrawl listening on :%s", port)
go func() {
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Fatalln(err)
}
}()
<-done
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
log.Fatalln(err)
}
}