124 lines
3.3 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|