CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.2 KiB
Go
158 lines
5.2 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Token is a position in every stream a sync response carries.
|
|
//
|
|
// A sync response is assembled from streams that move independently: room
|
|
// events, read receipts, to-device messages, account data and presence. A
|
|
// client that has seen receipt 7 but only event 4 has to be able to say so
|
|
// in the one opaque string the API gives it, so the token names a position
|
|
// in each.
|
|
//
|
|
// The wire form is
|
|
//
|
|
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}_{typing}
|
|
//
|
|
// with trailing zeroes left off, so a client that has only ever seen room
|
|
// events carries "s12" rather than "s12_0_0_0_0_0_0". The device list
|
|
// position came with Phase 7 and the notification count position with
|
|
// Phase 8 and the typing position after them, each added at the end,
|
|
// which is what lets every token handed out before them go on parsing. The leading "s" is there
|
|
// to make a token recognisable in a log and to leave room for a second
|
|
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
|
|
// own — without any ambiguity about which is which.
|
|
//
|
|
// The spec says only that the token is opaque, and clients treat it that
|
|
// way. It is still worth being readable: a token is the first thing in
|
|
// every bug report about /sync.
|
|
type Token struct {
|
|
PDU int64
|
|
Receipt int64
|
|
ToDevice int64
|
|
AccountData int64
|
|
Presence int64
|
|
DeviceList int64
|
|
|
|
// Notification is the push server's notification count stream.
|
|
Notification int64
|
|
|
|
// Typing is the typing tracker's position. It lives in memory and
|
|
// starts again at zero when the server restarts, so it is compared
|
|
// for difference, never for order.
|
|
Typing int64
|
|
}
|
|
|
|
// tokenPositions is how many positions a token carries.
|
|
const tokenPositions = 8
|
|
|
|
// tokenPrefix marks a classic /sync token.
|
|
const tokenPrefix = "s"
|
|
|
|
// IsInitial reports whether the token is the start of every stream, which
|
|
// is what a sync with no ?since= means.
|
|
func (t Token) IsInitial() bool { return t == Token{} }
|
|
|
|
// String renders the wire form.
|
|
func (t Token) String() string {
|
|
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification, t.Typing}
|
|
// Trailing zeroes carry no information, and leaving them off keeps the
|
|
// common token short.
|
|
last := 0
|
|
for i, p := range parts {
|
|
if p != 0 {
|
|
last = i
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(tokenPrefix)
|
|
for i, p := range parts[:last+1] {
|
|
if i > 0 {
|
|
b.WriteByte('_')
|
|
}
|
|
b.WriteString(strconv.FormatInt(p, 10))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ParseToken reads a token a client sent back.
|
|
//
|
|
// An empty string is the initial token: /sync without ?since= and
|
|
// /messages without ?from= both mean "from the beginning", and refusing
|
|
// the empty string would make every caller special-case it.
|
|
//
|
|
// A token with fewer fields than this build writes is accepted and the
|
|
// missing ones are zero. That is what lets a token stored by a client
|
|
// before a stream existed go on working: the stream it did not know about
|
|
// reads as "seen nothing", so the client is sent that stream in full once
|
|
// and is in step from then on.
|
|
func ParseToken(raw string) (Token, error) {
|
|
if raw == "" {
|
|
return Token{}, nil
|
|
}
|
|
body, ok := strings.CutPrefix(raw, tokenPrefix)
|
|
if !ok {
|
|
return Token{}, fmt.Errorf("%q is not a sync token: it must start with %q", raw, tokenPrefix)
|
|
}
|
|
|
|
fields := strings.Split(body, "_")
|
|
if len(fields) > tokenPositions {
|
|
return Token{}, fmt.Errorf("%q is not a sync token: it has %d positions, this server writes at most %d",
|
|
raw, len(fields), tokenPositions)
|
|
}
|
|
|
|
var positions [tokenPositions]int64
|
|
for i, field := range fields {
|
|
value, err := strconv.ParseInt(field, 10, 64)
|
|
// The PDU position alone may be negative: /messages hands out
|
|
// positions inside history backfilled from other servers, which
|
|
// is numbered downwards from -1. Every other stream starts at 0.
|
|
if err != nil || (value < 0 && i > 0) {
|
|
return Token{}, fmt.Errorf("%q is not a sync token: position %d is %q", raw, i+1, field)
|
|
}
|
|
positions[i] = value
|
|
}
|
|
return Token{
|
|
PDU: positions[0],
|
|
Receipt: positions[1],
|
|
ToDevice: positions[2],
|
|
AccountData: positions[3],
|
|
Presence: positions[4],
|
|
DeviceList: positions[5],
|
|
|
|
Notification: positions[6],
|
|
Typing: positions[7],
|
|
}, nil
|
|
}
|
|
|
|
// tokenAt is the token naming the top of every stream, which is what a
|
|
// sync response hands back when it has drained all of them.
|
|
func tokenAt(positions storage.StreamPositions) Token {
|
|
return Token{
|
|
PDU: positions.PDU,
|
|
Receipt: positions.Receipt,
|
|
ToDevice: positions.ToDevice,
|
|
AccountData: positions.AccountData,
|
|
Presence: positions.Presence,
|
|
DeviceList: positions.DeviceList,
|
|
|
|
Notification: positions.Notification,
|
|
}
|
|
}
|
|
|
|
// pduToken is the token for a position in the room event stream alone.
|
|
//
|
|
// It is what /messages and a room's prev_batch hand out: both are about
|
|
// room events only, and a token from either has to be usable as a ?since=
|
|
// for /sync, where the other streams then read as unseen. That is the
|
|
// conservative direction — the client is sent those streams again rather
|
|
// than having them skipped.
|
|
func pduToken(pos int64) Token { return Token{PDU: pos} }
|