Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
28 changes: 23 additions & 5 deletions docs/install/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -66,7 +84,7 @@ stream {
}

server {
listen 8883;
listen 443;
proxy_pass mqtt;
proxy_connect_timeout 5s;
}
Expand All @@ -91,5 +109,5 @@ tcp:

entryPoints:
mqtt:
address: ":8883"
address: ":443"
```
30 changes: 17 additions & 13 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -44,6 +45,7 @@ type App struct {
codeConnector CodeConnector
hwrClient *hwr.HWRClient
mqttBroker *mqtt.Broker
roomManager *screenshare.RoomManager
}

// Start starts the app
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
120 changes: 120 additions & 0 deletions internal/app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 35 additions & 1 deletion internal/app/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hub

import (
"encoding/base64"
"encoding/json"
"strconv"
"time"

Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/app/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
3 changes: 2 additions & 1 deletion internal/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading