From 2b8c3ae6eea0d970266900f69b31e40e74cd6722 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Wed, 15 Jul 2026 19:28:17 +0200 Subject: [PATCH] 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 --- .env.example | 3 + .gitea/workflows/docker.yml | 53 ++++++++++++ .gitignore | 3 + Dockerfile | 15 ++++ README.md | 71 +++++++++++++++ docker-compose.yml | 17 ++++ game/canvas.go | 168 ++++++++++++++++++++++++++++++++++++ game/model.go | 95 ++++++++++++++++++++ game/model_test.go | 120 ++++++++++++++++++++++++++ game/view.go | 119 +++++++++++++++++++++++++ go.mod | 40 +++++++++ go.sum | 75 ++++++++++++++++ main.go | 123 ++++++++++++++++++++++++++ run-local.sh | 11 +++ 14 files changed, 913 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 game/canvas.go create mode 100644 game/model.go create mode 100644 game/model_test.go create mode 100644 game/view.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100755 run-local.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62a1aae --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env to override docker-compose.yml defaults. +IMAGE=repo.explewd.com/explewd/scrawl:latest +HOST_PORT=23235 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..093185e --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,53 @@ +name: Docker + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive image name and tags + id: meta + shell: bash + run: | + set -euo pipefail + SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}" + REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}" + SHA="${GITHUB_SHA:-${GITEA_SHA:-}}" + if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then + echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1 + fi + HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||') + IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]') + SHORT_SHA=$(echo "${SHA}" | cut -c1-7) + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.host }} + username: ${{ github.actor }} + # Create a Gitea PAT with packages:write scope and add it as a + # repository secret named TKNTKN (Settings → Secrets) + password: ${{ secrets.TKNTKN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..decc5c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.ssh/ +scrawl +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e46c4e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /scrawl . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=build /scrawl /usr/local/bin/scrawl +# Host key persists in a volume — see docker-compose.yml — so it doesn't +# regenerate (and change the server's fingerprint) on every redeploy. +ENV HOST_KEY_PATH=/data/host_key +EXPOSE 23235 +CMD ["scrawl"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa0b37 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# scrawl + +A shared ASCII doodle wall you connect to over SSH. Everyone connected +right now is drawing on the same canvas, live. + +``` +ssh scrawl.goonk.se -p 23235 +``` + +Not aiming to be useful to anyone but me — built purely because a +multiplayer canvas over raw SSH is a genuinely fun, weird shape for a +toy, and the `wish`+`bubbletea` pattern from +[delve-term](https://repo.explewd.com/explewd/delve-term) was still +fresh enough to reuse without re-learning it. + +## Controls + +- Arrow keys or `hjkl` — move your cursor +- `space` / `enter` — paint +- `backspace` / `delete` — erase +- `[` / `]` — cycle the ink character +- `1`-`8` — pick a color +- `c` — clear the whole canvas (yes, for everyone — it's a shared wall) +- `q` / `ctrl+c` — quit + +## Architecture + +Same security model as `delve-term`: the SSH server +([`charmbracelet/wish`](https://github.com/charmbracelet/wish)) hands +each connection straight to a [bubbletea](https://github.com/charmbracelet/bubbletea) +program — no shell, no exec, structurally can't reach a real shell on +the host. + +The one thing this app needed beyond `delve-term`'s pattern: multiple +sessions sharing live state. `wish`'s `bm.Middleware` helper hides the +`*tea.Program` it creates behind its own closure, so this app builds the +program directly instead, keeping a reference in a small registry +(`game.Canvas`) that broadcasts a redraw signal to every *other* +connected session the instant one of them paints. + +A real bug worth knowing about if you're extending this: `Program.Send()` +blocks until that program's `Run()` event loop is actively reading from +it. Broadcasting synchronously from inside `Join()`/`Paint()` deadlocked +every session before it ever reached `Run()` — fixed by sending +asynchronously (`go p.Send(...)`) everywhere the canvas notifies +sessions of a change. + +A second, subtler bug: `lipgloss`'s default 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, a +systemd journal), so every session silently lost all color/background +styling at once. Fixed with a `lipgloss.Renderer` created per-session, +bound to that session's actual output, forced to `TrueColor`. + +## Local development + +```bash +./run-local.sh # starts on :23235 +ssh -p 23235 localhost -o StrictHostKeyChecking=no # in another terminal +``` + +## Status + +Verified with two simultaneous SSH sessions (scripted, not just live +poking) — one paints, the other sees it via the broadcast, peer count +stays in sync. No persistence: canvas state lives in memory only and +resets on restart, which is fine for what this is. + +## License + +MIT. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c9a0bdb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + app: + image: ${IMAGE} + container_name: scrawl + restart: unless-stopped + pull_policy: always + ports: + - "${HOST_PORT:-23235}:23235" + volumes: + # Named volume — just the SSH host key, so the server's fingerprint + # stays stable across redeploys instead of changing (and tripping + # every client's "REMOTE HOST IDENTIFICATION HAS CHANGED" warning) + # every time a new image ships. + - scrawl-data:/data + +volumes: + scrawl-data: diff --git a/game/canvas.go b/game/canvas.go new file mode 100644 index 0000000..7db8d41 --- /dev/null +++ b/game/canvas.go @@ -0,0 +1,168 @@ +package game + +import ( + "math/rand" + "sync" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + Width = 76 + Height = 20 +) + +type Cell struct { + Char rune + Color string // lipgloss-compatible ANSI color string, "" = unset +} + +// Cursor is another connected session's position, shown as a marker on +// top of the canvas so drawing feels like a shared space, not a diff you +// only notice after the fact. +type Cursor struct { + X, Y int + Color string + Label string +} + +// canvasUpdatedMsg is broadcast to every connected session's bubbletea +// program whenever the shared state changes — cells or cursors — so each +// client's own Update/View loop redraws without polling. +type canvasUpdatedMsg struct{} + +var cursorPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#00d2d3"} + +// Canvas is the single shared drawing surface plus the registry of +// connected sessions' bubbletea programs, used purely to fan a redraw +// signal out to everyone whenever anything changes -- there's no other +// use of the registry (no per-session logic reaches back in through it). +type Canvas struct { + mu sync.Mutex + cells [Height][Width]Cell + cursors map[string]*Cursor + programs map[string]*tea.Program + nextColorIdx int +} + +func NewCanvas() *Canvas { + return &Canvas{ + cursors: make(map[string]*Cursor), + programs: make(map[string]*tea.Program), + } +} + +func (c *Canvas) Join(id string, label string, p *tea.Program) *Cursor { + c.mu.Lock() + defer c.mu.Unlock() + color := cursorPalette[c.nextColorIdx%len(cursorPalette)] + c.nextColorIdx++ + cur := &Cursor{X: Width / 2, Y: Height / 2, Color: color, Label: label} + c.cursors[id] = cur + c.programs[id] = p + c.broadcastLocked() + return cur +} + +func (c *Canvas) Leave(id string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.cursors, id) + delete(c.programs, id) + c.broadcastLocked() +} + +func (c *Canvas) Paint(x, y int, char rune, color string) { + c.mu.Lock() + defer c.mu.Unlock() + if x < 0 || x >= Width || y < 0 || y >= Height { + return + } + c.cells[y][x] = Cell{Char: char, Color: color} + c.broadcastLocked() +} + +func (c *Canvas) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.cells = [Height][Width]Cell{} + c.broadcastLocked() +} + +func (c *Canvas) MoveCursor(id string, dx, dy int) { + c.mu.Lock() + defer c.mu.Unlock() + cur, ok := c.cursors[id] + if !ok { + return + } + cur.X = clamp(cur.X+dx, 0, Width-1) + cur.Y = clamp(cur.Y+dy, 0, Height-1) + c.broadcastLocked() +} + +// Snapshot returns a copy of the current cells and cursors, safe to read +// from a View() call without holding the lock while rendering. +func (c *Canvas) Snapshot() (cells [Height][Width]Cell, cursors map[string]Cursor) { + c.mu.Lock() + defer c.mu.Unlock() + cursors = make(map[string]Cursor, len(c.cursors)) + for id, cur := range c.cursors { + cursors[id] = *cur + } + return c.cells, cursors +} + +// CursorsSnapshot is Snapshot() without paying for a cell-array copy when +// only cursor positions are needed (the common case for input handling). +func (c *Canvas) CursorsSnapshot() map[string]Cursor { + c.mu.Lock() + defer c.mu.Unlock() + cursors := make(map[string]Cursor, len(c.cursors)) + for id, cur := range c.cursors { + cursors[id] = *cur + } + return cursors +} + +func (c *Canvas) PeerCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.cursors) +} + +// broadcastLocked must be called with c.mu already held. Each Send runs in +// its own goroutine rather than inline: a just-joined program's Run() +// loop hasn't started reading its input channel yet at the moment Join() +// calls this (Join registers the program, then broadcasts, before the +// caller gets around to calling p.Run()) -- an inline, blocking Send to +// that program would deadlock the whole session handler before it ever +// reaches Run(). Confirmed by hitting exactly that deadlock in testing: +// the very first session hung with nothing rendered. +func (c *Canvas) broadcastLocked() { + for _, p := range c.programs { + if p == nil { + continue + } + go p.Send(canvasUpdatedMsg{}) + } +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func RandomID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} diff --git a/game/model.go b/game/model.go new file mode 100644 index 0000000..d7e0b54 --- /dev/null +++ b/game/model.go @@ -0,0 +1,95 @@ +package game + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var inkPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#ffffff"} +var charPalette = []rune{'█', '▓', '▒', '░', '#', '*', '.', '@', 'o', '+'} + +type state int + +const ( + stateIntro state = iota + stateCanvas +) + +type Model struct { + canvas *Canvas + id string + label string + state state + renderer *lipgloss.Renderer + + inkColorIdx int + charIdx int + + width, height int +} + +func NewModel(canvas *Canvas, id, label string, renderer *lipgloss.Renderer) Model { + return Model{canvas: canvas, id: id, label: label, state: stateIntro, renderer: renderer} +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case canvasUpdatedMsg: + // Someone (possibly this session) changed the shared state -- + // nothing to do here but re-render; View() always reads a fresh + // snapshot from the canvas. + return m, nil + + case tea.KeyMsg: + if m.state == stateIntro { + m.state = stateCanvas + return m, nil + } + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "up", "k": + m.canvas.MoveCursor(m.id, 0, -1) + case "down", "j": + m.canvas.MoveCursor(m.id, 0, 1) + case "left", "h": + m.canvas.MoveCursor(m.id, -1, 0) + case "right", "l": + m.canvas.MoveCursor(m.id, 1, 0) + + case " ", "enter": + if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok { + m.canvas.Paint(cur.X, cur.Y, charPalette[m.charIdx], inkPalette[m.inkColorIdx]) + } + + case "backspace", "delete": + if cur, ok := m.canvas.CursorsSnapshot()[m.id]; ok { + m.canvas.Paint(cur.X, cur.Y, ' ', "") + } + + case "[": + m.charIdx = (m.charIdx - 1 + len(charPalette)) % len(charPalette) + case "]": + m.charIdx = (m.charIdx + 1) % len(charPalette) + + case "1", "2", "3", "4", "5", "6", "7", "8": + m.inkColorIdx = int(msg.String()[0] - '1') + + case "c": + m.canvas.Clear() + } + return m, nil +} diff --git a/game/model_test.go b/game/model_test.go new file mode 100644 index 0000000..3a0de96 --- /dev/null +++ b/game/model_test.go @@ -0,0 +1,120 @@ +package game + +import ( + "io" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func TestColorAndCharSwitching(t *testing.T) { + c := NewCanvas() + m := NewModel(c, "test-id", "tester", newTestRenderer()) + c.Join("test-id", "tester", nil) + + // First keypress just exits the intro screen, per Update()'s own logic. + mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + m = mi.(Model) + if m.state != stateCanvas { + t.Fatalf("expected first keypress to exit intro, state=%v", m.state) + } + if m.inkColorIdx != 0 { + t.Fatalf("expected inkColorIdx unchanged by the intro-exit keypress, got %d", m.inkColorIdx) + } + + // Now actually press "3" for color selection. + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + m = mi.(Model) + if m.inkColorIdx != 2 { + t.Fatalf("expected inkColorIdx=2 after pressing '3', got %d", m.inkColorIdx) + } + + // Paint with the selected color. + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + m = mi.(Model) + + cells, cursors := c.Snapshot() + cur := cursors["test-id"] + painted := cells[cur.Y][cur.X] + if painted.Char != charPalette[0] { + t.Fatalf("expected painted char to be default charPalette[0]=%q, got %q", charPalette[0], painted.Char) + } + if painted.Color != inkPalette[2] { + t.Fatalf("expected painted color to be inkPalette[2]=%q (selected via '3'), got %q", inkPalette[2], painted.Color) + } +} + +func TestCharCycling(t *testing.T) { + c := NewCanvas() + m := NewModel(c, "test-id", "tester", newTestRenderer()) + c.Join("test-id", "tester", nil) + + mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro + m = mi.(Model) + + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")}) + m = mi.(Model) + if m.charIdx != 1 { + t.Fatalf("expected charIdx=1 after ']', got %d", m.charIdx) + } +} + +func TestViewChangesAfterMoveAwayFromPaint(t *testing.T) { + c := NewCanvas() + m := NewModel(c, "test-id", "tester", newTestRenderer()) + c.Join("test-id", "tester", nil) + + mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro + m = mi.(Model) + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // paint + m = mi.(Model) + + viewAtPaintPosition := m.View() + + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right + m = mi.(Model) + + viewAfterMove := m.View() + + if viewAtPaintPosition == viewAfterMove { + t.Fatal("expected View() output to differ after moving away from a painted cell, but it's identical") + } + t.Logf("view at paint position:\n%s", viewAtPaintPosition) + t.Logf("view after move:\n%s", viewAfterMove) +} + +func TestMoveCursorActuallyMoves(t *testing.T) { + c := NewCanvas() + m := NewModel(c, "test-id", "tester", newTestRenderer()) + c.Join("test-id", "tester", nil) + + before := c.CursorsSnapshot()["test-id"] + t.Logf("before: x=%d y=%d", before.X, before.Y) + + mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro + m = mi.(Model) + mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right + m = mi.(Model) + + after := c.CursorsSnapshot()["test-id"] + t.Logf("after: x=%d y=%d", after.X, after.Y) + + if after.X != before.X+1 { + t.Fatalf("expected cursor X to move from %d to %d, got %d", before.X, before.X+1, after.X) + } +} + +// newTestRenderer forces TrueColor rather than letting lipgloss +// auto-detect from the writer (io.Discard, like any non-*os.File writer, +// would otherwise be treated as non-color-capable) -- matches main.go's +// real per-session renderer setup, so these tests actually exercise the +// same styling path a real SSH session does instead of silently testing +// an unstyled/plain-text code path that would mask exactly this class +// of bug. +func newTestRenderer() *lipgloss.Renderer { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + return r +} diff --git a/game/view.go b/game/view.go new file mode 100644 index 0000000..dafe7d2 --- /dev/null +++ b/game/view.go @@ -0,0 +1,119 @@ +package game + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Generated via `figlet -f slant SCRAWL` — the slant felt right for a +// doodle/scribble tool, not hand-tuned character by character. +const banner = ` _____ __________ ___ _ ____ + / ___// ____/ __ \/ | | / / / + \__ \/ / / /_/ / /| | | /| / / / + ___/ / /___/ _, _/ ___ | |/ |/ / /___ +/____/\____/_/ |_/_/ |_|__/|__/_____/` + +func (m Model) View() string { + if m.state == stateIntro { + return m.renderIntro() + } + return m.renderCanvas() +} + +// style is a small accessor so every render call goes through m.renderer +// (bound to this specific SSH session's actual output) rather than +// lipgloss's package-level default styles, which detect color support +// from the *server process's* os.Stdout -- not any given session's +// terminal. That's a real bug this app hit: the server's stdout is +// typically redirected (a log file, a systemd journal, anything that +// isn't a TTY), so the default global renderer silently disabled all +// color/background styling for every connected session at once, +// regardless of what terminal they were actually using. +func (m Model) style() lipgloss.Style { + return m.renderer.NewStyle() +} + +func (m Model) renderIntro() string { + title := m.style().Bold(true).Foreground(lipgloss.Color("#ff6b6b")).Render(banner) + dim := m.style().Foreground(lipgloss.Color("#777777")) + body := title + "\n" + + dim.Render("a shared doodle wall, one keystroke at a time") + "\n\n" + + "Everyone connected right now is drawing on the same canvas.\n" + + "Move with arrow keys or hjkl, space/enter to paint, backspace to\n" + + "erase. [ and ] cycle the ink character, 1-8 pick a color, c clears\n" + + "everything for everyone (yes, really — it's a shared wall).\n\n" + + dim.Render("press any key to start") + return m.boxStyle().Render(body) +} + +func (m Model) boxStyle() lipgloss.Style { + return m.style().Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#555555")).Padding(1, 2) +} + +func (m Model) renderCanvas() string { + cells, cursors := m.canvas.Snapshot() + + // Cursors are a background highlight on whatever's actually painted at + // that cell, not a glyph that replaces it -- a glyph-replace bug meant + // painting a cell your own cursor already sat on was invisible: the + // cursor marker before and after painting rendered as the exact same + // character in the exact same color, so bubbletea's diffing renderer + // (correctly) sent zero bytes, since nothing had visually changed. + type painted struct { + ch rune + color string + bg string + } + grid := make([][]painted, Height) + for y := 0; y < Height; y++ { + grid[y] = make([]painted, Width) + for x := 0; x < Width; x++ { + c := cells[y][x] + if c.Char == 0 { + grid[y][x] = painted{ch: ' '} + } else { + grid[y][x] = painted{ch: c.Char, color: c.Color} + } + } + } + for id, cur := range cursors { + if id == m.id { + continue + } + grid[cur.Y][cur.X].bg = cur.Color + } + if self, ok := cursors[m.id]; ok { + grid[self.Y][self.X].bg = inkPalette[m.inkColorIdx] + } + + var b strings.Builder + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + p := grid[y][x] + style := m.style() + if p.color != "" { + style = style.Foreground(lipgloss.Color(p.color)) + } + if p.bg != "" { + style = style.Background(lipgloss.Color(p.bg)).Foreground(lipgloss.Color("#000000")) + } + ch := p.ch + if ch == 0 || ch == ' ' { + ch = ' ' + } + b.WriteString(style.Render(" ")) + b.WriteString(style.Render(string(ch))) + } + b.WriteString("\n") + } + + inkSwatch := m.style().Foreground(lipgloss.Color(inkPalette[m.inkColorIdx])).Render(string(charPalette[m.charIdx])) + dim := m.style().Foreground(lipgloss.Color("#777777")) + status := fmt.Sprintf("ink %s (1-8 color, [ ] char) peers: %d %s", + inkSwatch, m.canvas.PeerCount(), dim.Render("space paint · c clear · q quit")) + + return m.boxStyle().Render(b.String() + "\n" + status) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6da5014 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module scrawl + +go 1.24.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 + github.com/charmbracelet/wish v1.4.7 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/log v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/termios v0.1.0 // indirect + github.com/creack/pty v1.1.21 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc29b0e --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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/main.go b/main.go new file mode 100644 index 0000000..16c1172 --- /dev/null +++ b/main.go @@ -0,0 +1,123 @@ +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) + } +} diff --git a/run-local.sh b/run-local.sh new file mode 100755 index 0000000..5ccd8a5 --- /dev/null +++ b/run-local.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Local test-run helper — not part of the deploy path (see Dockerfile / +# docker-compose.yml for that). Persists a host key under .ssh/ so the +# server's fingerprint doesn't change between runs and nag every client. +set -eu +cd "$(dirname "$0")" +mkdir -p .ssh +export PORT="${PORT:-23235}" +export HOST_KEY_PATH="${HOST_KEY_PATH:-.ssh/scrawl_host_key}" +echo "starting scrawl on :$PORT -- connect with: ssh -p $PORT localhost" +go run .