Add websocket-based device sync and connected devices UI

Replaces polling-only desktop sync with a live push channel: desktop
apps hold a websocket to the server and get notified on project,
document, and folder changes from either the app or the web editor.
Settings now lists connected devices with revoke support.

Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
2026-07-21 12:44:19 -04:00
parent bf4ea1e661
commit 9619a892d5
5 changed files with 454 additions and 7 deletions
+35
View File
@@ -14,6 +14,7 @@ use argon2::{
};
use crate::{
devices::{notify_devices, DeviceEvent},
models::{Project, User},
projects::{decode_text_blob, encode_text_blob},
AppState,
@@ -366,6 +367,8 @@ pub async fn create_project(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(ProjectSummary {
id: project.id,
name: project.name,
@@ -399,6 +402,8 @@ pub async fn delete_project(
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -673,6 +678,8 @@ pub async fn push_file(
.execute(&state.db)
.await;
notify_devices(&state, &user_id, DeviceEvent::project(&project_id)).await;
Ok(PushOutcome::Applied(Json(PushFileResponse {
path: payload.path,
hash: content_hash(&incoming),
@@ -700,6 +707,8 @@ pub async fn delete_file(
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::project(&project_id)).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -838,6 +847,8 @@ pub async fn create_folder(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFolder {
id: folder_id,
name,
@@ -873,6 +884,8 @@ pub async fn rename_folder(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFolder {
id,
name: row.0,
@@ -955,6 +968,8 @@ pub async fn move_folder(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFolder {
id,
name: row.0,
@@ -1012,6 +1027,8 @@ pub async fn delete_folder(
return Err((StatusCode::NOT_FOUND, "Folder not found or unauthorized".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -1044,6 +1061,8 @@ pub async fn move_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(ProjectSummary {
id: row.0,
name: row.1,
@@ -1289,6 +1308,8 @@ pub async fn push_document(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::document(&id)).await;
Ok(PushOutcome::Applied(Json(PushFileResponse {
path: id,
hash: incoming_hash,
@@ -1323,6 +1344,8 @@ pub async fn create_document(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(DocumentContent {
id: document_id,
title: payload.title,
@@ -1361,6 +1384,8 @@ pub async fn move_document(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudDocument {
id: row.0,
title: row.1,
@@ -1388,6 +1413,8 @@ pub async fn delete_document(
return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -1487,6 +1514,8 @@ pub async fn upload_account_file(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFile {
id: file_id,
name,
@@ -1525,6 +1554,8 @@ pub async fn rename_account_file(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFile {
id,
name: row.0,
@@ -1563,6 +1594,8 @@ pub async fn move_account_file(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(CloudFile {
id,
name: row.0,
@@ -1625,5 +1658,7 @@ pub async fn delete_account_file(
return Err((StatusCode::NOT_FOUND, "File not found or unauthorized".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(StatusCode::NO_CONTENT)
}
+256
View File
@@ -0,0 +1,256 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, State,
},
http::{HeaderMap, StatusCode},
Json,
};
use axum_extra::extract::cookie::SignedCookieJar;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use uuid::Uuid;
use crate::AppState;
fn hash_token(token: &str) -> String {
format!("{:x}", Sha256::digest(token.as_bytes()))
}
fn bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.filter(|value| value.starts_with("Bearer "))
.map(|value| value[7..].to_string())
}
fn get_user_id(jar: &SignedCookieJar) -> Option<String> {
jar.get("session_user_id").map(|c| c.value().to_string())
}
async fn authenticate_device(
state: &AppState,
headers: &HeaderMap,
) -> Result<(String, String), (StatusCode, String)> {
let token = bearer_token(headers).ok_or((
StatusCode::UNAUTHORIZED,
"Missing Authorization header".to_string(),
))?;
let row = sqlx::query_as::<_, (String, String)>(
"SELECT id, user_id FROM device_tokens WHERE token_hash = ?",
)
.bind(hash_token(&token))
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (device_id, user_id) = row.ok_or((
StatusCode::UNAUTHORIZED,
"Invalid device token".to_string(),
))?;
Ok((user_id, device_id))
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DeviceEvent {
pub kind: String,
pub project_id: Option<String>,
pub document_id: Option<String>,
}
impl DeviceEvent {
pub fn project(project_id: &str) -> Self {
Self {
kind: "project".to_string(),
project_id: Some(project_id.to_string()),
document_id: None,
}
}
pub fn document(document_id: &str) -> Self {
Self {
kind: "document".to_string(),
project_id: None,
document_id: Some(document_id.to_string()),
}
}
pub fn structure() -> Self {
Self {
kind: "structure".to_string(),
project_id: None,
document_id: None,
}
}
}
pub async fn notify_devices(state: &AppState, user_id: &str, event: DeviceEvent) {
let events = state.device_events.lock().await;
if let Some(sender) = events.get(user_id) {
let _ = sender.send(event);
}
}
pub struct DevicePresence {
pub connected_since: String,
pub connection_id: String,
pub stop: watch::Sender<bool>,
}
pub async fn ws_handler(
State(state): State<AppState>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Result<axum::response::Response, (StatusCode, String)> {
let (user_id, device_id) = authenticate_device(&state, &headers).await?;
Ok(ws.on_upgrade(move |socket| handle_socket(state, socket, user_id, device_id)))
}
async fn handle_socket(state: AppState, socket: WebSocket, user_id: String, device_id: String) {
let mut receiver = {
let mut events = state.device_events.lock().await;
let sender = events
.entry(user_id)
.or_insert_with(|| broadcast::channel(16).0)
.clone();
sender.subscribe()
};
let connection_id = Uuid::new_v4().to_string();
let (stop_tx, mut stop_rx) = watch::channel(false);
{
let mut presence = state.device_presence.lock().await;
presence.insert(
device_id.clone(),
DevicePresence {
connected_since: chrono::Utc::now().to_rfc3339(),
connection_id: connection_id.clone(),
stop: stop_tx,
},
);
}
let (mut sink, mut stream) = socket.split();
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
heartbeat.tick().await;
loop {
tokio::select! {
event = receiver.recv() => {
match event {
Ok(event) => {
let Ok(payload) = serde_json::to_string(&event) else { continue };
if sink.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = heartbeat.tick() => {
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
break;
}
}
incoming = stream.next() => {
match incoming {
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => {}
}
}
_ = stop_rx.changed() => {
break;
}
}
}
let mut presence = state.device_presence.lock().await;
if presence
.get(&device_id)
.map(|entry| entry.connection_id == connection_id)
.unwrap_or(false)
{
presence.remove(&device_id);
}
}
#[derive(Serialize)]
pub struct DeviceView {
pub id: String,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub connected: bool,
pub connected_since: Option<String>,
}
pub async fn list_devices(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<DeviceView>>, (StatusCode, String)> {
let user_id =
get_user_id(&jar).ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let rows = sqlx::query_as::<_, (String, String, String, Option<String>)>(
"SELECT id, name, created_at, last_used_at FROM device_tokens WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(&user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let presence = state.device_presence.lock().await;
Ok(Json(
rows.into_iter()
.map(|(id, name, created_at, last_used_at)| {
let entry = presence.get(&id);
DeviceView {
connected: entry.is_some(),
connected_since: entry.map(|e| e.connected_since.clone()),
id,
name,
created_at,
last_used_at,
}
})
.collect(),
))
}
pub async fn revoke_device(
State(state): State<AppState>,
jar: SignedCookieJar,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id =
get_user_id(&jar).ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let result = sqlx::query("DELETE FROM device_tokens WHERE id = ? AND user_id = ?")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Device not found".to_string()));
}
let presence = state.device_presence.lock().await;
if let Some(entry) = presence.get(&id) {
let _ = entry.stop.send(true);
}
Ok(StatusCode::NO_CONTENT)
}
+13 -3
View File
@@ -6,8 +6,10 @@ use axum_extra::extract::cookie::Key;
use sqlx::AnyPool;
use std::sync::Arc;
use std::collections::HashMap;
use tokio::sync::Mutex;
use tokio::sync::{broadcast, Mutex};
use yrs_axum::broadcast::BroadcastGroup;
use devices::{DeviceEvent, DevicePresence};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -18,6 +20,7 @@ mod auth;
mod compiler;
mod db;
mod desktop;
mod devices;
mod docs;
mod folders;
mod files;
@@ -43,6 +46,8 @@ pub struct AppState {
pub key: Key,
pub registration_enabled: bool,
pub rate_limiter: RateLimiterMap,
pub device_presence: Arc<Mutex<HashMap<String, DevicePresence>>>,
pub device_events: Arc<Mutex<HashMap<String, broadcast::Sender<DeviceEvent>>>>,
}
impl axum::extract::FromRef<AppState> for Key {
@@ -93,6 +98,8 @@ async fn main() {
key,
registration_enabled,
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
device_presence: Arc::new(Mutex::new(HashMap::new())),
device_events: Arc::new(Mutex::new(HashMap::new())),
};
let api_routes = Router::new()
@@ -139,7 +146,9 @@ async fn main() {
.route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_file))
.route("/packages", get(packages::list_packages))
.route("/packages/publish", post(packages::publish_package))
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package))
.route("/devices", get(devices::list_devices))
.route("/devices/{id}", delete(devices::revoke_device));
let desktop_routes = Router::new()
.route("/version", get(desktop::version_info))
@@ -158,7 +167,8 @@ async fn main() {
.route("/files", get(desktop::list_account_files).post(desktop::upload_account_file))
.route("/files/{id}", get(desktop::pull_account_file).patch(desktop::rename_account_file).delete(desktop::delete_account_file))
.route("/files/{id}/move", patch(desktop::move_account_file))
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file))
.route("/ws", get(devices::ws_handler));
let v1_routes = Router::new()
.route("/render", post(public_api::render_handler));
+17 -4
View File
@@ -13,6 +13,7 @@ use yrs::Update;
use crate::{
compiler::ProjectInput,
devices::{notify_devices, DeviceEvent},
models::{
CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
UpdateProjectRequest,
@@ -336,6 +337,8 @@ pub async fn update_project(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(Json(project))
}
@@ -363,6 +366,8 @@ pub async fn delete_project(
return Err((StatusCode::NOT_FOUND, "Project not found or unauthorized".to_string()));
}
notify_devices(&state, &user_id, DeviceEvent::structure()).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -394,7 +399,7 @@ pub async fn create_project_file(
Json(payload): Json<CreateProjectFileRequest>,
) -> Result<Json<ProjectFile>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = project_role(&state, &id, &user_id_opt)
let (project, role) = project_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
@@ -417,6 +422,8 @@ pub async fn create_project_file(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
Ok(Json(file))
}
@@ -427,7 +434,7 @@ pub async fn upload_project_file(
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = project_role(&state, &id, &user_id_opt)
let (project, role) = project_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
@@ -465,6 +472,8 @@ pub async fn upload_project_file(
uploaded.push(path);
}
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
Ok(Json(serde_json::json!({ "files": uploaded })))
}
@@ -505,7 +514,7 @@ pub async fn update_project_file(
Json(payload): Json<UpdateProjectFileRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = project_role(&state, &id, &user_id_opt)
let (project, role) = project_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
@@ -524,6 +533,8 @@ pub async fn update_project_file(
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
}
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -533,7 +544,7 @@ pub async fn delete_project_file(
jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
let (_, role) = project_role(&state, &id, &user_id_opt)
let (project, role) = project_role(&state, &id, &user_id_opt)
.await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" {
@@ -551,5 +562,7 @@ pub async fn delete_project_file(
return Err((StatusCode::NOT_FOUND, "File not found".to_string()));
}
notify_devices(&state, &project.owner_id, DeviceEvent::project(&id)).await;
Ok(StatusCode::NO_CONTENT)
}