Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

252 lines
8.4 KiB
Rust

//! A tiny HTTP server for the live session tests: it answers the live
//! endpoints of the sync server and streams the events the test pushes.
//!
//! The stub is deliberately small: it records the requests it answered, it
//! holds the text of one room and it can drop the event streams it opened.
#![allow(dead_code)] // every helper is used by one of the test cases
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
/// The largest request body the stub reads, in bytes.
const MAX_BODY: usize = 1024 * 1024;
/// One request the stub answered.
#[derive(Clone, Debug)]
pub struct Call {
/// The path of the request, without the query.
pub path: String,
/// The body of the request.
pub body: String,
}
/// The state of the stub, shared by its threads.
struct State {
/// The text of the room.
text: Mutex<String>,
/// The revision of the room.
rev: Mutex<u64>,
/// The joins that arrived.
joins: Mutex<Vec<Call>>,
/// The operations that arrived.
ops: Mutex<Vec<Call>>,
/// The status the next operation answers with.
ops_status: Mutex<Option<u16>>,
/// The number of event streams that were opened.
streams: Mutex<usize>,
/// The senders of the open event streams.
senders: Mutex<Vec<Sender<String>>>,
}
/// A running stub server.
pub struct Stub {
/// The base URL of the server.
pub base: String,
state: Arc<State>,
}
impl Stub {
/// Starts a server whose room holds `text`.
pub fn start(text: &str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let state = Arc::new(State {
text: Mutex::new(text.to_owned()),
rev: Mutex::new(0),
joins: Mutex::new(Vec::new()),
ops: Mutex::new(Vec::new()),
ops_status: Mutex::new(None),
streams: Mutex::new(0),
senders: Mutex::new(Vec::new()),
});
let server = Self { base: format!("http://127.0.0.1:{port}"), state: Arc::clone(&state) };
std::thread::spawn({
let state = Arc::clone(&state);
move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let state = Arc::clone(&state);
std::thread::spawn(move || serve(&state, stream));
}
}
});
server
}
/// The joins the stub answered.
pub fn joins(&self) -> Vec<Call> {
self.state.joins.lock().expect("joins").clone()
}
/// The operations the stub answered.
pub fn ops(&self) -> Vec<Call> {
self.state.ops.lock().expect("ops").clone()
}
/// Makes the next operation answer with this status.
pub fn set_ops_status(&self, status: u16) {
*self.state.ops_status.lock().expect("status") = Some(status);
}
/// Changes the text of the room, as another peer would.
pub fn set_room_text(&self, text: &str) {
let mut room = self.state.text.lock().expect("text");
text.clone_into(&mut room);
}
/// The number of event streams that were opened.
pub fn stream_count(&self) -> usize {
*self.state.streams.lock().expect("streams")
}
/// Sends raw Server-Sent Events text to the open streams.
pub fn push_event(&self, raw: &str) {
let mut senders = self.state.senders.lock().expect("senders");
senders.retain(|sender| sender.send(raw.to_owned()).is_ok());
}
/// Drops the open event streams: the clients see the stream end.
pub fn drop_streams(&self) {
let mut senders = self.state.senders.lock().expect("senders");
senders.clear();
}
}
/// Answers one request of a client.
fn serve(state: &Arc<State>, mut stream: TcpStream) {
let Some(request) = read_request(&mut stream) else {
return;
};
if request.path.ends_with("/join") {
state.joins.lock().expect("joins").push(request);
let reply = format!("{{\"text\":{},\"rev\":{},\"peers\":[]}}", quote(&state.text()), state.rev());
respond(&mut stream, 200, &reply);
} else if request.path.ends_with("/ops") {
state.ops.lock().expect("ops").push(request);
let status = state.ops_status.lock().expect("status").take();
if let Some(status) = status {
respond(&mut stream, status, "{\"error\":\"stale\"}");
} else {
let rev = state.bump_rev();
respond(&mut stream, 200, &format!("{{\"rev\":{rev}}}"));
}
} else if request.path.ends_with("/presence") {
respond(&mut stream, 200, "{\"peers\":[]}");
} else if request.path.ends_with("/leave") {
respond(&mut stream, 200, "{}");
} else if request.path.ends_with("/events") {
stream_events(state, stream);
} else {
respond(&mut stream, 404, "{}");
}
}
impl State {
/// The text of the room.
fn text(&self) -> String {
self.text.lock().expect("text").clone()
}
/// The revision of the room.
fn rev(&self) -> u64 {
*self.rev.lock().expect("rev")
}
/// Moves the revision of the room on.
fn bump_rev(&self) -> u64 {
let mut rev = self.rev.lock().expect("rev");
*rev = rev.saturating_add(1);
*rev
}
}
/// Reads the request of a client: the line, the headers and the body.
fn read_request(stream: &mut TcpStream) -> Option<Call> {
let mut reader = BufReader::new(stream.try_clone().ok()?);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let path = line.split_whitespace().nth(1)?.split('?').next()?.to_owned();
let mut length = 0usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).ok()? == 0 {
break;
}
let header = header.trim_end();
if header.is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
length = value.trim().parse().ok()?;
}
}
let mut body = vec![0u8; length.min(MAX_BODY)];
if !body.is_empty() {
reader.read_exact(&mut body).ok()?;
}
Some(Call { path, body: String::from_utf8_lossy(&body).into_owned() })
}
/// Writes a complete answer and closes the connection.
fn respond(stream: &mut TcpStream, status: u16, body: &str) {
let reason = match status {
200 => "OK",
204 => "No Content",
404 => "Not Found",
409 => "Conflict",
_ => "Error",
};
let _ = write!(stream, "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.flush();
}
/// Streams the events of the test until the stream is dropped.
fn stream_events(state: &Arc<State>, mut stream: TcpStream) {
let (sender, receiver): (Sender<String>, Receiver<String>) = mpsc::channel();
state.senders.lock().expect("senders").push(sender);
*state.streams.lock().expect("streams") += 1;
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
);
let _ = stream.flush();
loop {
match receiver.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(chunk) => {
if write!(stream, "{:x}\r\n{chunk}\r\n", chunk.len()).is_err() || stream.flush().is_err() {
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
/// The text of the room as a JSON string.
fn quote(text: &str) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other if (other as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", other as u32);
}
other => out.push(other),
}
}
out.push('"');
out
}