diff --git a/README.md b/README.md index d068f244..3fc81273 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ See the [documentation](https://ddvk.github.io/rmfakecloud/remarkable/setup/) fo | [Send document by email](https://ddvk.github.io/rmfakecloud/install/configuration/#email-settings) | ✅ | | | [Handwriting recognition](https://ddvk.github.io/rmfakecloud/install/configuration/#handwriting-recognition) | ✅ | | | Handwriting search | ❌ | | -| [Screen sharing](https://ddvk.github.io/rmfakecloud/install/configuration/#screen-sharing) | ✅ | Requires TLS and special proxying | +| [Screen sharing](https://ddvk.github.io/rmfakecloud/install/configuration/#screen-sharing) | ✅ | | | [Storage integrations](https://ddvk.github.io/rmfakecloud/usage/integrations/) | ✅ | | | Integration with Dropbox | 🟡 | [WIP](https://github.com/ddvk/rmfakecloud/blob/master/internal/integrations/dropbox.go) | | Integration with Google Drive | 🟡 | [WIP](https://github.com/ddvk/rmfakecloud/pull/241) | diff --git a/docs/install/configuration.md b/docs/install/configuration.md index df05d463..7cc7e98e 100644 --- a/docs/install/configuration.md +++ b/docs/install/configuration.md @@ -42,14 +42,32 @@ To be able to send email from your reMarkable, fill the following variables: ## Screen sharing +Screen sharing streams your tablet display to a browser via WebRTC. There are two signaling modes depending on your tablet's software version. + +### REST-based (reMarkable OS 3.27+) + +Starting with OS 3.27, the tablet can use REST-based signaling instead of MQTT. No additional setup is required, screen sharing works out of the box. + +Start a screen share session from the sharing menu on your tablet, then open the **Screen Share** page in the rmfakecloud web UI. The page automatically finds the active session and connects. + +| Variable name | Description | +|-------------------|-------------| +| `ICE_SERVERS` | JSON array of WebRTC ICE servers. Default: Google STUN server. Format: `[{"urls":["stun:stun.l.google.com:19302"]}]` or with TURN: `[{"urls":["turn:turn.example.com:3478"],"username":"user","credential":"pass"}]` | + +Without `ICE_SERVERS` set, a public Google STUN server is used, which works when the tablet and browser / app are on the same network. If you want to screenshare across the internet, you may need a TURN server. + +### MQTT-based (reMarkable OS < 3.27) + +Older tablet software uses MQTT for screen share signaling. This requires additional configuration: + | Variable name | Description | |-------------------|-------------| | `MQTT_PORT` | Port for MQTT broker (default: 8883) | | `ICE_SERVERS` | JSON array of WebRTC ICE servers. Default: none. Format: `[{"urls":["stun:stun.l.google.com:19302"]}]` or with TURN: `[{"urls":["turn:turn.example.com:3478"],"username":"user","credential":"pass"}]` | -| `TLS_CERT` | `path/to/cert`, required for screen sharing | -| `TLS_KEY` | `/path/to/key`, required for screen sharing | +| `TLS_CERT` | `path/to/cert`, required for MQTT screen sharing | +| `TLS_KEY` | `/path/to/key`, required for MQTT screen sharing | -TLS certificates are required for screen sharing. Desktop apps may not use system certificate store for MQTT. +TLS certificates are required for MQTT screen sharing. Desktop apps may not use the system certificate store for MQTT. Requires overriding DNS for `vernemq-prod.cloud.remarkable.engineering` to point to your rmfakecloud instance and using a TCP (not HTTP) reverse proxy. Without `ICE_SERVERS` set, screen sharing will work over USB and if the tablet and desktop app are on the same network. @@ -66,7 +84,7 @@ stream { } server { - listen 8883; + listen 443; proxy_pass mqtt; proxy_connect_timeout 5s; } @@ -91,5 +109,5 @@ tcp: entryPoints: mqtt: - address: ":8883" + address: ":443" ``` diff --git a/internal/app/app.go b/internal/app/app.go index 1ce2230b..d1fbeb92 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/config" "github.com/ddvk/rmfakecloud/internal/hwr" "github.com/ddvk/rmfakecloud/internal/mqtt" + "github.com/ddvk/rmfakecloud/internal/screenshare" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/ddvk/rmfakecloud/internal/storage/fs" @@ -44,6 +45,7 @@ type App struct { codeConnector CodeConnector hwrClient *hwr.HWRClient mqttBroker *mqtt.Broker + roomManager *screenshare.RoomManager } // Start starts the app @@ -72,7 +74,9 @@ func (app *App) Start() { if app.mqttBroker != nil { log.Infof("MQTT listening on port: %s", app.cfg.MQTTPort) - app.mqttBroker = mqtt.NewBroker(app.cfg.MQTTPort, tlsConfig, app.validateMQTTToken, app.cfg.ICEServers) + if tlsConfig != nil { + app.mqttBroker.SetTLSConfig(tlsConfig) + } if err := app.mqttBroker.Start(); err != nil { log.Errorf("Failed to start MQTT broker: %v", err) } @@ -170,11 +174,13 @@ func NewApp(cfg *config.Config) App { }, } - app.mqttBroker = mqtt.NewBroker(cfg.MQTTPort, nil, app.validateMQTTToken, cfg.ICEServers) + roomMgr := screenshare.NewRoomManager() + app.roomManager = roomMgr + app.mqttBroker = mqtt.NewBroker(cfg.MQTTPort, nil, app.validateMQTTToken, cfg.ICEServers, roomMgr, ntfHub) app.registerRoutes(router) - uiApp := ui.New(cfg, fsStorage, codeConnector, ntfHub, pcStore, fsStorage, fsStorage) + uiApp := ui.New(cfg, fsStorage, codeConnector, ntfHub, pcStore, fsStorage, fsStorage, roomMgr, app.mqttBroker) uiApp.RegisterRoutes(router) storageapp := fs.NewApp(cfg, fsStorage) @@ -198,18 +204,16 @@ func (app *App) validateMQTTToken(token string) (string, error) { claims := &UserClaims{} err := common.ClaimsFromToken(claims, token, app.cfg.JWTSecretKey) - if err != nil { - return "", fmt.Errorf("invalid token: %w", err) - } - - if claims.Profile.UserID == "" { - return "", fmt.Errorf("missing user ID in token") + if err == nil && claims.Profile.UserID != "" && claims.Version == tokenVersion { + userID := common.SanitizeUid(strings.TrimPrefix(claims.Profile.UserID, "auth0|")) + return userID, nil } - if claims.Version != tokenVersion { - return "", fmt.Errorf("invalid token version") + webClaims := &ui.WebUserClaims{} + err = common.ClaimsFromToken(webClaims, token, app.cfg.JWTSecretKey) + if err == nil && webClaims.UserID != "" { + return common.SanitizeUid(webClaims.UserID), nil } - userID := common.SanitizeUid(strings.TrimPrefix(claims.Profile.UserID, "auth0|")) - return userID, nil + return "", fmt.Errorf("invalid token") } diff --git a/internal/app/handlers.go b/internal/app/handlers.go index eb80a5d1..c30fc35b 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -23,6 +23,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/hwr" "github.com/ddvk/rmfakecloud/internal/integrations" "github.com/ddvk/rmfakecloud/internal/messages" + mqttmod "github.com/ddvk/rmfakecloud/internal/mqtt" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/ddvk/rmfakecloud/internal/storage/fs" "github.com/gin-gonic/gin" @@ -1163,6 +1164,125 @@ func (app *App) connectWebSocket(c *gin.Context) { go app.hub.ConnectWs(uid, deviceID, connection) } +func (app *App) screenshareCreateRoom(c *gin.Context) { + uid := userID(c) + deviceID := c.GetString(deviceIDKey) + + room := app.roomManager.CreateRoom(uid, deviceID) + + c.JSON(http.StatusCreated, gin.H{ + "roomId": room.RoomID, + "createdAt": room.CreatedAt.Format(time.RFC3339Nano), + "iceServers": app.cfg.ICEServers, + }) +} + +func (app *App) screenshareDeleteRoom(c *gin.Context) { + app.roomManager.DeleteRoom(c.Param("roomId")) + c.Status(http.StatusNoContent) +} + +func (app *App) screenshareJoinActive(c *gin.Context) { + uid := userID(c) + + roomID := app.roomManager.FindActiveRoom(uid) + if roomID == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "no active room"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roomId": roomID, + "clients": app.roomManager.GetClients(roomID), + "iceServers": app.cfg.ICEServers, + }) +} + +func (app *App) screenshareKeepalive(c *gin.Context) { + app.roomManager.Keepalive(c.Param("roomId")) + c.Status(http.StatusOK) +} + +func (app *App) screenshareGetRoom(c *gin.Context) { + roomID := c.Param("roomId") + + room := app.roomManager.GetRoom(roomID) + if room == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roomId": room.RoomID, + "createdAt": room.CreatedAt.Format(time.RFC3339Nano), + "clients": app.roomManager.GetClients(roomID), + }) +} + +func (app *App) screenshareBroadcast(c *gin.Context) { + roomID := c.Param("roomId") + deviceID := c.GetString(deviceIDKey) + + if !app.roomManager.RoomExists(roomID) { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + badReq(c, "invalid body") + return + } + + log.Debugf("Screenshare broadcast: room=%s from=%s payload=%s", roomID, deviceID, string(body)) + + app.roomManager.AddBroadcast(roomID, deviceID, body) + c.Status(http.StatusOK) +} + +func (app *App) screenshareDirect(c *gin.Context) { + roomID := c.Param("roomId") + deviceID := c.GetString(deviceIDKey) + + if !app.roomManager.RoomExists(roomID) { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + + var msg struct { + Payload json.RawMessage `json:"payload"` + TargetClientID string `json:"targetClientId"` + } + if err := c.ShouldBindJSON(&msg); err != nil { + badReq(c, "invalid body") + return + } + + log.Debugf("Screenshare direct: room=%s from=%s to=%s", roomID, deviceID, msg.TargetClientID) + + app.roomManager.AddDirect(roomID, deviceID, msg.TargetClientID, msg.Payload) + c.Status(http.StatusOK) +} + +func (app *App) handleMQTTWebSocket(c *gin.Context) { + upgrader := websocket.Upgrader{ + Subprotocols: []string{"mqtt"}, + CheckOrigin: func(r *http.Request) bool { + return true + }, + } + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Warnf("MQTT WebSocket upgrade failed: %v", err) + return + } + + err = app.mqttBroker.EstablishConnection("ws", mqttmod.NewWsConn(conn)) + if err != nil { + log.Warnf("MQTT WebSocket connection failed: %v", err) + } +} + // syncReports reports sync errors back func (app *App) syncReports(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) diff --git a/internal/app/hub/hub.go b/internal/app/hub/hub.go index 95731354..da4c262e 100644 --- a/internal/app/hub/hub.go +++ b/internal/app/hub/hub.go @@ -2,6 +2,7 @@ package hub import ( "encoding/base64" + "encoding/json" "strconv" "time" @@ -94,7 +95,6 @@ func (h *Hub) Notify(uid, deviceID string, doc DocumentNotification, eventType m msg: &msg, } } -// NotifyPasscodeReset pushes a PasscodeResetApproved event to every client of uid. func (h *Hub) NotifyPasscodeReset(uid, deviceID, deviceName, requestID string) { msgid := strconv.Itoa(int(time.Now().UnixNano())) msg := messages.WsMessage{ @@ -117,6 +117,40 @@ func (h *Hub) NotifyPasscodeReset(uid, deviceID, deviceName, requestID string) { } } +func (h *Hub) NotifyScreenshare(uid, fromClientID string, payload interface{}) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + log.Errorf("hub: failed to marshal screenshare payload: %v", err) + return + } + encoded := base64.StdEncoding.EncodeToString(payloadJSON) + + messageID := uuid.New().String() + timeStamp := time.Now().UTC().Format(time.RFC3339Nano) + + msg := messages.WsMessage{ + Message: messages.NotificationMessage{ + MessageID: messageID, + MessageID2: messageID, + MessageID3: messageID, + Attributes: messages.Attributes{ + Auth0UserID: uid, + Event: messages.ScreenshareMessageEvent, + SourceDeviceID: fromClientID, + }, + PublishTime: timeStamp, + PublishTime2: timeStamp, + Data: encoded, + }, + } + + h.notifications <- notification{ + uid: uid, + from: fromClientID, + msg: &msg, + } +} + func (h *Hub) send(n notification) { uid := n.uid msg := n.msg diff --git a/internal/app/routes.go b/internal/app/routes.go index 08e8e514..3457e4c1 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -82,11 +82,23 @@ func (app *App) registerRoutes(router *gin.Engine) { router.POST("/report/v1", app.nullReport) router.POST("/v2/events", app.nullReport) + if app.mqttBroker != nil { + router.GET("/mqtt", app.handleMQTTWebSocket) + } + //routes needing api authentitcation authRoutes := router.Group("/") authRoutes.Use(app.authMiddleware()) { + authRoutes.POST("/screenshare/v1/rooms", app.screenshareCreateRoom) + authRoutes.POST("/screenshare/v1/rooms/join-active", app.screenshareJoinActive) + authRoutes.GET("/screenshare/v1/rooms/:roomId", app.screenshareGetRoom) + authRoutes.DELETE("/screenshare/v1/rooms/:roomId", app.screenshareDeleteRoom) + authRoutes.POST("/screenshare/v1/rooms/:roomId/keepalive", app.screenshareKeepalive) + authRoutes.POST("/screenshare/v1/rooms/:roomId/messages/broadcast", app.screenshareBroadcast) + authRoutes.POST("/screenshare/v1/rooms/:roomId/messages/direct", app.screenshareDirect) + // document notifications authRoutes.GET("/notifications/ws/json/1", app.connectWebSocket) diff --git a/internal/config/config.go b/internal/config/config.go index 446b8b3a..9fe5766d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -249,6 +249,11 @@ func FromEnv() *Config { iceServers = nil } } + if len(iceServers) == 0 { + iceServers = []interface{}{ + map[string]string{"url": "stun:stun.l.google.com:19302", "username": "", "credential": ""}, + } + } hashSchemaVersion := os.Getenv(envHashSchemaVersion) if hashSchemaVersion == "" { diff --git a/internal/messages/messages.go b/internal/messages/messages.go index 5fe4ee06..eb13411f 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -19,8 +19,9 @@ const ( //SyncCompletedEvent sync completed sync15 SyncCompletedEvent NotificationType = "SyncComplete" - // PasscodeResetApprovedEvent passcode reset approved by the user PasscodeResetApprovedEvent NotificationType = "PasscodeResetApproved" + + ScreenshareMessageEvent NotificationType = "ScreenshareMessage" ) // BlobStorageRequest else diff --git a/internal/mqtt/broker.go b/internal/mqtt/broker.go index 23449d1f..8d9cfbb1 100644 --- a/internal/mqtt/broker.go +++ b/internal/mqtt/broker.go @@ -3,10 +3,15 @@ package mqtt import ( "crypto/tls" "encoding/json" + "errors" "fmt" + "io" + "net" "strings" - "sync" + "github.com/ddvk/rmfakecloud/internal/screenshare" + + "github.com/gorilla/websocket" mqtt "github.com/mochi-mqtt/server/v2" "github.com/mochi-mqtt/server/v2/listeners" "github.com/mochi-mqtt/server/v2/packets" @@ -14,19 +19,25 @@ import ( ) type Broker struct { - server *mqtt.Server - port string - tlsConfig *tls.Config - authHook *AuthHook - aclHook *ACLHook + server *mqtt.Server + port string + tlsConfig *tls.Config + authHook *AuthHook + aclHook *ACLHook + RoomManager *screenshare.RoomManager +} + +type NotificationHub interface { + NotifyScreenshare(uid, fromClientID string, payload interface{}) } type AuthHook struct { mqtt.HookBase validateToken func(token string) (userID string, err error) server *mqtt.Server - roomManager *RoomManager + roomManager *screenshare.RoomManager iceServers []interface{} + hub NotificationHub } type ACLHook struct { @@ -35,22 +46,10 @@ type ACLHook struct { type ConnectionHook struct { mqtt.HookBase - roomManager *RoomManager -} - -type RoomManager struct { - mu sync.RWMutex - rooms map[string]*ScreenshareRoom + roomManager *screenshare.RoomManager } -type ScreenshareRoom struct { - name string - participants map[string]*Participant - creatorUserID string - creatorDeviceID string -} - -type Participant struct { +type participant struct { clientID string userID string } @@ -66,109 +65,13 @@ type SignalingMessage struct { IceServers map[string]interface{} `json:"iceServers"` } -func NewRoomManager() *RoomManager { - return &RoomManager{ - rooms: make(map[string]*ScreenshareRoom), - } -} - -func (rm *RoomManager) GetOrCreateRoom(roomName, userID, deviceID string) *ScreenshareRoom { - rm.mu.Lock() - defer rm.mu.Unlock() - - room, exists := rm.rooms[roomName] - if !exists { - room = &ScreenshareRoom{ - name: roomName, - participants: make(map[string]*Participant), - creatorUserID: userID, - creatorDeviceID: deviceID, - } - rm.rooms[roomName] = room - log.Infof("MQTT: Created new screenshare room=%s user_id=%s device_id=%s", roomName, userID, deviceID) - } - return room -} - -func (rm *RoomManager) AddParticipant(roomName, clientID, userID string) bool { - room := rm.GetOrCreateRoom(roomName, userID, clientID) - wasNew := len(room.participants) == 0 - room.participants[clientID] = &Participant{ - clientID: clientID, - userID: userID, - } - log.Debugf("MQTT: Added participant to room=%s client_id=%s user_id=%s total_participants=%d", - roomName, clientID, userID, len(room.participants)) - return wasNew -} - -func (rm *RoomManager) RemoveParticipant(clientID string) (roomName, creatorUserID, creatorDeviceID string, wasLastParticipant bool) { - rm.mu.Lock() - defer rm.mu.Unlock() - - for rName, room := range rm.rooms { - if _, exists := room.participants[clientID]; exists { - delete(room.participants, clientID) - log.Debugf("MQTT: Removed participant from room=%s client_id=%s remaining=%d", - rName, clientID, len(room.participants)) - - if len(room.participants) == 0 { - roomName = rName - creatorUserID = room.creatorUserID - creatorDeviceID = room.creatorDeviceID - wasLastParticipant = true - delete(rm.rooms, rName) - log.Infof("MQTT: Removed empty room=%s", rName) - } - return - } - } - return -} - -func (rm *RoomManager) GetPeers(roomName, senderClientID string) []Participant { - rm.mu.RLock() - defer rm.mu.RUnlock() - - room, exists := rm.rooms[roomName] - if !exists { - return nil - } - - var peers []Participant - for clientID, participant := range room.participants { - if clientID != senderClientID { - peers = append(peers, *participant) - } - } - return peers -} - -func (rm *RoomManager) FindActiveRoom(userID string) string { - rm.mu.RLock() - defer rm.mu.RUnlock() - - for roomName := range rm.rooms { - return roomName - } - return "" -} - -func (rm *RoomManager) RoomExists(roomName string) bool { - rm.mu.RLock() - defer rm.mu.RUnlock() - - _, exists := rm.rooms[roomName] - return exists -} - -func NewBroker(port string, tlsConfig *tls.Config, validateToken func(token string) (string, error), iceServers []interface{}) *Broker { - roomManager := NewRoomManager() +func NewBroker(port string, tlsConfig *tls.Config, validateToken func(token string) (string, error), iceServers []interface{}, roomManager *screenshare.RoomManager, hub NotificationHub) *Broker { return &Broker{ - port: port, - tlsConfig: tlsConfig, - authHook: &AuthHook{validateToken: validateToken, roomManager: roomManager, iceServers: iceServers}, - aclHook: &ACLHook{}, + port: port, + tlsConfig: tlsConfig, + authHook: &AuthHook{validateToken: validateToken, roomManager: roomManager, iceServers: iceServers, hub: hub}, + aclHook: &ACLHook{}, + RoomManager: roomManager, } } @@ -196,6 +99,11 @@ func (h *ConnectionHook) OnPacketRead(cl *mqtt.Client, pk packets.Packet) (packe packetType = "SUBSCRIBE" case packets.Pingreq: packetType = "PINGREQ" + if h.roomManager != nil { + if roomID := h.roomManager.FindActiveRoom(string(cl.Properties.Username)); roomID != "" { + h.roomManager.Keepalive(roomID) + } + } case packets.Disconnect: packetType = "DISCONNECT" } @@ -227,6 +135,10 @@ func (h *ConnectionHook) OnDisconnect(cl *mqtt.Client, err error, expire bool) { } } +func (b *Broker) SetTLSConfig(tlsConfig *tls.Config) { + b.tlsConfig = tlsConfig +} + func (b *Broker) Start() error { b.server = mqtt.New(&mqtt.Options{ InlineClient: true, @@ -234,7 +146,7 @@ func (b *Broker) Start() error { b.authHook.server = b.server - connHook := &ConnectionHook{roomManager: b.authHook.roomManager} + connHook := &ConnectionHook{roomManager: b.RoomManager} if err := b.server.AddHook(connHook, nil); err != nil { return fmt.Errorf("failed to add connection hook: %w", err) } @@ -251,9 +163,7 @@ func (b *Broker) Start() error { if tlsConfig != nil { tlsConfig = tlsConfig.Clone() - // Force TLS 1.2 to work around desktop app TLS 1.3 issue - tlsConfig.MaxVersion = tls.VersionTLS12 - log.Infof("MQTT: Forcing TLS 1.2 maximum version") + tlsConfig.MinVersion = tls.VersionTLS12 originalGetCertificate := tlsConfig.GetCertificate tlsConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -308,6 +218,88 @@ func (b *Broker) Stop() error { return nil } +func (b *Broker) PublishSignaling(userID, clientID string, payload []byte) { + if b.server == nil { + return + } + topic := fmt.Sprintf("user/%s/client/%s/signaling/screenshare", userID, clientID) + b.server.Publish(topic, payload, false, 1) +} + +func (b *Broker) HasConnectedClient(userID string) bool { + if b.server == nil { + return false + } + prefix := userID + "-" + for _, cl := range b.server.Clients.GetAll() { + if string(cl.Properties.Username) == userID || strings.HasPrefix(cl.ID, prefix) { + return true + } + } + return false +} + +func (b *Broker) EstablishConnection(listenerID string, conn net.Conn) error { + if b.server == nil { + conn.Close() + return fmt.Errorf("MQTT broker not started") + } + return b.server.EstablishConnection(listenerID, conn) +} + +var ErrInvalidMessage = errors.New("message type not binary") + +type WsConn struct { + net.Conn + C *websocket.Conn + r io.Reader +} + +func NewWsConn(wsConn *websocket.Conn) *WsConn { + return &WsConn{Conn: wsConn.UnderlyingConn(), C: wsConn} +} + +func (ws *WsConn) Read(p []byte) (int, error) { + if ws.r == nil { + op, r, err := ws.C.NextReader() + if err != nil { + return 0, err + } + if op != websocket.BinaryMessage { + return 0, ErrInvalidMessage + } + ws.r = r + } + + var n int + for { + if n == len(p) { + return n, nil + } + br, err := ws.r.Read(p[n:]) + n += br + if err != nil { + ws.r = nil + if errors.Is(err, io.EOF) { + err = nil + } + return n, err + } + } +} + +func (ws *WsConn) Write(p []byte) (int, error) { + err := ws.C.WriteMessage(websocket.BinaryMessage, p) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (ws *WsConn) Close() error { + return ws.Conn.Close() +} + func (h *AuthHook) ID() string { return "rmfakecloud-auth" } @@ -406,69 +398,64 @@ func (h *AuthHook) handleSignalingMessage(senderClientID, userID string, msg *Si } func (h *AuthHook) handleCreateRoom(senderClientID, userID string, msg *SignalingMessage, qos byte) { - roomName := msg.Room - if roomName == "" { - roomName = "screenshare" + if h.roomManager == nil { + return } - if h.roomManager != nil { - h.roomManager.AddParticipant(roomName, senderClientID, userID) + if existing := h.roomManager.FindActiveRoom(userID); existing != "" { + h.roomManager.AddParticipant(existing, senderClientID, userID) + response := SignalingMessage{Type: "room-created", Room: msg.Room, RoomId: existing} + responseBytes, _ := json.Marshal(response) + broadcastTopic := fmt.Sprintf("user/%s/signaling", userID) + if h.server != nil { + h.server.Publish(broadcastTopic, responseBytes, false, qos) + } + return } + room := h.roomManager.CreateRoom(userID, senderClientID) + response := SignalingMessage{ Type: "room-created", - RoomId: roomName, + Room: msg.Room, + RoomId: room.RoomID, } broadcastTopic := fmt.Sprintf("user/%s/signaling", userID) responseBytes, err := json.Marshal(response) if err != nil { - log.Errorf("MQTT: Failed to marshal room-created response error=%v", err) + log.Errorf("MQTT: failed to marshal room-created: %v", err) return } - log.Debugf("MQTT: Broadcasting room-created to all clients topic=%s room=%s", broadcastTopic, roomName) - if h.server != nil { - err := h.server.Publish(broadcastTopic, responseBytes, false, qos) - if err != nil { - log.Errorf("MQTT: Failed to broadcast room-created error=%v", err) - } + h.server.Publish(broadcastTopic, responseBytes, false, qos) } } func (h *AuthHook) handleJoinRoom(senderClientID, userID string, msg *SignalingMessage, qos byte) { - roomName := msg.Room - if roomName == "" { - if h.roomManager != nil { - roomName = h.roomManager.FindActiveRoom(userID) - } - if roomName == "" { - roomName = "screenshare" - } + if h.roomManager == nil { + return } - if h.roomManager == nil || !h.roomManager.RoomExists(roomName) { - log.Infof("MQTT: Join room failed, room does not exist room=%s client_id=%s", roomName, senderClientID) - - response := SignalingMessage{ - Type: "room-not-found", - } - responseBytes, err := json.Marshal(response) - if err != nil { - log.Errorf("MQTT: Failed to marshal room-not-found response error=%v", err) - return - } + roomID := msg.RoomId + if roomID == "" { + roomID = h.roomManager.FindActiveRoom(userID) + } - responseTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", userID, senderClientID, roomName) + if roomID == "" || !h.roomManager.RoomExists(roomID) { + log.Infof("MQTT: room not found for join client=%s", senderClientID) + response := SignalingMessage{Type: "room-not-found"} + responseBytes, _ := json.Marshal(response) + responseTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", userID, senderClientID, roomID) if h.server != nil { h.server.Publish(responseTopic, responseBytes, false, qos) } return } - h.roomManager.AddParticipant(roomName, senderClientID, userID) + h.roomManager.AddParticipant(roomID, senderClientID, userID) iceServers := h.iceServers if iceServers == nil { @@ -477,27 +464,37 @@ func (h *AuthHook) handleJoinRoom(senderClientID, userID string, msg *SignalingM response := SignalingMessage{ Type: "room-joined", - RoomId: roomName, + RoomId: roomID, IceServers: map[string]interface{}{ - "iceServers": iceServers, + "ice_servers": iceServers, }, } - h.sendResponse(senderClientID, userID, roomName, &response, qos) + h.sendResponse(senderClientID, userID, roomID, &response, qos) } -func (h *AuthHook) handleBroadcast(senderClientID, userID string, msg *SignalingMessage, qos byte) { - roomName := msg.Room - if roomName == "" { - if h.roomManager != nil { - roomName = h.roomManager.FindActiveRoom(userID) +func (h *AuthHook) getPeers(roomID, senderClientID string) []participant { + clients := h.roomManager.GetClients(roomID) + var peers []participant + for _, c := range clients { + if c.ClientID != senderClientID { + peers = append(peers, participant{clientID: c.ClientID, userID: c.UserID}) } } + return peers +} - var peers []Participant - if h.roomManager != nil { - peers = h.roomManager.GetPeers(roomName, senderClientID) +func (h *AuthHook) handleBroadcast(senderClientID, userID string, msg *SignalingMessage, qos byte) { + if h.roomManager == nil { + return + } + + roomID := msg.RoomId + if roomID == "" { + roomID = h.roomManager.FindActiveRoom(userID) } + peers := h.getPeers(roomID, senderClientID) + broadcastMsg := map[string]interface{}{ "type": "broadcast", "clientId": senderClientID, @@ -506,38 +503,34 @@ func (h *AuthHook) handleBroadcast(senderClientID, userID string, msg *Signaling msgBytes, err := json.Marshal(broadcastMsg) if err != nil { - log.Errorf("MQTT: Failed to marshal broadcast message error=%v", err) + log.Errorf("MQTT: failed to marshal broadcast: %v", err) return } - log.Debugf("MQTT: Broadcasting message from client_id=%s to %d peers in room=%s", - senderClientID, len(peers), roomName) - for _, peer := range peers { - peerTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", peer.userID, peer.clientID, roomName) + peerTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", peer.userID, peer.clientID, roomID) if h.server != nil { - err := h.server.Publish(peerTopic, msgBytes, false, qos) - if err != nil { - log.Errorf("MQTT: Failed to broadcast to peer=%s error=%v", peer.clientID, err) - } else { - log.Debugf("MQTT: Broadcast sent to peer=%s", peer.clientID) - } + h.server.Publish(peerTopic, msgBytes, false, qos) } } + + + if roomID != "" { + payloadBytes, _ := json.Marshal(msg.Payload) + h.roomManager.AddBroadcast(roomID, senderClientID, payloadBytes) + } } func (h *AuthHook) handleDirect(senderClientID, userID string, msg *SignalingMessage, qos byte) { targetClientID := msg.ClientId if targetClientID == "" { - log.Warnf("MQTT: Direct message missing clientId from sender=%s", senderClientID) + log.Warnf("MQTT: direct message missing clientId from sender=%s", senderClientID) return } - roomName := msg.Room - if roomName == "" { - if h.roomManager != nil { - roomName = h.roomManager.FindActiveRoom(userID) - } + roomID := msg.RoomId + if roomID == "" && h.roomManager != nil { + roomID = h.roomManager.FindActiveRoom(userID) } directMsg := map[string]interface{}{ @@ -548,44 +541,32 @@ func (h *AuthHook) handleDirect(senderClientID, userID string, msg *SignalingMes msgBytes, err := json.Marshal(directMsg) if err != nil { - log.Errorf("MQTT: Failed to marshal direct message error=%v", err) + log.Errorf("MQTT: failed to marshal direct message: %v", err) return } - peerTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", userID, targetClientID, roomName) + peerTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", userID, targetClientID, roomID) + if h.server != nil { + h.server.Publish(peerTopic, msgBytes, false, qos) + } - log.Debugf("MQTT: Sending direct message from client_id=%s to peer=%s room=%s", - senderClientID, targetClientID, roomName) - if h.server != nil { - err := h.server.Publish(peerTopic, msgBytes, false, qos) - if err != nil { - log.Errorf("MQTT: Failed to send direct message to peer=%s error=%v", targetClientID, err) - } else { - log.Debugf("MQTT: Direct message sent to peer=%s", targetClientID) - } + if roomID != "" && h.roomManager != nil { + payloadBytes, _ := json.Marshal(msg.Payload) + h.roomManager.AddDirect(roomID, senderClientID, targetClientID, payloadBytes) } } -func (h *AuthHook) sendResponse(clientID, userID, roomName string, response *SignalingMessage, qos byte) { +func (h *AuthHook) sendResponse(clientID, userID, roomID string, response *SignalingMessage, qos byte) { responseBytes, err := json.Marshal(response) if err != nil { - log.Errorf("MQTT: Failed to marshal response error=%v", err) + log.Errorf("MQTT: failed to marshal response: %v", err) return } - responseTopic := fmt.Sprintf("user/%s/client/%s/signaling/%s", userID, clientID, roomName) - - log.Debugf("MQTT: Sending response type=%s to client_id=%s topic=%s", - response.Type, clientID, responseTopic) - + responseTopic := fmt.Sprintf("user/%s/client/%s/signaling/room/%s", userID, clientID, roomID) if h.server != nil { - err := h.server.Publish(responseTopic, responseBytes, false, qos) - if err != nil { - log.Errorf("MQTT: Failed to send response to client=%s error=%v", clientID, err) - } else { - log.Debugf("MQTT: Response sent successfully to client=%s", clientID) - } + h.server.Publish(responseTopic, responseBytes, false, qos) } } @@ -599,7 +580,11 @@ func (h *AuthHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet log.Debugf("MQTT: Publish payload: %s", string(pk.Payload)) } - // Pattern: remarkable/screenshare/signaling/user/{userId}/client/{clientId} + + if roomID := h.roomManager.FindActiveRoom(userID); roomID != "" { + h.roomManager.Keepalive(roomID) + } + if strings.HasPrefix(pk.TopicName, "remarkable/screenshare/signaling/user/") { parts := strings.Split(pk.TopicName, "/") if len(parts) >= 7 { @@ -619,6 +604,31 @@ func (h *AuthHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet } } + if cl.ID != "inline" && strings.HasPrefix(pk.TopicName, "user/") && strings.Contains(pk.TopicName, "/signaling") { + var msg SignalingMessage + if err := json.Unmarshal(pk.Payload, &msg); err == nil && (msg.Type == "direct" || msg.Type == "broadcast") { + senderClientID := msg.ClientId + if senderClientID == "" { + senderClientID = cl.ID + } + roomID := h.roomManager.FindActiveRoom(userID) + if roomID != "" { + payloadBytes, _ := json.Marshal(msg.Payload) + if msg.Type == "direct" { + targetClientID := msg.ClientId + + parts := strings.Split(pk.TopicName, "/") + if len(parts) >= 4 { + targetClientID = parts[3] + } + h.roomManager.AddDirect(roomID, senderClientID, targetClientID, payloadBytes) + } else { + h.roomManager.AddBroadcast(roomID, senderClientID, payloadBytes) + } + } + } + } + return pk, nil } diff --git a/internal/screenshare/rooms.go b/internal/screenshare/rooms.go new file mode 100644 index 00000000..9dff4e82 --- /dev/null +++ b/internal/screenshare/rooms.go @@ -0,0 +1,276 @@ +package screenshare + +import ( + "encoding/json" + "sync" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" +) + +type RoomManager struct { + mu sync.RWMutex + rooms map[string]*Room +} + +type Room struct { + RoomID string + CreatedAt time.Time + lastActivity time.Time + participants map[string]*RoomClient + ownerUserID string + messages []Message + notify chan struct{} +} + +type RoomClient struct { + ClientID string `json:"clientId"` + UserID string `json:"userId"` + IsOwner bool `json:"isOwner"` +} + +type Message struct { + Type string `json:"type"` + SenderClientID string `json:"clientId,omitempty"` + TargetClientID string `json:"targetClientId,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +const roomTimeout = 60 * time.Second + +func NewRoomManager() *RoomManager { + rm := &RoomManager{ + rooms: make(map[string]*Room), + } + go rm.expireLoop() + return rm +} + +func (rm *RoomManager) expireLoop() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for range ticker.C { + rm.mu.Lock() + now := time.Now().UTC() + for id, room := range rm.rooms { + if now.Sub(room.lastActivity) > roomTimeout { + delete(rm.rooms, id) + log.Infof("Screenshare: expired room=%s (no keepalive for %s)", id, roomTimeout) + } + } + rm.mu.Unlock() + } +} + +func (rm *RoomManager) Keepalive(roomID string) { + rm.mu.Lock() + defer rm.mu.Unlock() + if room, exists := rm.rooms[roomID]; exists { + room.lastActivity = time.Now().UTC() + } +} + +func (rm *RoomManager) CreateRoom(userID, deviceID string) *Room { + rm.mu.Lock() + defer rm.mu.Unlock() + + roomID := uuid.New().String() + now := time.Now().UTC() + room := &Room{ + RoomID: roomID, + CreatedAt: now, + lastActivity: now, + participants: make(map[string]*RoomClient), + ownerUserID: userID, + notify: make(chan struct{}, 1), + } + room.participants[deviceID] = &RoomClient{ + ClientID: deviceID, + UserID: userID, + IsOwner: true, + } + rm.rooms[roomID] = room + log.Infof("Screenshare: created room=%s user=%s device=%s", roomID, userID, deviceID) + return room +} + +func (rm *RoomManager) GetRoom(roomID string) *Room { + rm.mu.RLock() + defer rm.mu.RUnlock() + return rm.rooms[roomID] +} + +func (rm *RoomManager) GetClients(roomID string) []RoomClient { + rm.mu.RLock() + defer rm.mu.RUnlock() + + room, exists := rm.rooms[roomID] + if !exists { + return nil + } + clients := make([]RoomClient, 0, len(room.participants)) + for _, c := range room.participants { + clients = append(clients, *c) + } + return clients +} + +func (rm *RoomManager) AddBroadcast(roomID, senderClientID string, payload json.RawMessage) { + rm.mu.Lock() + defer rm.mu.Unlock() + + room, exists := rm.rooms[roomID] + if !exists { + return + } + // New broadcast clears old messages to prevent stale signaling on reconnect + room.messages = nil + room.messages = append(room.messages, Message{ + Type: "broadcast", + SenderClientID: senderClientID, + Payload: payload, + }) + log.Debugf("Screenshare: broadcast in room=%s from=%s", roomID, senderClientID) +} + +func (rm *RoomManager) AddDirect(roomID, senderClientID, targetClientID string, payload json.RawMessage) { + rm.mu.Lock() + defer rm.mu.Unlock() + + room, exists := rm.rooms[roomID] + if !exists { + return + } + room.messages = append(room.messages, Message{ + Type: "direct", + SenderClientID: senderClientID, + TargetClientID: targetClientID, + Payload: payload, + }) + select { + case room.notify <- struct{}{}: + default: + } + log.Debugf("Screenshare: direct in room=%s from=%s to=%s", roomID, senderClientID, targetClientID) +} + +func (rm *RoomManager) WaitForMessages(roomID string, after int, timeout time.Duration) []Message { + rm.mu.RLock() + room, exists := rm.rooms[roomID] + rm.mu.RUnlock() + if !exists { + return nil + } + + deadline := time.After(timeout) + for { + msgs := rm.GetMessages(roomID, after) + if len(msgs) > 0 { + // Wait briefly for additional messages (ICE candidates follow the offer) + time.Sleep(200 * time.Millisecond) + return rm.GetMessages(roomID, after) + } + select { + case <-room.notify: + case <-deadline: + return nil + } + } +} + +func (rm *RoomManager) GetMessages(roomID string, after int) []Message { + rm.mu.RLock() + defer rm.mu.RUnlock() + + room, exists := rm.rooms[roomID] + if !exists { + return nil + } + if after >= len(room.messages) { + return nil + } + msgs := make([]Message, len(room.messages)-after) + copy(msgs, room.messages[after:]) + return msgs +} + +func (rm *RoomManager) AddParticipant(roomID, clientID, userID string) { + rm.mu.Lock() + defer rm.mu.Unlock() + + room, exists := rm.rooms[roomID] + if !exists { + return + } + room.participants[clientID] = &RoomClient{ + ClientID: clientID, + UserID: userID, + IsOwner: false, + } + log.Debugf("Screenshare: added participant to room=%s client=%s user=%s total=%d", + roomID, clientID, userID, len(room.participants)) +} + +func (rm *RoomManager) RemoveParticipant(clientID string) { + rm.mu.Lock() + defer rm.mu.Unlock() + + for roomID, room := range rm.rooms { + if _, exists := room.participants[clientID]; exists { + delete(room.participants, clientID) + log.Debugf("Screenshare: removed participant from room=%s client=%s remaining=%d", + roomID, clientID, len(room.participants)) + + if len(room.participants) == 0 { + delete(rm.rooms, roomID) + log.Infof("Screenshare: removed empty room=%s", roomID) + } + return + } + } +} + +func (rm *RoomManager) DeleteAllForUser(userID string) { + rm.mu.Lock() + defer rm.mu.Unlock() + for id, room := range rm.rooms { + if room.ownerUserID == userID { + delete(rm.rooms, id) + log.Infof("Screenshare: deleted room=%s for user=%s", id, userID) + } + } +} + +func (rm *RoomManager) DeleteRoom(roomID string) { + rm.mu.Lock() + defer rm.mu.Unlock() + delete(rm.rooms, roomID) + log.Infof("Screenshare: deleted room=%s", roomID) +} + +func (rm *RoomManager) FindActiveRoom(userID string) string { + rm.mu.RLock() + defer rm.mu.RUnlock() + + var newest *Room + for _, room := range rm.rooms { + if room.ownerUserID == userID { + if newest == nil || room.CreatedAt.After(newest.CreatedAt) { + newest = room + } + } + } + if newest != nil { + return newest.RoomID + } + return "" +} + +func (rm *RoomManager) RoomExists(roomID string) bool { + rm.mu.RLock() + defer rm.mu.RUnlock() + + _, exists := rm.rooms[roomID] + return exists +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 3cc82b49..54c5b579 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -1,6 +1,7 @@ package ui import ( + "encoding/json" "errors" "fmt" "net/http" @@ -772,3 +773,124 @@ func (app *ReactAppWrapper) downloadThroughIntegration(c *gin.Context) { c.DataFromReader(http.StatusOK, size, "", response, nil) } + +func (app *ReactAppWrapper) screenshareJoinActive(c *gin.Context) { + uid := userID(c) + + roomID := app.roomManager.FindActiveRoom(uid) + if roomID == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "no active room"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roomId": roomID, + "clients": app.roomManager.GetClients(roomID), + "iceServers": app.cfg.ICEServers, + }) +} + +func (app *ReactAppWrapper) screenshareGetRoom(c *gin.Context) { + roomID := c.Param("roomId") + + room := app.roomManager.GetRoom(roomID) + if room == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roomId": room.RoomID, + "createdAt": room.CreatedAt.Format(time.RFC3339Nano), + "clients": app.roomManager.GetClients(roomID), + }) +} + +func (app *ReactAppWrapper) screenshareGetOffer(c *gin.Context) { + uid := userID(c) + clientID := c.GetString(browserIDContextKey) + + roomID := app.roomManager.FindActiveRoom(uid) + if roomID == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "no active room"}) + return + } + + app.roomManager.AddBroadcast(roomID, clientID, json.RawMessage(`{"type":"request-offer","clientId":"`+clientID+`"}`)) + + var inner map[string]interface{} + json.Unmarshal([]byte(`{"type":"request-offer","clientId":"`+clientID+`","sourceDeviceID":"`+clientID+`"}`), &inner) + app.h.NotifyScreenshare(uid, clientID, inner) + + if app.mqtt != nil && app.mqtt.HasConnectedClient(uid) { + clients := app.roomManager.GetClients(roomID) + for _, cl := range clients { + if cl.IsOwner { + mqttMsg, _ := json.Marshal(map[string]interface{}{ + "type": "broadcast", + "clientId": clientID, + "payload": json.RawMessage(`{"type":"request-offer","clientId":"` + clientID + `"}`), + }) + app.mqtt.PublishSignaling(uid, cl.ClientID, mqttMsg) + break + } + } + } + + msgs := app.roomManager.WaitForMessages(roomID, 1, 30*time.Second) + if msgs == nil { + c.JSON(http.StatusGatewayTimeout, gin.H{"error": "timeout waiting for offer"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roomId": roomID, + "messages": msgs, + "iceServers": app.cfg.ICEServers, + }) +} + +func (app *ReactAppWrapper) screenshareSendAnswer(c *gin.Context) { + roomID := c.Param("roomId") + clientID := c.GetString(browserIDContextKey) + uid := userID(c) + + if !app.roomManager.RoomExists(roomID) { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + + var msg struct { + Payload json.RawMessage `json:"payload"` + TargetClientID string `json:"targetClientId"` + } + if err := c.ShouldBindJSON(&msg); err != nil { + badReq(c, "invalid body") + return + } + + app.roomManager.AddDirect(roomID, clientID, msg.TargetClientID, msg.Payload) + + var inner map[string]interface{} + json.Unmarshal(msg.Payload, &inner) + inner["sourceDeviceID"] = clientID + app.h.NotifyScreenshare(uid, clientID, inner) + + if app.mqtt != nil && app.mqtt.HasConnectedClient(uid) { + mqttMsg, _ := json.Marshal(map[string]interface{}{ + "type": "direct", + "clientId": clientID, + "payload": json.RawMessage(msg.Payload), + }) + app.mqtt.PublishSignaling(uid, msg.TargetClientID, mqttMsg) + } + + c.Status(http.StatusAccepted) +} + +func (app *ReactAppWrapper) screenshareDeleteRoom(c *gin.Context) { + uid := userID(c) + app.roomManager.DeleteAllForUser(uid) + c.Status(http.StatusNoContent) +} + diff --git a/internal/ui/routes.go b/internal/ui/routes.go index 69edab9c..a463f7f2 100644 --- a/internal/ui/routes.go +++ b/internal/ui/routes.go @@ -86,6 +86,13 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { auth.GET("integrations/:intid/metadata/*path", app.getMetadataIntegration) auth.GET("integrations/:intid/download/*path", app.downloadThroughIntegration) + ss := auth.Group("screenshare") + ss.GET("room", app.screenshareJoinActive) + ss.GET("room/:roomId", app.screenshareGetRoom) + ss.GET("offer", app.screenshareGetOffer) + ss.POST("room/:roomId/answer", app.screenshareSendAnswer) + ss.DELETE("room/:roomId", app.screenshareDeleteRoom) + //admin admin := auth.Group("") admin.Use(app.adminMiddleware()) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 4367254d..6a7d6c98 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -12,6 +12,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/common" "github.com/ddvk/rmfakecloud/internal/config" "github.com/ddvk/rmfakecloud/internal/messages" + "github.com/ddvk/rmfakecloud/internal/screenshare" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/ddvk/rmfakecloud/internal/storage/models" "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" @@ -59,6 +60,11 @@ type notificationHub interface { Sync(uid string) error } +type mqttBridge interface { + PublishSignaling(userID, clientID string, payload []byte) + HasConnectedClient(userID string) bool +} + // ReactAppWrapper encapsulates an app type ReactAppWrapper struct { fs http.FileSystem @@ -69,6 +75,8 @@ type ReactAppWrapper struct { h *hub.Hub passcodeStore passcodestore.Store backends map[common.SyncVersion]backend + roomManager *screenshare.RoomManager + mqtt mqttBridge } // hack for serving index.html on / @@ -82,7 +90,9 @@ func New(cfg *config.Config, h *hub.Hub, pcStore passcodestore.Store, docHandler documentHandler, - blobHandler blobHandler) *ReactAppWrapper { + blobHandler blobHandler, + roomManager *screenshare.RoomManager, + mqttBroker mqttBridge) *ReactAppWrapper { sub, err := fs.Sub(webui.Assets, jsBuildFolder) if err != nil { @@ -108,6 +118,8 @@ func New(cfg *config.Config, common.Sync10: backend10, common.Sync15: backend15, }, + roomManager: roomManager, + mqtt: mqttBroker, } return &staticWrapper } diff --git a/ui/package.json b/ui/package.json index d65f1d7a..2025a310 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,8 @@ "bootstrap": "^5.3.3", "jwt-decode": "^4.0.0", "moment": "^2.30.1", + "mqtt": "^5.15.1", + "pako": "^2.1.0", "react": "^18.3.1", "react-arborist": "^3.4.0", "react-bootstrap": "^2.10.5", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 9a93ab68..0fb9dc52 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + mqtt: + specifier: ^5.15.1 + version: 5.15.1 + pako: + specifier: ^2.1.0 + version: 2.1.0 react: specifier: ^18.3.1 version: 18.3.1 react-arborist: specifier: ^3.4.0 - version: 3.4.0(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.4.0(@types/node@25.5.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-bootstrap: specifier: ^2.10.5 version: 2.10.5(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -59,7 +65,7 @@ importers: version: 18.3.1 '@vitejs/plugin-react-swc': specifier: ^3.7.1 - version: 3.7.1(@swc/helpers@0.5.15)(vite@5.4.19(sass-embedded@1.81.0)(sass@1.77.6)) + version: 3.7.1(@swc/helpers@0.5.15)(vite@5.4.19(@types/node@25.5.0)(sass-embedded@1.81.0)(sass@1.77.6)) eslint: specifier: ^9.15.0 version: 9.15.0 @@ -83,7 +89,7 @@ importers: version: 8.15.0(eslint@9.15.0)(typescript@5.6.3) vite: specifier: ^5.4.19 - version: 5.4.19(sass-embedded@1.81.0)(sass@1.77.6) + version: 5.4.19(@types/node@25.5.0)(sass-embedded@1.81.0)(sass@1.77.6) packages: @@ -91,6 +97,10 @@ packages: resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@bufbuild/protobuf@2.2.2': resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==} @@ -365,56 +375,67 @@ packages: resolution: {integrity: sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.1': resolution: {integrity: sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.1': resolution: {integrity: sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.1': resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.1': resolution: {integrity: sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.1': resolution: {integrity: sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.1': resolution: {integrity: sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.1': resolution: {integrity: sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.1': resolution: {integrity: sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.1': resolution: {integrity: sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.1': resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.1': resolution: {integrity: sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==} @@ -454,24 +475,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.9.2': resolution: {integrity: sha512-8xzrOmsyCC1zrx2Wzx/h8dVsdewO1oMCwBTLc1gSJ/YllZYTb04pNm6NsVbzUX2tKddJVRgSJXV10j/NECLwpA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.9.2': resolution: {integrity: sha512-kZrNz/PjRQKcchWF6W292jk3K44EoVu1ad5w+zbS4jekIAxsM8WwQ1kd+yjUlN9jFcF8XBat5NKIs9WphJCVXg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.9.2': resolution: {integrity: sha512-TTIpR4rjMkhX1lnFR+PSXpaL83TrQzp9znRdp2TzYrODlUd/R20zOwSo9vFLCyH6ZoD47bccY7QeGZDYT3nlRg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.9.2': resolution: {integrity: sha512-+Eg2d4icItKC0PMjZxH7cSYFLWk0aIp94LNmOw6tPq0e69ax6oh10upeq0D1fjWsKLmOJAWEvnXlayZcijEXDw==} @@ -529,6 +554,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -541,9 +569,15 @@ packages: '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/warning@3.0.3': resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.15.0': resolution: {integrity: sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -614,6 +648,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -661,10 +699,16 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + bootstrap@5.3.3: resolution: {integrity: sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==} peerDependencies: @@ -680,9 +724,18 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + broker-factory@3.1.14: + resolution: {integrity: sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==} + buffer-builder@0.2.0: resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -724,9 +777,16 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + commist@3.2.0: + resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -746,6 +806,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@4.2.1: resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} engines: {node: '>=8'} @@ -835,6 +904,14 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -848,6 +925,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-unique-numbers@9.0.27: + resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==} + engines: {node: '>=18.2.0'} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -921,6 +1002,9 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} @@ -931,6 +1015,9 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -959,6 +1046,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -985,6 +1076,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-sdsl@4.3.0: + resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1023,6 +1117,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + make-cancellable-promise@1.3.2: resolution: {integrity: sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==} @@ -1063,6 +1160,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -1083,6 +1183,14 @@ packages: moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + mqtt-packet@9.0.2: + resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==} + + mqtt@5.15.1: + resolution: {integrity: sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==} + engines: {node: '>=16.0.0'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1119,6 +1227,9 @@ packages: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} deprecated: This package is no longer supported. + number-allocator@1.0.14: + resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1138,6 +1249,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1180,6 +1294,13 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prop-types-extra@1.1.1: resolution: {integrity: sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==} peerDependencies: @@ -1298,6 +1419,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -1322,6 +1447,9 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -1503,10 +1631,22 @@ packages: simple-get@3.1.1: resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1568,6 +1708,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.15.0: resolution: {integrity: sha512-wY4FRGl0ZI+ZU4Jo/yjdBu0lVTSML58pu6PgGtJmCufvzfV565pUF6iACQt092uFOd49iLOTX/sEVmHtbSrS+w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1593,6 +1736,9 @@ packages: peerDependencies: react: '>=16.14.0' + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1662,9 +1808,33 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + worker-factory@7.0.49: + resolution: {integrity: sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==} + + worker-timers-broker@8.0.16: + resolution: {integrity: sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==} + + worker-timers-worker@9.0.14: + resolution: {integrity: sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==} + + worker-timers@8.0.31: + resolution: {integrity: sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -1678,6 +1848,8 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.29.2': {} + '@bufbuild/protobuf@2.2.2': {} '@esbuild/aix-ppc64@0.21.5': @@ -1992,6 +2164,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + '@types/prop-types@15.7.13': {} '@types/react-dom@18.3.1': @@ -2007,8 +2183,16 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 + '@types/readable-stream@4.0.23': + dependencies: + '@types/node': 25.5.0 + '@types/warning@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.0 + '@typescript-eslint/eslint-plugin@8.15.0(@typescript-eslint/parser@8.15.0(eslint@9.15.0)(typescript@5.6.3))(eslint@9.15.0)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2091,16 +2275,20 @@ snapshots: '@typescript-eslint/types': 8.15.0 eslint-visitor-keys: 4.2.0 - '@vitejs/plugin-react-swc@3.7.1(@swc/helpers@0.5.15)(vite@5.4.19(sass-embedded@1.81.0)(sass@1.77.6))': + '@vitejs/plugin-react-swc@3.7.1(@swc/helpers@0.5.15)(vite@5.4.19(@types/node@25.5.0)(sass-embedded@1.81.0)(sass@1.77.6))': dependencies: '@swc/core': 1.9.2(@swc/helpers@0.5.15) - vite: 5.4.19(sass-embedded@1.81.0)(sass@1.77.6) + vite: 5.4.19(@types/node@25.5.0)(sass-embedded@1.81.0)(sass@1.77.6) transitivePeerDependencies: - '@swc/helpers' abbrev@1.1.1: optional: true + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -2149,9 +2337,18 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + binary-extensions@2.3.0: optional: true + bl@6.1.6: + dependencies: + '@types/readable-stream': 4.0.23 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.7.0 + bootstrap@5.3.3(@popperjs/core@2.11.8): dependencies: '@popperjs/core': 2.11.8 @@ -2169,8 +2366,22 @@ snapshots: dependencies: fill-range: 7.1.1 + broker-factory@3.1.14: + dependencies: + '@babel/runtime': 7.29.2 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + worker-factory: 7.0.49 + buffer-builder@0.2.0: {} + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + callsites@3.1.0: {} canvas@2.11.2: @@ -2219,8 +2430,17 @@ snapshots: colorjs.io@0.5.2: {} + commist@3.2.0: {} + concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + console-control-strings@1.1.0: optional: true @@ -2236,6 +2456,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decompress-response@4.2.1: dependencies: mimic-response: 2.1.0 @@ -2367,6 +2591,10 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events@3.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.2: @@ -2381,6 +2609,11 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-unique-numbers@9.0.27: + dependencies: + '@babel/runtime': 7.29.2 + tslib: 2.8.1 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -2462,6 +2695,8 @@ snapshots: has-unicode@2.0.1: optional: true + help-me@5.0.0: {} + history@4.10.1: dependencies: '@babel/runtime': 7.26.0 @@ -2483,6 +2718,8 @@ snapshots: - supports-color optional: true + ieee754@1.2.1: {} + ignore@5.3.2: {} immutable@4.3.7: @@ -2503,13 +2740,14 @@ snapshots: wrappy: 1.0.2 optional: true - inherits@2.0.4: - optional: true + inherits@2.0.4: {} invariant@2.2.4: dependencies: loose-envify: 1.4.0 + ip-address@10.1.0: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -2530,6 +2768,8 @@ snapshots: isexe@2.0.0: {} + js-sdsl@4.3.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -2563,6 +2803,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + make-cancellable-promise@1.3.2: {} make-dir@3.1.0: @@ -2596,6 +2838,8 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimist@1.2.8: {} + minipass@3.3.6: dependencies: yallist: 4.0.0 @@ -2615,6 +2859,37 @@ snapshots: moment@2.30.1: {} + mqtt-packet@9.0.2: + dependencies: + bl: 6.1.6 + debug: 4.4.3 + process-nextick-args: 2.0.1 + transitivePeerDependencies: + - supports-color + + mqtt@5.15.1: + dependencies: + '@types/readable-stream': 4.0.23 + '@types/ws': 8.18.1 + commist: 3.2.0 + concat-stream: 2.0.0 + debug: 4.4.3 + help-me: 5.0.0 + lru-cache: 10.4.3 + minimist: 1.2.8 + mqtt-packet: 9.0.2 + number-allocator: 1.0.14 + readable-stream: 4.7.0 + rfdc: 1.4.1 + socks: 2.8.7 + split2: 4.2.0 + worker-timers: 8.0.31 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + ms@2.1.3: {} nan@2.22.0: @@ -2645,6 +2920,13 @@ snapshots: set-blocking: 2.0.0 optional: true + number-allocator@1.0.14: + dependencies: + debug: 4.4.3 + js-sdsl: 4.3.0 + transitivePeerDependencies: + - supports-color + object-assign@4.1.1: {} once@1.4.0: @@ -2669,6 +2951,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2707,6 +2991,10 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + prop-types-extra@1.1.1(react@18.3.1): dependencies: react: 18.3.1 @@ -2723,10 +3011,10 @@ snapshots: queue-microtask@1.2.3: {} - react-arborist@3.4.0(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-arborist@3.4.0(@types/node@25.5.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dnd: 14.0.5(@types/react@18.3.12)(react@18.3.1) + react-dnd: 14.0.5(@types/node@25.5.0)(@types/react@18.3.12)(react@18.3.1) react-dnd-html5-backend: 14.1.0 react-dom: 18.3.1(react@18.3.1) react-window: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2760,7 +3048,7 @@ snapshots: dependencies: dnd-core: 14.0.1 - react-dnd@14.0.5(@types/react@18.3.12)(react@18.3.1): + react-dnd@14.0.5(@types/node@25.5.0)(@types/react@18.3.12)(react@18.3.1): dependencies: '@react-dnd/invariant': 2.0.0 '@react-dnd/shallowequal': 2.0.0 @@ -2769,6 +3057,7 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 18.3.1 optionalDependencies: + '@types/node': 25.5.0 '@types/react': 18.3.12 react-dom@18.3.1(react@18.3.1): @@ -2865,7 +3154,14 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - optional: true + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 readdirp@3.6.0: dependencies: @@ -2886,6 +3182,8 @@ snapshots: reusify@1.0.4: {} + rfdc@1.4.1: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -2925,8 +3223,7 @@ snapshots: dependencies: tslib: 2.8.1 - safe-buffer@5.2.1: - optional: true + safe-buffer@5.2.1: {} sass-embedded-android-arm64@1.81.0: optional: true @@ -3058,8 +3355,17 @@ snapshots: simple-concat: 1.0.1 optional: true + smart-buffer@4.2.0: {} + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3070,7 +3376,6 @@ snapshots: string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - optional: true strip-ansi@6.0.1: dependencies: @@ -3124,6 +3429,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typedarray@0.0.6: {} + typescript-eslint@8.15.0(eslint@9.15.0)(typescript@5.6.3): dependencies: '@typescript-eslint/eslint-plugin': 8.15.0(@typescript-eslint/parser@8.15.0(eslint@9.15.0)(typescript@5.6.3))(eslint@9.15.0)(typescript@5.6.3) @@ -3149,6 +3456,8 @@ snapshots: dependencies: react: 18.3.1 + undici-types@7.18.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3157,19 +3466,19 @@ snapshots: dependencies: react: 18.3.1 - util-deprecate@1.0.2: - optional: true + util-deprecate@1.0.2: {} value-equal@1.0.1: {} varint@6.0.0: {} - vite@5.4.19(sass-embedded@1.81.0)(sass@1.77.6): + vite@5.4.19(@types/node@25.5.0)(sass-embedded@1.81.0)(sass@1.77.6): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.44.1 optionalDependencies: + '@types/node': 25.5.0 fsevents: 2.3.3 sass: 1.77.6 sass-embedded: 1.81.0 @@ -3198,9 +3507,38 @@ snapshots: word-wrap@1.2.5: {} + worker-factory@7.0.49: + dependencies: + '@babel/runtime': 7.29.2 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + + worker-timers-broker@8.0.16: + dependencies: + '@babel/runtime': 7.29.2 + broker-factory: 3.1.14 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + worker-timers-worker: 9.0.14 + + worker-timers-worker@9.0.14: + dependencies: + '@babel/runtime': 7.29.2 + tslib: 2.8.1 + worker-factory: 7.0.49 + + worker-timers@8.0.31: + dependencies: + '@babel/runtime': 7.29.2 + tslib: 2.8.1 + worker-timers-broker: 8.0.16 + worker-timers-worker: 9.0.14 + wrappy@1.0.2: optional: true + ws@8.20.0: {} + yallist@4.0.0: optional: true diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 8a2d0f3b..57e40ec2 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -16,6 +16,7 @@ import Documents from "./pages/Documents"; import Integrations from "./pages/Integrations"; import Profile from "./pages/Profile"; import Admin from "./pages/Admin"; +import ScreenShare from "./pages/ScreenShare"; import NoMatch from "./pages/404"; import "react-toastify/dist/ReactToastify.css"; @@ -50,6 +51,7 @@ export default function App() { + diff --git a/ui/src/components/Navigation.jsx b/ui/src/components/Navigation.jsx index 4607efc5..9cdc3fcc 100644 --- a/ui/src/components/Navigation.jsx +++ b/ui/src/components/Navigation.jsx @@ -42,6 +42,11 @@ const NavigationBar = () => { Connect + + + + Screen Share + { isAdmin() && diff --git a/ui/src/pages/ScreenShare/index.jsx b/ui/src/pages/ScreenShare/index.jsx new file mode 100644 index 00000000..de97146e --- /dev/null +++ b/ui/src/pages/ScreenShare/index.jsx @@ -0,0 +1,668 @@ + +import { useState, useEffect, useRef, useCallback } from "react"; +import { Container, Alert, Spinner, Button } from "react-bootstrap"; +import { BsGearFill, BsFullscreenExit, BsArrowCounterclockwise, BsArrowClockwise } from "react-icons/bs"; +import { Inflate } from "pako"; +import constants from "../../common/constants"; + +const STATUS = { + WAITING: "waiting", + CONNECTING: "connecting", + STREAMING: "streaming", + ERROR: "error", +}; + +const BACKDROP_PRESETS = { + white: "#FFFFFF", + "off-white": "#F9F6F1", + gray: "#2D2D2D", + black: "#000000", +}; + +function getBackdrop() { + return localStorage.getItem("screenshare-backdrop") || "black"; +} + +function setBackdropPref(val) { + localStorage.setItem("screenshare-backdrop", val); +} + +function getCustomColor() { + return localStorage.getItem("screenshare-custom-color") || "#808080"; +} + +function setCustomColorPref(val) { + localStorage.setItem("screenshare-custom-color", val); +} + +function resolveBackdrop(pref) { + return BACKDROP_PRESETS[pref] || pref; +} + +function isLightColor(hex) { + const c = hex.replace("#", ""); + const r = parseInt(c.substring(0, 2), 16); + const g = parseInt(c.substring(2, 4), 16); + const b = parseInt(c.substring(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 > 128; +} + +function api(path, opts = {}) { + return fetch(`${constants.ROOT_URL}/screenshare/${path}`, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); +} + +export default function ScreenShare() { + const [status, setStatus] = useState(STATUS.WAITING); + const [errorMsg, setErrorMsg] = useState(""); + const [poppedOut, setPoppedOut] = useState(false); + const [manualRotation, setManualRotation] = useState(0); + const [backdrop, setBackdrop] = useState(getBackdrop); + const [customColor, setCustomColor] = useState(getCustomColor); + const [showControls, setShowControls] = useState(false); + const [pinnedPosition, setPinnedPosition] = useState(null); + const [controlsPosition, setControlsPosition] = useState("right"); + const controlsRef = useRef(null); + const manualRotationRef = useRef(0); + + useEffect(() => { + if (!showControls) return; + const handler = (e) => { + if (controlsRef.current && !controlsRef.current.contains(e.target)) { + setShowControls(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showControls]); + + useEffect(() => { + if (!poppedOut) return; + const check = () => { + const canvas = videoRef.current; + if (!canvas || !canvas.width || !canvas.height) return; + const rot = ((manualRotation % 360) + 360) % 360; + const isRotated = rot % 180 !== 0; + const aspect = isRotated ? canvas.height / canvas.width : canvas.width / canvas.height; + const vpAspect = (window.innerWidth - 50) / window.innerHeight; + setControlsPosition(aspect < vpAspect ? "right" : "top"); + }; + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, [poppedOut, manualRotation]); + const videoRef = useRef(null); + const pcRef = useRef(null); + const dcRef = useRef(null); + const roomIdRef = useRef(null); + const tabletClientIdRef = useRef(null); + + const cleanup = useCallback(() => { + dcRef.current = null; + if (pcRef.current) { + pcRef.current.close(); + pcRef.current = null; + } + roomIdRef.current = null; + }, []); + + const setupPeerConnection = useCallback( + (iceServers) => { + if (pcRef.current) pcRef.current.close(); + + const config = {}; + if (iceServers?.length) { + config.iceServers = iceServers.map((s) => ({ + urls: s.url || s.urls, + username: s.username, + credential: s.credential, + })); + } + + const pc = new RTCPeerConnection(config); + pcRef.current = pc; + + pc.ondatachannel = (event) => { + const dc = event.channel; + dc.binaryType = "arraybuffer"; + dcRef.current = dc; + + let screenWidth = 0; + let screenHeight = 0; + const canvas = videoRef.current; + let ctx = null; + let lastPenX = 0; + let lastPenY = 0; + let rotation = 0; + const frameQueue = []; + let rafPending = false; + let pendingBuffer = null; + + function updateCursor() { + const cursor = document.getElementById("pen-cursor"); + if (!cursor || !canvas) return; + if (lastPenX === 0 && lastPenY === 0) { + cursor.style.display = "none"; + return; + } + const rect = canvas.getBoundingClientRect(); + const cw = canvas.width; + const ch = canvas.height; + const rot = ((manualRotationRef.current % 360) + 360) % 360; + const isLandscape = rot === 90 || rot === 270; + const renderedW = isLandscape ? rect.height : rect.width; + const renderedH = isLandscape ? rect.width : rect.height; + const scaleX = renderedW / cw; + const scaleY = renderedH / ch; + + const cx = (lastPenX - cw / 2) * scaleX; + const cy = (lastPenY - ch / 2) * scaleY; + + const rad = rot * Math.PI / 180; + const rx = cx * Math.cos(rad) - cy * Math.sin(rad); + const ry = cx * Math.sin(rad) + cy * Math.cos(rad); + + const screenX = rect.left + rect.width / 2 + rx; + const screenY = rect.top + rect.height / 2 + ry; + + cursor.style.display = "block"; + cursor.style.left = (screenX - 4) + "px"; + cursor.style.top = (screenY - 4) + "px"; + } + + dc.onopen = () => { + const header = new TextEncoder().encode("reMarkable"); + const buf = new ArrayBuffer(header.length + 2); + new Uint8Array(buf).set(header); + dc.send(buf); + }; + + dc.onmessage = (e) => { + const data = e.data; + if (!(data instanceof ArrayBuffer)) return; + const bytes = new Uint8Array(data); + if (bytes.length === 0) return; + + const msgType = bytes[0]; + + // 'h' = header with screen dimensions + if (msgType === 0x68 && bytes.length >= 7) { + const view = new DataView(data); + screenWidth = view.getUint16(3, false); + screenHeight = view.getUint16(5, false); + if (canvas) { + canvas.width = screenWidth; + canvas.height = screenHeight; + ctx = canvas.getContext("2d", {willReadFrequently: true}); + } + setStatus(STATUS.STREAMING); + return; + } + + // 'g' = heartbeat, 'f' = flag + if (msgType === 0x67) return; + + if (msgType === 0x66 && bytes.length >= 5) { + const rv = new DataView(data, 1); + const rawDeg = rv.getUint32(0, false); + const newRotation = (360 - rawDeg) % 360; + if (newRotation !== rotation) { + const prev = rotation; + rotation = newRotation; + setManualRotation(r => { + const target = r + ((newRotation - prev + 540) % 360 - 180); + return target; + }); + } + return; + } + + // 'd' = pen position (5 bytes: [0x64, x_hi, x_lo, y_hi, y_lo]) + if (msgType === 0x64 && bytes.length === 5 && ctx) { + const penX = (bytes[1] << 8) | bytes[2]; + const penY = (bytes[3] << 8) | bytes[4]; + lastPenX = penX; + lastPenY = penY; + updateCursor(); + return; + } + + // frame data: [type(1), rectCount(2), deflatedSize(4), zlib...] + if (msgType === 0x00 && bytes.length > 7 && ctx) { + + const declaredSize = new DataView(data, 3, 4).getUint32(0, false); + const expectedLen = 7 + declaredSize; + let frameBytes; + if (bytes.length < expectedLen) { + pendingBuffer = new Uint8Array(bytes); + return; + } else if (pendingBuffer) { + const combined = new Uint8Array(pendingBuffer.length + bytes.length); + combined.set(pendingBuffer); + combined.set(bytes, pendingBuffer.length); + pendingBuffer = null; + frameBytes = combined; + } else { + frameBytes = new Uint8Array(bytes); + } + + frameQueue.push(frameBytes); + if (!rafPending) { + rafPending = true; + requestAnimationFrame(() => { + rafPending = false; + while (frameQueue.length > 0) { + const frame = frameQueue.shift(); + try { + const rectCount = (frame[1] << 8) | frame[2]; + const inf = new Inflate({chunkSize: 64}); + inf.push(frame.subarray(7), true); + let totalLen = 0; + for (const c of inf.chunks) totalLen += c.length; + const remainder = inf.strm.next_out > 0 + ? inf.strm.output.subarray(0, inf.strm.next_out) : null; + if (remainder) totalLen += remainder.length; + if (totalLen < 12) continue; + const raw = new Uint8Array(totalLen); + let off = 0; + for (const c of inf.chunks) { raw.set(c, off); off += c.length; } + if (remainder) { raw.set(remainder, off); off += remainder.length; } + + let pos = 0; + for (let ri = 0; ri < rectCount && pos + 12 <= raw.length; ri++) { + const rv = new DataView(raw.buffer, raw.byteOffset + pos, raw.byteLength - pos); + const regionX = rv.getUint16(0, false); + const regionY = rv.getUint16(2, false); + const regionW = rv.getUint16(4, false); + const regionH = rv.getUint16(6, false); + const pxDataLen = rv.getUint32(8, false); + const pixelData = raw.subarray(pos + 12, pos + 12 + pxDataLen); + pos += 12 + pxDataLen; + + const pixels = regionW * regionH; + if (!pixels) continue; + + if (regionX === 0 && regionY === 0 && + regionW >= screenWidth * 0.9 && regionH >= screenHeight * 0.9) { + screenWidth = regionW; + screenHeight = regionH; + canvas.width = screenWidth; + canvas.height = screenHeight; + } + + + const imgData = ctx.createImageData(regionW, regionH); + const out = imgData.data; + const availPixels = Math.min(pixels, Math.floor(pixelData.length / 2)); + for (let i = 0; i < availPixels; i++) { + const val = pixelData[i * 2] | (pixelData[i * 2 + 1] << 8); + const r5 = (val >> 11) & 0x1f; + const g6 = (val >> 5) & 0x3f; + const b5 = val & 0x1f; + out[i * 4] = (r5 << 3) | (r5 >> 2); + out[i * 4 + 1] = (g6 << 2) | (g6 >> 4); + out[i * 4 + 2] = (b5 << 3) | (b5 >> 2); + out[i * 4 + 3] = 255; + } + ctx.putImageData(imgData, regionX, regionY); + } + + } catch (e) { + console.error("[screenshare] frame error:", e); + } + } + }); + } + } + }; + + dc.onclose = () => { + if (!disconnectedRef.current) { + setStatus(STATUS.WAITING); + } + }; + }; + + // Don't trickle ICE candidates. The tablet crashes on mDNS candidates. + // The SDP answer already contains our ICE info for LAN connectivity. + pc.onicecandidate = () => {}; + + pc.oniceconnectionstatechange = () => + + pc.onconnectionstatechange = () => { + + if ( + pc.connectionState === "failed" || + pc.connectionState === "disconnected" + ) { + cleanup(); + if (!disconnectedRef.current) { + setStatus(STATUS.WAITING); + } + } + }; + + return pc; + }, + [cleanup] + ); + + const joinRoom = useCallback(async () => { + try { + setStatus(STATUS.CONNECTING); + + const offerRes = await api("offer"); + if (!offerRes.ok) throw new Error("Failed to get offer from device"); + + const data = await offerRes.json(); + roomIdRef.current = data.roomId; + + const pc = setupPeerConnection(data.iceServers); + + const msgs = data.messages || []; + const offerMsg = msgs.find(m => { + let p = m.payload; + if (p && p.type === "webtrc" && p.payload) p = p.payload; + return p && p.type === "offer"; + }); + + if (!offerMsg) throw new Error("No offer received from device"); + + if (offerMsg.clientId) tabletClientIdRef.current = offerMsg.clientId; + let offerPayload = offerMsg.payload; + if (offerPayload.type === "webtrc") offerPayload = offerPayload.payload; + + const sdp = offerPayload.description || offerPayload.sdp; + await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp })); + + for (const msg of msgs) { + let inner = msg.payload; + if (!inner) continue; + if (inner.type === "webtrc" && inner.payload) inner = inner.payload; + if (inner.type === "candidate") { + await pc.addIceCandidate(new RTCIceCandidate({ + candidate: inner.candidate, + sdpMid: inner.mid || "0", + })); + } + } + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + // Wait for ICE gathering to complete so relay candidates are in the SDP + if (pc.iceGatheringState !== "complete") { + await new Promise((resolve) => { + const check = () => { + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", check); + resolve(); + } + }; + pc.addEventListener("icegatheringstatechange", check); + setTimeout(resolve, 5000); + }); + } + + await api(`room/${data.roomId}/answer`, { + method: "POST", + body: JSON.stringify({ + targetClientId: offerMsg.clientId, + payload: { + type: "webtrc", + payload: { type: "answer", description: pc.localDescription.sdp }, + }, + }), + }); + } catch (e) { + setErrorMsg(e.message); + setStatus(STATUS.ERROR); + } + }, [setupPeerConnection]); + + useEffect(() => { + if (status !== STATUS.WAITING) return; + + const check = setInterval(async () => { + const r = await api("room").catch(() => null); + if (r?.ok) { + clearInterval(check); + joinRoom(); + } + }, 2000); + + return () => clearInterval(check); + }, [status, joinRoom]); + + useEffect(() => { manualRotationRef.current = manualRotation; }, [manualRotation]); + useEffect(() => cleanup, [cleanup]); + + const disconnectedRef = useRef(false); + + const disconnect = () => { + if (dcRef.current && dcRef.current.readyState === "open") { + const buf = new ArrayBuffer(4); + new DataView(buf).setInt32(0, 0x65, false); + dcRef.current.send(buf); + } + cleanup(); + setPoppedOut(false); + setShowControls(false); + disconnectedRef.current = true; + setStatus(STATUS.ERROR); + setErrorMsg("Disconnected. Start a new screenshare session from the tablet to reconnect."); + }; + + const reconnect = () => { + disconnectedRef.current = false; + setStatus(STATUS.WAITING); + }; + + + return ( + + {status === STATUS.ERROR && ( + + {errorMsg} + {disconnectedRef.current && ( + + )} + + )} + + {status === STATUS.WAITING && ( + + + Waiting for reMarkable to start screen sharing... + + )} + + {status === STATUS.CONNECTING && ( + + + Connecting to reMarkable... + + )} + +
+ {(() => { + const isRotated = ((manualRotation % 360) + 360) % 360 % 180 !== 0; + const canvasStyle = { + background: "#000", + borderRadius: poppedOut ? 0 : "4px", + transform: `rotate(${manualRotation}deg)`, + transition: "transform 0.3s ease", + }; + if (poppedOut) { + const pad = controlsPosition === "right" ? 56 : 0; + const topPad = controlsPosition === "top" ? 48 : 0; + if (isRotated) { + canvasStyle.maxWidth = `calc(100vh - ${topPad}px)`; + canvasStyle.maxHeight = `calc(100vw - ${pad}px)`; + } else { + canvasStyle.maxWidth = `calc(100vw - ${pad}px)`; + canvasStyle.maxHeight = `calc(100vh - ${topPad}px)`; + } + canvasStyle.width = "auto"; + } else if (isRotated) { + canvasStyle.height = "70vh"; + canvasStyle.width = "auto"; + } else { + canvasStyle.width = "100%"; + canvasStyle.maxWidth = "min(600px, 100%)"; + } + return ; + })()} +
+ {(() => { + const light = poppedOut && isLightColor(resolveBackdrop(backdrop)); + const btnVar = poppedOut ? (light ? "outline-dark" : "outline-light") : "outline-secondary"; + if (poppedOut) { + return ( +
+
+ {controlsPosition === "right" ? ( + <> + + + + ) : ( + <> + + + + )} +
+ {showControls && ( +
+
+ + +
+ +
+ {Object.entries(BACKDROP_PRESETS).map(([name, color]) => ( +
+
+ )} +
+ ); + } + return ( +
+ + + + +
+ ); + })()} +
+ + ); +} diff --git a/ui/src/services/api.service.js b/ui/src/services/api.service.js index 454f2a7b..f763066e 100644 --- a/ui/src/services/api.service.js +++ b/ui/src/services/api.service.js @@ -29,6 +29,7 @@ class ApiServices { .then((text) => { let user = jwtDecode(text); localStorage.setItem("currentUser", JSON.stringify(user)); + localStorage.setItem("authToken", text); return user; }); } @@ -205,6 +206,7 @@ class ApiServices { function removeUser(){ localStorage.removeItem("currentUser"); + localStorage.removeItem("authToken"); } function handleError(r) { if (!r.ok) {