Initial commit
This commit is contained in:
642
main.go
Normal file
642
main.go
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "net/http/pprof"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activeRequest struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
AdminSecret string `json:"admin_secret"`
|
||||||
|
PublicClientId string `json:"public_client_id"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminRequest struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
AdminSecret string `json:"admin_secret"`
|
||||||
|
PublicClientId string `json:"public_client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type announceRequest struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
ClientId string `json:"client_id"`
|
||||||
|
AdminSecret string `json:"admin_secret"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlRequest struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
ClientId string `json:"client_id"`
|
||||||
|
Control string `json:"control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createResponse struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
AdminSecret string `json:"admin_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type removeRequest struct {
|
||||||
|
RoomId string `json:"room_id"`
|
||||||
|
ClientId string `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
PublicClientId string `json:"public_client_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
|
||||||
|
clientId string
|
||||||
|
room *room
|
||||||
|
lastSeen time.Time
|
||||||
|
eventChan chan *event
|
||||||
|
}
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
AdminEvent *adminEvent `json:"admin_event"`
|
||||||
|
StandardEvent *standardEvent `json:"standard_event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminEvent struct {
|
||||||
|
Client *client `json:"client"`
|
||||||
|
Remove bool `json:"remove"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type standardEvent struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
AdminSecret string `json:"admin_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlEvent struct {
|
||||||
|
Control string `json:"control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type room struct {
|
||||||
|
roomId string
|
||||||
|
clientById map[string]*client
|
||||||
|
clientByPublicId map[string]*client
|
||||||
|
present map[*presentState]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchState struct {
|
||||||
|
responseWriter http.ResponseWriter
|
||||||
|
request *http.Request
|
||||||
|
flusher http.Flusher
|
||||||
|
room *room
|
||||||
|
client *client
|
||||||
|
admin bool
|
||||||
|
eventChan chan *event
|
||||||
|
}
|
||||||
|
|
||||||
|
type presentState struct {
|
||||||
|
responseWriter http.ResponseWriter
|
||||||
|
request *http.Request
|
||||||
|
flusher http.Flusher
|
||||||
|
room *room
|
||||||
|
controlChan chan *controlEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
var key []byte
|
||||||
|
var roomById = map[string]*room{}
|
||||||
|
var mu = sync.Mutex{}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
|
keyFlag := flag.String("key", "", "secret key")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *keyFlag == "" {
|
||||||
|
log.Fatalf("please specify --key (suggestion: %x)", rand.Uint64())
|
||||||
|
}
|
||||||
|
key = []byte(*keyFlag)
|
||||||
|
|
||||||
|
go scanLoop()
|
||||||
|
|
||||||
|
registerFile("/", "index.html")
|
||||||
|
registerFile("/remote.js", "remote.js")
|
||||||
|
registerFile("/remote.css", "remote.css")
|
||||||
|
|
||||||
|
http.HandleFunc("/api/active", active)
|
||||||
|
http.HandleFunc("/api/admin", admin)
|
||||||
|
http.HandleFunc("/api/announce", announce)
|
||||||
|
http.HandleFunc("/api/control", control)
|
||||||
|
http.HandleFunc("/api/create", create)
|
||||||
|
http.HandleFunc("/api/present", present)
|
||||||
|
http.HandleFunc("/api/remove", remove)
|
||||||
|
http.HandleFunc("/api/watch", watch)
|
||||||
|
|
||||||
|
server := http.Server{
|
||||||
|
Addr: ":2000",
|
||||||
|
}
|
||||||
|
err := server.ListenAndServe()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("ListenAndServe(): %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerFile(urlPath, filename string) {
|
||||||
|
http.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == urlPath {
|
||||||
|
serveStatic(w, r, path.Join("static", filename))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveStatic(resp http.ResponseWriter, req *http.Request, path string) {
|
||||||
|
resp.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
http.ServeFile(resp, req, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
scan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scan() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-30 * time.Second)
|
||||||
|
|
||||||
|
for _, rm := range roomById {
|
||||||
|
for _, c := range rm.clientById {
|
||||||
|
if c.lastSeen.Before(cutoff) {
|
||||||
|
c.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func active(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
req := &activeRequest{}
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := getRoom(req.RoomId)
|
||||||
|
|
||||||
|
if req.AdminSecret != rm.adminSecret() {
|
||||||
|
http.Error(w, "invalid admin_secret", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := rm.clientByPublicId[req.PublicClientId]
|
||||||
|
if c == nil {
|
||||||
|
http.Error(w, "invalid public_client_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Active = req.Active
|
||||||
|
c.update()
|
||||||
|
rm.sendAdminEvent(&adminEvent{
|
||||||
|
Client: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func admin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
req := &adminRequest{}
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := getRoom(req.RoomId)
|
||||||
|
|
||||||
|
if req.AdminSecret != rm.adminSecret() {
|
||||||
|
http.Error(w, "invalid admin_secret", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := rm.clientByPublicId[req.PublicClientId]
|
||||||
|
if c == nil {
|
||||||
|
http.Error(w, "invalid public_client_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Admin = true
|
||||||
|
c.update()
|
||||||
|
rm.sendAdminEvent(&adminEvent{
|
||||||
|
Client: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func announce(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
req := &announceRequest{}
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := getRoom(req.RoomId)
|
||||||
|
|
||||||
|
admin := false
|
||||||
|
if req.AdminSecret != "" {
|
||||||
|
if req.AdminSecret == rm.adminSecret() {
|
||||||
|
admin = true
|
||||||
|
} else {
|
||||||
|
http.Error(w, "invalid admin_secret", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := rm.getClient(req.ClientId)
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
if c.Name != req.Name {
|
||||||
|
c.Name = req.Name
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Admin != admin {
|
||||||
|
c.Admin = admin
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
rm.sendAdminEvent(&adminEvent{
|
||||||
|
Client: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func control(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
req := &controlRequest{}
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := getRoom(req.RoomId)
|
||||||
|
c := rm.getClient(req.ClientId)
|
||||||
|
|
||||||
|
if !c.Active {
|
||||||
|
http.Error(w, "client is not active", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
rm.sendControlEvent(&controlEvent{
|
||||||
|
Control: req.Control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp := &createResponse{
|
||||||
|
RoomId: uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := newRoom(resp.RoomId)
|
||||||
|
resp.AdminSecret = rm.adminSecret()
|
||||||
|
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
err := enc.Encode(resp)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ps := newPresentState(w, r)
|
||||||
|
if ps == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeChan := w.(http.CloseNotifier).CloseNotify()
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-closeChan:
|
||||||
|
ps.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
mu.Lock()
|
||||||
|
ps.sendHeartbeat()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
case ctrl := <-ps.controlChan:
|
||||||
|
mu.Lock()
|
||||||
|
ps.sendEvent(ctrl)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
req := &removeRequest{}
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rm := getRoom(req.RoomId)
|
||||||
|
c := rm.getClient(req.ClientId)
|
||||||
|
c.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
func watch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws := newWatchState(w, r)
|
||||||
|
if ws == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeChan := w.(http.CloseNotifier).CloseNotify()
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
|
||||||
|
ws.sendInitial()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-closeChan:
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
mu.Lock()
|
||||||
|
ws.sendHeartbeat()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
case event := <-ws.eventChan:
|
||||||
|
mu.Lock()
|
||||||
|
ws.sendEvent(event)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) sendEvent(e *event) {
|
||||||
|
if c.eventChan != nil {
|
||||||
|
c.eventChan <- e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) remove() {
|
||||||
|
delete(c.room.clientById, c.clientId)
|
||||||
|
delete(c.room.clientByPublicId, c.PublicClientId)
|
||||||
|
|
||||||
|
c.room.sendAdminEvent(&adminEvent{
|
||||||
|
Client: c,
|
||||||
|
Remove: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) update() {
|
||||||
|
e := &event{
|
||||||
|
StandardEvent: &standardEvent{
|
||||||
|
Active: c.Active,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if c.Admin {
|
||||||
|
e.StandardEvent.AdminSecret = c.room.adminSecret()
|
||||||
|
}
|
||||||
|
c.sendEvent(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRoom(roomId string) *room {
|
||||||
|
return &room{
|
||||||
|
roomId: roomId,
|
||||||
|
clientById: map[string]*client{},
|
||||||
|
clientByPublicId: map[string]*client{},
|
||||||
|
present: map[*presentState]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRoom(roomId string) *room {
|
||||||
|
r := roomById[roomId]
|
||||||
|
if r == nil {
|
||||||
|
r = newRoom(roomId)
|
||||||
|
roomById[roomId] = r
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *room) adminSecret() string {
|
||||||
|
h := hmac.New(sha256.New, key)
|
||||||
|
return base64.StdEncoding.EncodeToString(h.Sum([]byte(rm.roomId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *room) getClient(clientId string) *client {
|
||||||
|
c := rm.clientById[clientId]
|
||||||
|
if c == nil {
|
||||||
|
c = &client{
|
||||||
|
clientId: clientId,
|
||||||
|
PublicClientId: uuid.New().String(),
|
||||||
|
room: rm,
|
||||||
|
}
|
||||||
|
rm.clientById[clientId] = c
|
||||||
|
rm.clientByPublicId[c.PublicClientId] = c
|
||||||
|
|
||||||
|
rm.sendAdminEvent(&adminEvent{
|
||||||
|
Client: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastSeen = time.Now().UTC()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *room) sendAdminEvent(ae *adminEvent) {
|
||||||
|
for _, client := range rm.clientById {
|
||||||
|
if !client.Admin {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client.sendEvent(&event{
|
||||||
|
AdminEvent: ae,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rm *room) sendControlEvent(ce *controlEvent) {
|
||||||
|
for present, _ := range rm.present {
|
||||||
|
present.sendEvent(ce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWatchState(w http.ResponseWriter, r *http.Request) *watchState {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
ws := &watchState{
|
||||||
|
responseWriter: w,
|
||||||
|
request: r,
|
||||||
|
eventChan: make(chan *event, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
ws.flusher, ok = w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(ws.responseWriter, "streaming unsupported", http.StatusBadRequest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId := r.URL.Query().Get("room_id")
|
||||||
|
ws.room = getRoom(roomId)
|
||||||
|
|
||||||
|
clientId := r.URL.Query().Get("client_id")
|
||||||
|
ws.client = ws.room.getClient(clientId)
|
||||||
|
|
||||||
|
adminSecret := r.URL.Query().Get("admin_secret")
|
||||||
|
if adminSecret != "" {
|
||||||
|
if adminSecret == ws.room.adminSecret() {
|
||||||
|
ws.admin = true
|
||||||
|
} else {
|
||||||
|
http.Error(w, "invalid admin_secret", http.StatusBadRequest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.client.eventChan = ws.eventChan
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *watchState) sendInitial() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !ws.admin {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, client := range ws.room.clientById {
|
||||||
|
ws.sendEvent(&event{
|
||||||
|
AdminEvent: &adminEvent{
|
||||||
|
Client: client,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *watchState) sendHeartbeat() {
|
||||||
|
fmt.Fprintf(ws.responseWriter, ":\n\n")
|
||||||
|
ws.flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *watchState) sendEvent(e *event) {
|
||||||
|
j, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ws.responseWriter, "data: %s\n\n", j)
|
||||||
|
ws.flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *watchState) close() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if ws.client.eventChan == ws.eventChan {
|
||||||
|
ws.client.eventChan = nil
|
||||||
|
close(ws.eventChan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPresentState(w http.ResponseWriter, r *http.Request) *presentState {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
ps := &presentState{
|
||||||
|
responseWriter: w,
|
||||||
|
request: r,
|
||||||
|
controlChan: make(chan *controlEvent, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
ps.flusher, ok = w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(ps.responseWriter, "streaming unsupported", http.StatusBadRequest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roomId := r.URL.Query().Get("room_id")
|
||||||
|
ps.room = getRoom(roomId)
|
||||||
|
|
||||||
|
ps.room.present[ps] = true
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *presentState) sendHeartbeat() {
|
||||||
|
fmt.Fprintf(ps.responseWriter, ":\n\n")
|
||||||
|
ps.flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *presentState) sendEvent(e *controlEvent) {
|
||||||
|
j, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(ps.responseWriter, "data: %s\n\n", j)
|
||||||
|
ps.flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *presentState) close() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
delete(ps.room.present, ps)
|
||||||
|
close(ps.controlChan)
|
||||||
|
}
|
||||||
48
present/present.py
Executable file
48
present/present.py
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pyautogui
|
||||||
|
import requests
|
||||||
|
import sseclient
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
ALLOWED_CONTROLS = {'left', 'right'}
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f'usage: {sys.argv[0]} <url>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
url = urllib.parse.urlparse(sys.argv[1])
|
||||||
|
qs = urllib.parse.parse_qs(url.query)
|
||||||
|
|
||||||
|
if 'room' not in qs or len(qs['room']) != 1:
|
||||||
|
print(f'invald url: {sys.argv[1]}')
|
||||||
|
|
||||||
|
room = qs['room'][0]
|
||||||
|
|
||||||
|
presentUrl = urllib.parse.urlunparse([
|
||||||
|
url.scheme,
|
||||||
|
url.netloc,
|
||||||
|
'/api/present',
|
||||||
|
url.params,
|
||||||
|
urllib.parse.urlencode({'room_id': room}),
|
||||||
|
url.fragment,
|
||||||
|
])
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
response = requests.get(presentUrl, stream=True)
|
||||||
|
client = sseclient.SSEClient(response)
|
||||||
|
for event in client.events():
|
||||||
|
parsed = json.loads(event.data)
|
||||||
|
control = parsed['control']
|
||||||
|
if control not in ALLOWED_CONTROLS:
|
||||||
|
print(f'INVALID CONTROL: {control}')
|
||||||
|
continue
|
||||||
|
print(control)
|
||||||
|
pyautogui.press(control)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
time.sleep(2)
|
||||||
14
static/.stylelintrc.json
Normal file
14
static/.stylelintrc.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "stylelint-config-standard",
|
||||||
|
"plugins": [
|
||||||
|
"stylelint-order"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"order/order": [
|
||||||
|
"custom-properties",
|
||||||
|
"declarations"
|
||||||
|
],
|
||||||
|
"order/properties-alphabetical-order": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
static/Makefile
Normal file
16
static/Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
all: check remote.js
|
||||||
|
|
||||||
|
remote.js: *.ts *.json
|
||||||
|
tsc
|
||||||
|
|
||||||
|
check: *.ts *.json *.css
|
||||||
|
tslint --project . --fix
|
||||||
|
stylelint --fix *.css
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f remote.js
|
||||||
|
|
||||||
|
ci:
|
||||||
|
tslint --project .
|
||||||
|
stylelint *.css
|
||||||
|
|
||||||
8
static/index.html
Normal file
8
static/index.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Slide Together</title>
|
||||||
|
<link rel="stylesheet" href="remote.css">
|
||||||
|
<script src="remote.js" charset="UTF-8"></script>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
79
static/remote.css
Normal file
79
static/remote.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
:root {
|
||||||
|
--background-color: #222831;
|
||||||
|
--subtle-color: #2d4059;
|
||||||
|
--normal-color: #eee;
|
||||||
|
--highlight-color: #d65a31;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--normal-color);
|
||||||
|
font-family: Courier, monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px dotted var(--highlight-color);
|
||||||
|
color: var(--normal-color);
|
||||||
|
font-family: Courier, monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border: 3px solid var(--highlight-color);
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--subtle-color);
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tfoot tr {
|
||||||
|
border: 3px solid var(--highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin,
|
||||||
|
.active {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.3;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin.enable {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
border: 3px solid var(--highlight-color);
|
||||||
|
display: inline-block;
|
||||||
|
margin: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
transition: all 0.8s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:active {
|
||||||
|
background: var(--subtle-color);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls.enable .control-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
230
static/remote.js
Normal file
230
static/remote.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use strict";
|
||||||
|
function main() {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
if (url.searchParams.has("room")) {
|
||||||
|
renderRoom(url.searchParams.get("room"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newRoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderRoom(roomId) {
|
||||||
|
const clientId = uuid();
|
||||||
|
const adminSecret = localStorage.getItem(`admin_secret:${roomId}`);
|
||||||
|
const prnt = document.body;
|
||||||
|
const nameLabel = create(prnt, "label", "Name: ");
|
||||||
|
const name = create(nameLabel, "input");
|
||||||
|
name.type = "text";
|
||||||
|
name.size = 30;
|
||||||
|
name.value = localStorage.getItem("name") || "";
|
||||||
|
name.addEventListener("change", () => {
|
||||||
|
localStorage.setItem("name", name.value);
|
||||||
|
});
|
||||||
|
addEventListener("unload", () => remove(roomId, clientId));
|
||||||
|
announce(roomId, clientId, adminSecret, name);
|
||||||
|
watch(roomId, clientId, adminSecret, prnt);
|
||||||
|
}
|
||||||
|
function newRoom() {
|
||||||
|
fetch("/api/create", { method: "POST" })
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(data => {
|
||||||
|
const resp = data;
|
||||||
|
localStorage.setItem(`admin_secret:${resp.room_id}`, resp.admin_secret);
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set("room", resp.room_id);
|
||||||
|
location.href = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function announce(roomId, clientId, adminSecret, name) {
|
||||||
|
const req = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
name: name.value,
|
||||||
|
};
|
||||||
|
fetch("/api/announce", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function watch(roomId, clientId, adminSecret, prnt) {
|
||||||
|
const url = new URL("/api/watch", location.href);
|
||||||
|
url.searchParams.set("room_id", roomId);
|
||||||
|
url.searchParams.set("client_id", clientId);
|
||||||
|
if (adminSecret) {
|
||||||
|
url.searchParams.set("admin_secret", adminSecret);
|
||||||
|
}
|
||||||
|
const es = new EventSource(url.toString());
|
||||||
|
renderControls(roomId, clientId, adminSecret, prnt, es);
|
||||||
|
if (adminSecret) {
|
||||||
|
renderAdmin(roomId, adminSecret, prnt, es);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderControls(roomId, clientId, adminSecret, prnt, es) {
|
||||||
|
const controls = create(prnt, "div", undefined, ["controls"]);
|
||||||
|
const left = create(controls, "span", "<<<", ["control-button"]);
|
||||||
|
left.addEventListener("click", () => control(roomId, clientId, controls, "left"));
|
||||||
|
const right = create(controls, "span", ">>>", ["control-button"]);
|
||||||
|
right.addEventListener("click", () => control(roomId, clientId, controls, "right"));
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
control(roomId, clientId, controls, "left");
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
case "ArrowRight":
|
||||||
|
control(roomId, clientId, controls, "right");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.addEventListener("message", (e) => {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
if (!event.standard_event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.standard_event.admin_secret && !adminSecret) {
|
||||||
|
localStorage.setItem(`admin_secret:${roomId}`, event.standard_event.admin_secret);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
if (event.standard_event.active) {
|
||||||
|
controls.classList.add("enable");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.classList.remove("enable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderAdmin(roomId, adminSecret, prnt, es) {
|
||||||
|
const table = create(prnt, "table", undefined, ["users"]);
|
||||||
|
const head = create(table, "thead");
|
||||||
|
const head1 = create(head, "tr");
|
||||||
|
create(head1, "th", "Name");
|
||||||
|
create(head1, "th", "👑");
|
||||||
|
create(head1, "th", "👆");
|
||||||
|
const body = create(table, "tbody");
|
||||||
|
const rows = new Map();
|
||||||
|
es.addEventListener("message", (e) => {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
if (!event.admin_event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const client = event.admin_event.client;
|
||||||
|
let row = rows.get(client.public_client_id);
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
rows.delete(client.public_client_id);
|
||||||
|
}
|
||||||
|
if (event.admin_event.remove) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row = document.createElement("tr");
|
||||||
|
row.dataset.name = client.name;
|
||||||
|
let before = null;
|
||||||
|
for (const iter of body.children) {
|
||||||
|
const iterRow = iter;
|
||||||
|
if (iterRow.dataset.name.localeCompare(row.dataset.name) > 0) {
|
||||||
|
before = iter;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.insertBefore(row, before);
|
||||||
|
create(row, "td", client.name);
|
||||||
|
const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]);
|
||||||
|
adminCell.addEventListener("click", () => {
|
||||||
|
if (!client.admin) {
|
||||||
|
if (!confirm(`Grant admin access to ${client.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admin(roomId, adminSecret, client.public_client_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]);
|
||||||
|
activeCell.addEventListener("click", () => {
|
||||||
|
active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable"));
|
||||||
|
});
|
||||||
|
rows.set(client.public_client_id, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function active(roomId, adminSecret, publicClientId, val) {
|
||||||
|
const req = {
|
||||||
|
room_id: roomId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
public_client_id: publicClientId,
|
||||||
|
active: val,
|
||||||
|
};
|
||||||
|
fetch("/api/active", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function admin(roomId, adminSecret, publicClientId) {
|
||||||
|
const req = {
|
||||||
|
room_id: roomId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
public_client_id: publicClientId,
|
||||||
|
};
|
||||||
|
fetch("/api/admin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function control(roomId, clientId, controls, ctrl) {
|
||||||
|
if (!controls.classList.contains("enable")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const req = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
control: ctrl,
|
||||||
|
};
|
||||||
|
fetch("/api/control", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function create(prnt, tag, text, classes) {
|
||||||
|
const elem = document.createElement(tag);
|
||||||
|
prnt.appendChild(elem);
|
||||||
|
if (text) {
|
||||||
|
elem.innerText = text;
|
||||||
|
}
|
||||||
|
for (const cls of classes !== null && classes !== void 0 ? classes : []) {
|
||||||
|
elem.classList.add(cls);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
function remove(roomId, clientId) {
|
||||||
|
const req = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
};
|
||||||
|
navigator.sendBeacon("/api/remove", JSON.stringify(req));
|
||||||
|
}
|
||||||
|
function uuid() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
addEventListener("DOMContentLoaded", () => main());
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
main();
|
||||||
|
}
|
||||||
336
static/remote.ts
Normal file
336
static/remote.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
interface ActiveRequest {
|
||||||
|
room_id: string;
|
||||||
|
admin_secret: string;
|
||||||
|
public_client_id: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminRequest {
|
||||||
|
room_id: string;
|
||||||
|
admin_secret: string;
|
||||||
|
public_client_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnounceRequest {
|
||||||
|
room_id: string;
|
||||||
|
client_id: string;
|
||||||
|
admin_secret: string | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlRequest {
|
||||||
|
room_id: string;
|
||||||
|
client_id: string;
|
||||||
|
control: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateResponse {
|
||||||
|
room_id: string;
|
||||||
|
admin_secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoveRequest {
|
||||||
|
room_id: string;
|
||||||
|
client_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
standard_event?: StandardEvent;
|
||||||
|
admin_event?: AdminEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StandardEvent {
|
||||||
|
active?: boolean;
|
||||||
|
admin_secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminEvent {
|
||||||
|
client: Client;
|
||||||
|
remove: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
public_client_id: string;
|
||||||
|
name: string;
|
||||||
|
admin: boolean;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
|
||||||
|
if (url.searchParams.has("room")) {
|
||||||
|
renderRoom(url.searchParams.get("room")!);
|
||||||
|
} else {
|
||||||
|
newRoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoom(roomId: string) {
|
||||||
|
const clientId = uuid();
|
||||||
|
const adminSecret = localStorage.getItem(`admin_secret:${roomId}`);
|
||||||
|
|
||||||
|
const prnt = document.body;
|
||||||
|
|
||||||
|
const nameLabel = create(prnt, "label", "Name: ") as HTMLLabelElement;
|
||||||
|
const name = create(nameLabel, "input") as HTMLInputElement;
|
||||||
|
name.type = "text";
|
||||||
|
name.size = 30;
|
||||||
|
name.value = localStorage.getItem("name") || "";
|
||||||
|
name.addEventListener("change", () => {
|
||||||
|
localStorage.setItem("name", name.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("unload", () => remove(roomId, clientId!));
|
||||||
|
|
||||||
|
announce(roomId, clientId!, adminSecret, name);
|
||||||
|
|
||||||
|
watch(roomId, clientId!, adminSecret, prnt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRoom() {
|
||||||
|
fetch("/api/create", {method: "POST"})
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(data => {
|
||||||
|
const resp = data as CreateResponse;
|
||||||
|
|
||||||
|
localStorage.setItem(`admin_secret:${resp.room_id}`, resp.admin_secret);
|
||||||
|
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set("room", resp.room_id);
|
||||||
|
location.href = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(roomId: string, clientId: string, adminSecret: string | null, name: HTMLInputElement) {
|
||||||
|
const req: AnnounceRequest = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
name: name.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/announce", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function watch(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) {
|
||||||
|
const url = new URL("/api/watch", location.href);
|
||||||
|
url.searchParams.set("room_id", roomId);
|
||||||
|
url.searchParams.set("client_id", clientId);
|
||||||
|
if (adminSecret) {
|
||||||
|
url.searchParams.set("admin_secret", adminSecret);
|
||||||
|
}
|
||||||
|
const es = new EventSource(url.toString());
|
||||||
|
|
||||||
|
renderControls(roomId, clientId, adminSecret, prnt, es);
|
||||||
|
|
||||||
|
if (adminSecret) {
|
||||||
|
renderAdmin(roomId, adminSecret, prnt, es);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) {
|
||||||
|
const controls = create(prnt, "div", undefined, ["controls"]) as HTMLDivElement;
|
||||||
|
|
||||||
|
const left = create(controls, "span", "<<<", ["control-button"]) as HTMLDivElement;
|
||||||
|
left.addEventListener("click", () => control(roomId, clientId, controls, "left"));
|
||||||
|
|
||||||
|
const right = create(controls, "span", ">>>", ["control-button"]) as HTMLDivElement;
|
||||||
|
right.addEventListener("click", () => control(roomId, clientId, controls, "right"));
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
control(roomId, clientId, controls, "left");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case " ":
|
||||||
|
case "ArrowRight":
|
||||||
|
control(roomId, clientId, controls, "right");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("message", (e) => {
|
||||||
|
const event = JSON.parse(e.data) as Event;
|
||||||
|
|
||||||
|
if (!event.standard_event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.standard_event.admin_secret && !adminSecret) {
|
||||||
|
localStorage.setItem(`admin_secret:${roomId}`, event.standard_event.admin_secret);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.standard_event.active) {
|
||||||
|
controls.classList.add("enable");
|
||||||
|
} else {
|
||||||
|
controls.classList.remove("enable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: EventSource) {
|
||||||
|
const table = create(prnt, "table", undefined, ["users"]) as HTMLTableElement;
|
||||||
|
const head = create(table, "thead");
|
||||||
|
const head1 = create(head, "tr");
|
||||||
|
create(head1, "th", "Name");
|
||||||
|
create(head1, "th", "👑");
|
||||||
|
create(head1, "th", "👆");
|
||||||
|
|
||||||
|
const body = create(table, "tbody");
|
||||||
|
|
||||||
|
const rows: Map<string, HTMLTableRowElement> = new Map();
|
||||||
|
|
||||||
|
es.addEventListener("message", (e) => {
|
||||||
|
const event = JSON.parse(e.data) as Event;
|
||||||
|
|
||||||
|
if (!event.admin_event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = event.admin_event.client;
|
||||||
|
let row = rows.get(client.public_client_id);
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
rows.delete(client.public_client_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.admin_event.remove) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row = document.createElement("tr") as HTMLTableRowElement;
|
||||||
|
row.dataset.name = client.name;
|
||||||
|
|
||||||
|
let before = null;
|
||||||
|
for (const iter of body.children) {
|
||||||
|
const iterRow = iter as HTMLTableRowElement;
|
||||||
|
if (iterRow.dataset.name!.localeCompare(row.dataset.name) > 0) {
|
||||||
|
before = iter;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.insertBefore(row, before);
|
||||||
|
|
||||||
|
create(row, "td", client.name);
|
||||||
|
|
||||||
|
const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]) as HTMLTableCellElement;
|
||||||
|
adminCell.addEventListener("click", () => {
|
||||||
|
if (!client.admin) {
|
||||||
|
if (!confirm(`Grant admin access to ${client.name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admin(roomId, adminSecret, client.public_client_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]) as HTMLTableCellElement;
|
||||||
|
activeCell.addEventListener("click", () => {
|
||||||
|
active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable"));
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.set(client.public_client_id, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function active(roomId: string, adminSecret: string, publicClientId: string, val: boolean) {
|
||||||
|
const req: ActiveRequest = {
|
||||||
|
room_id: roomId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
public_client_id: publicClientId,
|
||||||
|
active: val,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/active", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin(roomId: string, adminSecret: string, publicClientId: string) {
|
||||||
|
const req: AdminRequest = {
|
||||||
|
room_id: roomId,
|
||||||
|
admin_secret: adminSecret,
|
||||||
|
public_client_id: publicClientId,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/admin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(roomId: string, clientId: string, controls: HTMLElement, ctrl: string) {
|
||||||
|
if (!controls.classList.contains("enable")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req: ControlRequest = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
control: ctrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch("/api/control", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(prnt: HTMLElement, tag: string, text?: string, classes?: string[]): HTMLElement {
|
||||||
|
const elem = document.createElement(tag);
|
||||||
|
prnt.appendChild(elem);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
elem.innerText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cls of classes ?? []) {
|
||||||
|
elem.classList.add(cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(roomId: string, clientId: string) {
|
||||||
|
const req: RemoveRequest = {
|
||||||
|
room_id: roomId,
|
||||||
|
client_id: clientId,
|
||||||
|
}
|
||||||
|
navigator.sendBeacon("/api/remove", JSON.stringify(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuid() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
addEventListener("DOMContentLoaded", () => main());
|
||||||
|
} else {
|
||||||
|
main();
|
||||||
|
}
|
||||||
11
static/tsconfig.json
Normal file
11
static/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outFile": "remote.js",
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2017"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"remote.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
10
static/tslint.json
Normal file
10
static/tslint.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "tslint:recommended",
|
||||||
|
"rules": {
|
||||||
|
"interface-name": false,
|
||||||
|
"max-line-length": false,
|
||||||
|
"no-bitwise": false,
|
||||||
|
"no-console": false,
|
||||||
|
"no-unused-expression": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user