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) } }