mirror of
https://github.com/la5nta/pat
synced 2026-08-07 22:32:06 -04:00
305 lines
7.4 KiB
Go
305 lines
7.4 KiB
Go
// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
|
|
// Use of this source code is governed by the MIT-license that can be
|
|
// found in the LICENSE file.
|
|
|
|
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/la5nta/pat/api/types"
|
|
"github.com/la5nta/pat/app"
|
|
"github.com/la5nta/pat/internal/debug"
|
|
"github.com/la5nta/pat/internal/osutil"
|
|
"github.com/la5nta/wl2k-go/mailbox"
|
|
)
|
|
|
|
const KeepaliveInterval = 4 * time.Minute
|
|
|
|
// WSConn represent one connection in the WSHub pool
|
|
type WSConn struct {
|
|
conn *websocket.Conn
|
|
out chan interface{}
|
|
}
|
|
|
|
// WSHub is a hub for broadcasting data to several websocket connections
|
|
type WSHub struct {
|
|
*app.App
|
|
|
|
mu sync.Mutex
|
|
pool map[*WSConn]struct{}
|
|
}
|
|
|
|
func NewWSHub(app *app.App) *WSHub {
|
|
return &WSHub{App: app, pool: map[*WSConn]struct{}{}}
|
|
}
|
|
|
|
func (w *WSHub) UpdateStatus() {
|
|
w.WriteJSON(struct{ Status types.Status }{w.GetStatus()})
|
|
}
|
|
|
|
func (w *WSHub) WriteProgress(p types.Progress) { w.WriteJSON(struct{ Progress types.Progress }{p}) }
|
|
func (w *WSHub) WriteNotification(n types.Notification) {
|
|
w.WriteJSON(struct{ Notification types.Notification }{n})
|
|
}
|
|
|
|
func (w *WSHub) Prompt(p app.Prompt) {
|
|
w.WriteJSON(struct{ Prompt types.Prompt }{p.Prompt})
|
|
go func() { <-p.Done(); w.WriteJSON(struct{ PromptAbort types.Prompt }{p.Prompt}) }()
|
|
}
|
|
|
|
func (w *WSHub) WriteJSON(v interface{}) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
for c := range w.pool {
|
|
select {
|
|
case c.out <- v:
|
|
case <-time.After(3 * time.Second):
|
|
debug.Printf("Closing one unresponsive web socket")
|
|
c.conn.Close()
|
|
delete(w.pool, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close closes all active WebSocket connections in the hub.
|
|
//
|
|
// The hub should not be used after calling Close.
|
|
func (w *WSHub) Close() error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.pool == nil {
|
|
return nil
|
|
}
|
|
for conn, _ := range w.pool {
|
|
// Closing the connection should trigger the deferred cleanup in the Handle method for that client,
|
|
// which includes removing it from the pool.
|
|
err := conn.conn.Close()
|
|
if err != nil {
|
|
debug.Printf("Error closing WebSocket connection %s: %v", conn.conn.RemoteAddr(), err)
|
|
}
|
|
}
|
|
w.pool = nil
|
|
return nil
|
|
}
|
|
|
|
func (w *WSHub) NumClients() int { return len(w.ClientAddrs()) }
|
|
|
|
func (w *WSHub) ClientAddrs() []string {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
addrs := make([]string, 0, len(w.pool))
|
|
for c := range w.pool {
|
|
addrs = append(addrs, c.conn.RemoteAddr().String())
|
|
}
|
|
return addrs
|
|
}
|
|
|
|
func (w *WSHub) WatchMBox(ctx context.Context, mbox *mailbox.DirHandler) {
|
|
// Maximise ulimit -n:
|
|
// fsnotify opens a file descriptor for every file in the directories it watches, which
|
|
// may more files than the current soft limit. The is especially a problem on macOS which
|
|
// has a default soft limit of only 256 files. Windows does not have a such a limit.
|
|
if runtime.GOOS != "windows" {
|
|
if err := osutil.RaiseOpenFileLimit(4096); err != nil {
|
|
log.Printf("Unable to raise open file limit: %v", err)
|
|
}
|
|
}
|
|
|
|
fsWatcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
log.Println("Unable to start fs watcher: ", err)
|
|
return
|
|
}
|
|
defer fsWatcher.Close()
|
|
|
|
// Add all directories in the mailbox to the watcher
|
|
for _, dir := range []string{mailbox.DIR_INBOX, mailbox.DIR_OUTBOX, mailbox.DIR_SENT, mailbox.DIR_ARCHIVE} {
|
|
p := path.Join(mbox.MBoxPath, dir)
|
|
debug.Printf("Adding '%s' to fs watcher", p)
|
|
if err := fsWatcher.Add(p); err != nil {
|
|
log.Printf("Unable to add path '%s' to fs watcher: %v", p, err)
|
|
}
|
|
}
|
|
|
|
// Listen for filesystem events and broadcast updates to all clients
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case e := <-fsWatcher.Events:
|
|
if e.Op == fsnotify.Chmod {
|
|
continue
|
|
}
|
|
// Make sure we don't send many of these events over a short period.
|
|
drainUntilSilence(fsWatcher, 100*time.Millisecond)
|
|
w.WriteJSON(struct {
|
|
UpdateMailbox bool
|
|
}{true})
|
|
case err := <-fsWatcher.Errors:
|
|
log.Println(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle adds a new websocket to the hub
|
|
//
|
|
// It will block until the client either stops responding or closes the connection.
|
|
func (w *WSHub) Handle(conn *websocket.Conn) {
|
|
debug.Printf("ws[%s] subscribed", conn.RemoteAddr())
|
|
c := &WSConn{
|
|
conn: conn,
|
|
out: make(chan interface{}, 1),
|
|
}
|
|
|
|
w.mu.Lock()
|
|
w.pool[c] = struct{}{}
|
|
w.mu.Unlock()
|
|
|
|
// Initial status update
|
|
// (broadcasted as it includes info to other clients about this new one)
|
|
w.UpdateStatus()
|
|
|
|
quit := w.wsReadLoop(conn)
|
|
|
|
// Disconnect and remove client when this handler returns.
|
|
defer func() {
|
|
debug.Printf("ws[%s] unsubscribing...", conn.RemoteAddr())
|
|
c.conn.Close()
|
|
w.mu.Lock()
|
|
delete(w.pool, c)
|
|
w.mu.Unlock()
|
|
w.UpdateStatus()
|
|
debug.Printf("ws[%s] unsubscribed", conn.RemoteAddr())
|
|
}()
|
|
|
|
lines, done, err := tailFile(w.Options().LogPath)
|
|
if err != nil {
|
|
log.Println(err)
|
|
return
|
|
}
|
|
defer close(done)
|
|
ticker := time.NewTicker(KeepaliveInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
var err error
|
|
c.conn.SetWriteDeadline(time.Time{})
|
|
select {
|
|
case <-ticker.C:
|
|
debug.Printf("ws[%s] ping", conn.RemoteAddr())
|
|
c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
err = c.conn.WriteJSON(struct {
|
|
Ping bool
|
|
}{true})
|
|
case line := <-lines:
|
|
c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
err = c.conn.WriteJSON(struct {
|
|
LogLine string
|
|
}{string(line)})
|
|
case v := <-c.out:
|
|
c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
err = c.conn.WriteJSON(v)
|
|
case <-quit:
|
|
// The read loop failed/disconnected. Abort.
|
|
return
|
|
}
|
|
if err != nil {
|
|
debug.Printf("ws[%s] write error: %v", conn.RemoteAddr(), err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// drainEvents reads from w.Events and blocks until the channel has been silent for at least 50 ms.
|
|
func drainUntilSilence(w *fsnotify.Watcher, silenceDur time.Duration) {
|
|
timer := time.NewTimer(silenceDur)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-w.Events:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
timer.Reset(silenceDur)
|
|
case <-timer.C:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expects the file to never get renamed/truncated or deleted
|
|
func tailFile(path string) (<-chan []byte, chan<- struct{}, error) {
|
|
lines := make(chan []byte)
|
|
done := make(chan struct{})
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
go func() {
|
|
rd := bufio.NewReader(file)
|
|
for {
|
|
data, _, err := rd.ReadLine()
|
|
if errors.Is(err, io.EOF) {
|
|
time.Sleep(time.Millisecond * 100)
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
file.Close()
|
|
return
|
|
case lines <- data:
|
|
}
|
|
}
|
|
}()
|
|
|
|
return lines, done, nil
|
|
}
|
|
|
|
func (w *WSHub) handleWSMessage(v map[string]json.RawMessage) {
|
|
raw, ok := v["prompt_response"]
|
|
if !ok {
|
|
return
|
|
}
|
|
var resp app.PromptResponse
|
|
json.Unmarshal(raw, &resp)
|
|
w.PromptHub().Respond(resp.ID, resp.Value, resp.Err)
|
|
}
|
|
|
|
func (w *WSHub) wsReadLoop(c *websocket.Conn) <-chan struct{} {
|
|
quit := make(chan struct{})
|
|
go func() {
|
|
for {
|
|
v := map[string]json.RawMessage{}
|
|
// We should at least get a ping response once per KeepaliveInterval.
|
|
c.SetReadDeadline(time.Now().Add(KeepaliveInterval + 10*time.Second))
|
|
err := c.ReadJSON(&v)
|
|
if err != nil {
|
|
debug.Printf("ws[%s] read error: %v", c.RemoteAddr(), err)
|
|
close(quit)
|
|
return
|
|
}
|
|
if _, ok := v["Pong"]; ok {
|
|
// That's the Ping response.
|
|
debug.Printf("ws[%s] pong", c.RemoteAddr())
|
|
continue
|
|
}
|
|
go w.handleWSMessage(v)
|
|
}
|
|
}()
|
|
return quit
|
|
}
|