Web client (warp-web) with E2EE, and two sync fixes
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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:22:11 +02:00
1 parent adc18e7cf3
commit b6f3be6ac4
90 files changed
+6608 -472

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
import { createClient, PendingEventOrdering, type MatrixClient } from 'matrix-js-sdk'
import { config } from '@/config'
import { bindCrypto, cryptoCallbacks, cryptoDatabasePrefix } from './crypto'
// The one MatrixClient of a signed-in session. It is kept outside Vue's
// reactivity on purpose: the SDK's objects mutate themselves and emit
// events, and the stores turn those events into plain reactive state.
//
// Everything that touches matrix-js-sdk lives under src/matrix and
// src/stores, so the desktop app can later swap in its Go backend behind
// the same stores.
export interface Credentials {
baseUrl: string
userId: string
deviceId: string
accessToken: string
}
let current: MatrixClient | null = null
let cryptoFailure = ''
/** The running client. Only call this while signed in. */
export function client(): MatrixClient {
if (!current) throw new Error('Not signed in')
return current
}
export function hasClient(): boolean {
return current !== null
}
/**
* Why end-to-end encryption didn't start in this session, or '' when it
* runs. Without it the client still works in unencrypted rooms.
*/
export function cryptoError(): string {
return cryptoFailure
}
/** A client without a session, for sign-in and registration. */
export function anonymousClient(): MatrixClient {
return createClient({ baseUrl: config.homeserverUrl })
}
export async function startClient(creds: Credentials): Promise<MatrixClient> {
stopClient()
const c = createClient({
baseUrl: creds.baseUrl,
userId: creds.userId,
deviceId: creds.deviceId,
accessToken: creds.accessToken,
timelineSupport: true,
useAuthorizationHeader: true,
cryptoCallbacks,
// Emoji (SAS) only: there is no camera to scan a QR code with here.
verificationMethods: ['m.sas.v1'],
})
current = c
// End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm),
// its keys in IndexedDB under this session's own name, so two accounts
// in one browser never share a store. It loads before the first sync so
// the to-device messages and room keys in it are not missed.
cryptoFailure = ''
try {
await c.initRustCrypto({ cryptoDatabasePrefix: cryptoDatabasePrefix(creds.userId, creds.deviceId) })
bindCrypto(c)
} catch (e) {
console.error('crypto: ', e)
cryptoFailure = e instanceof Error ? e.message : String(e)
}
await c.startClient({
initialSyncLimit: 30,
lazyLoadMembers: true,
// Your own messages that are still sending (or failed) are kept apart
// from the timeline, so a failed one doesn't sit between others' replies.
pendingEventOrdering: PendingEventOrdering.Detached,
threadSupport: false,
})
return c
}
export function stopClient() {
if (!current) return
bindCrypto(null)
current.stopClient()
current.removeAllListeners()
current = null
}
// Crypto stores of sessions that are over but not deleted yet. Deleting
// an IndexedDB database waits until every connection to it is closed, and
// the crypto stack may hold one until the page goes away; whatever is left
// here is deleted when the next page starts.
const forgetKey = 'warp.web.crypto.forget'
function pendingForget(): string[] {
try {
const v = JSON.parse(localStorage.getItem(forgetKey) ?? '[]')
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []
} catch {
return []
}
}
function setPendingForget(list: string[]) {
try {
if (list.length) localStorage.setItem(forgetKey, JSON.stringify(list))
else localStorage.removeItem(forgetKey)
} catch {
// Not kept: the store stays until it is deleted by hand.
}
}
/** Deletes a crypto store; false when it is still open after a few seconds. */
async function deleteStore(c: MatrixClient, prefix: string): Promise<boolean> {
const timeout = new Promise<false>((resolve) => window.setTimeout(() => resolve(false), 3000))
const done = c
.clearStores({ cryptoDatabasePrefix: prefix })
.then(() => true as const)
.catch((e) => {
console.error('clearStores: ', e)
return false as const
})
const ok = await Promise.race([done, timeout])
if (ok) setPendingForget(pendingForget().filter((p) => p !== prefix))
return ok
}
/**
* Stops the client and deletes what it kept in this browser, the
* session's encryption keys included. For when the session is over: kept,
* they could only ever decrypt for a device the homeserver has forgotten.
*/
export async function destroyClient(session?: { userId: string; deviceId: string }) {
// The session may have no running client (it failed to start), and its
// keys still have to go.
const c = current ?? anonymousClient()
stopClient()
const userId = session?.userId ?? c.getUserId()
const deviceId = session?.deviceId ?? c.getDeviceId()
if (!userId || !deviceId) return
const prefix = cryptoDatabasePrefix(userId, deviceId)
setPendingForget([...new Set([...pendingForget(), prefix])])
await deleteStore(c, prefix)
}
/** Finishes deleting the crypto stores of sessions that ended. Runs before any client starts. */
export async function forgetEndedSessions() {
const list = pendingForget()
if (!list.length) return
const c = anonymousClient()
for (const prefix of list) await deleteStore(c, prefix)
}