diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 676a198..b9cada2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -947,6 +947,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -3889,6 +3895,8 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ + "libc", + "rand_chacha", "rand_core", ] @@ -3907,6 +3915,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "raw-window-handle" @@ -4558,6 +4569,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5705,6 +5727,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "two-face" version = "0.4.5" @@ -5778,6 +5819,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-opener", + "tungstenite", "typst", "typst-assets", "typst-html", @@ -6345,6 +6387,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e5fc11..1a82118 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,4 +42,5 @@ base64 = "0.22" diffy = "0.4" walkdir = "2" ureq = { version = "2.12", features = ["json"] } +tungstenite = { version = "0.24", features = ["native-tls"] } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f58af3c..2ed94fb 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -19,7 +19,7 @@ pub struct DocumentLink { pub synced_at: Option, } -const SCHEMA: [&str; 5] = [ +const SCHEMA: [&str; 6] = [ "CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -51,6 +51,11 @@ const SCHEMA: [&str; 5] = [ data TEXT NOT NULL, source_modified INTEGER NOT NULL )", + "CREATE TABLE IF NOT EXISTS cloud_cache ( + key TEXT PRIMARY KEY, + payload TEXT NOT NULL, + cached_at TEXT NOT NULL + )", ]; const MIGRATIONS: [&str; 2] = [ @@ -416,4 +421,30 @@ impl Store { Ok(()) }) } + + pub fn cloud_cache(&self, key: &str) -> Result, String> { + self.with(|connection| { + connection + .query_row( + "SELECT payload FROM cloud_cache WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional() + }) + } + + pub fn save_cloud_cache(&self, key: &str, payload: &str) -> Result<(), String> { + self.with(|connection| { + connection.execute( + "INSERT INTO cloud_cache (key, payload, cached_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + payload = excluded.payload, + cached_at = excluded.cached_at", + params![key, payload, chrono::Utc::now().to_rfc3339()], + )?; + Ok(()) + }) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f6f914..1ae1119 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod sync; mod thumbnails; mod workspace; mod world; +mod ws; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde::{Deserialize, Serialize}; @@ -16,6 +17,7 @@ use assets::Asset; use db::Store; use compiler::{CompileResult, Diagnostic}; use lsp::{LspHandle, LspState}; +use ws::WsState; use sync::{Account, ProjectSummary, SyncReport}; use workspace::{ browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path, @@ -535,6 +537,16 @@ fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> { store.clear_thumbnails() } +#[tauri::command] +fn get_cloud_cache(store: State<'_, Store>, key: String) -> Result, String> { + store.cloud_cache(&key) +} + +#[tauri::command] +fn save_cloud_cache(store: State<'_, Store>, key: String, payload: String) -> Result<(), String> { + store.save_cloud_cache(&key, &payload) +} + #[tauri::command] fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result, String> { assets::list_assets(&app, &store) @@ -703,6 +715,7 @@ fn cloud_check_compatibility(server_url: String) -> sync::CompatibilityStatus { fn cloud_login( app: AppHandle, store: State<'_, Store>, + ws_state: State<'_, WsState>, server_url: String, email: String, password: String, @@ -712,12 +725,14 @@ fn cloud_login( let response = sync::login(&server_url, &email, &password, &device_name)?; let mut settings = load_settings(&app, &store)?; - settings.server_url = server_url; - settings.device_token = Some(response.token); + settings.server_url = server_url.clone(); + settings.device_token = Some(response.token.clone()); settings.account_email = Some(response.email.clone()); settings.account_username = Some(response.username.clone()); save_settings(&store, &settings)?; + ws_state.start(app, server_url, response.token); + Ok(Account { user_id: response.user_id, username: response.username, @@ -726,7 +741,11 @@ fn cloud_login( } #[tauri::command] -fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> { +fn cloud_logout( + app: AppHandle, + store: State<'_, Store>, + ws_state: State<'_, WsState>, +) -> Result<(), String> { let mut settings = load_settings(&app, &store)?; if let Some(token) = &settings.device_token { let _ = sync::logout(&settings.server_url, token); @@ -735,7 +754,31 @@ fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> { settings.device_token = None; settings.account_email = None; settings.account_username = None; - save_settings(&store, &settings) + save_settings(&store, &settings)?; + + ws_state.stop(&app); + + Ok(()) +} + +#[tauri::command] +fn cloud_ws_start(app: AppHandle, store: State<'_, Store>, ws_state: State<'_, WsState>) -> Result<(), String> { + let settings = load_settings(&app, &store)?; + if let Some(token) = settings.device_token { + ws_state.start(app, settings.server_url, token); + } + Ok(()) +} + +#[tauri::command] +fn cloud_ws_stop(app: AppHandle, ws_state: State<'_, WsState>) -> Result<(), String> { + ws_state.stop(&app); + Ok(()) +} + +#[tauri::command] +fn cloud_ws_status(ws_state: State<'_, WsState>) -> Result { + Ok(ws_state.status()) } #[tauri::command] @@ -1311,6 +1354,7 @@ pub fn run() { Ok(()) }) .manage(LspState::default()) + .manage(WsState::default()) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { if let Some(state) = window.app_handle().try_state::() { @@ -1342,6 +1386,8 @@ pub fn run() { thumbnail, read_image, clear_thumbnails, + get_cloud_cache, + save_cloud_cache, list_assets, list_resources, list_font_families, @@ -1357,6 +1403,9 @@ pub fn run() { cloud_login, cloud_logout, cloud_account, + cloud_ws_start, + cloud_ws_stop, + cloud_ws_status, cloud_list_projects, cloud_list_folders, cloud_create_folder, diff --git a/src-tauri/src/ws.rs b/src-tauri/src/ws.rs new file mode 100644 index 0000000..ef9274b --- /dev/null +++ b/src-tauri/src/ws.rs @@ -0,0 +1,156 @@ +use serde::{Deserialize, Serialize}; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Manager}; +use tungstenite::client::IntoClientRequest; +use tungstenite::stream::MaybeTlsStream; +use tungstenite::Message; + +pub const STATUS_EVENT: &str = "cloud://ws-status"; +pub const SYNC_EVENT: &str = "cloud://sync-event"; + +const READ_TIMEOUT: Duration = Duration::from_secs(10); +const RECONNECT_DELAY: Duration = Duration::from_secs(5); + +#[derive(Deserialize, Serialize, Clone)] +pub struct DeviceEvent { + pub kind: String, + pub project_id: Option, + pub document_id: Option, +} + +pub struct WsState { + generation: AtomicU64, + status: Mutex, +} + +impl Default for WsState { + fn default() -> Self { + Self { + generation: AtomicU64::new(0), + status: Mutex::new("offline".to_string()), + } + } +} + +impl WsState { + pub fn status(&self) -> String { + self.status + .lock() + .map(|slot| slot.clone()) + .unwrap_or_else(|_| "offline".to_string()) + } + + fn set_status(&self, app: &AppHandle, status: &str) { + if let Ok(mut slot) = self.status.lock() { + *slot = status.to_string(); + } + let _ = app.emit(STATUS_EVENT, status); + } + + pub fn start(&self, app: AppHandle, server_url: String, token: String) { + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.set_status(&app, "connecting"); + + std::thread::spawn(move || run_loop(app, server_url, token, generation)); + } + + pub fn stop(&self, app: &AppHandle) { + self.generation.fetch_add(1, Ordering::SeqCst); + self.set_status(app, "offline"); + } +} + +fn still_current(app: &AppHandle, generation: u64) -> bool { + app.state::().generation.load(Ordering::SeqCst) == generation +} + +fn ws_url(server_url: &str) -> String { + let trimmed = server_url.trim_end_matches('/'); + if let Some(rest) = trimmed.strip_prefix("https://") { + format!("wss://{}/api/desktop/ws", rest) + } else if let Some(rest) = trimmed.strip_prefix("http://") { + format!("ws://{}/api/desktop/ws", rest) + } else { + format!("ws://{}/api/desktop/ws", trimmed) + } +} + +fn configure_read_timeout(stream: &MaybeTlsStream) { + let tcp = match stream { + MaybeTlsStream::Plain(stream) => Some(stream), + MaybeTlsStream::NativeTls(stream) => Some(stream.get_ref()), + _ => None, + }; + + if let Some(tcp) = tcp { + let _ = tcp.set_read_timeout(Some(READ_TIMEOUT)); + } +} + +fn is_timeout(error: &tungstenite::Error) -> bool { + matches!( + error, + tungstenite::Error::Io(io_error) + if io_error.kind() == std::io::ErrorKind::WouldBlock + || io_error.kind() == std::io::ErrorKind::TimedOut + ) +} + +fn connect_and_listen(app: &AppHandle, url: &str, token: &str, generation: u64) -> Result<(), String> { + let mut request = url + .into_client_request() + .map_err(|e| e.to_string())?; + let header_value = format!("Bearer {}", token) + .parse() + .map_err(|_| "Invalid device token".to_string())?; + request.headers_mut().insert("Authorization", header_value); + + let (mut socket, _response) = tungstenite::connect(request).map_err(|e| e.to_string())?; + configure_read_timeout(socket.get_ref()); + + app.state::().set_status(app, "connected"); + + loop { + if !still_current(app, generation) { + let _ = socket.close(None); + return Ok(()); + } + + match socket.read() { + Ok(Message::Text(text)) => { + if let Ok(event) = serde_json::from_str::(text.as_ref()) { + let _ = app.emit(SYNC_EVENT, event); + } + } + Ok(Message::Ping(_)) => { + let _ = socket.flush(); + } + Ok(Message::Close(_)) => return Ok(()), + Ok(_) => {} + Err(ref error) if is_timeout(error) => continue, + Err(error) => return Err(error.to_string()), + } + } +} + +fn run_loop(app: AppHandle, server_url: String, token: String, generation: u64) { + let url = ws_url(&server_url); + + loop { + if !still_current(&app, generation) { + return; + } + + let _ = connect_and_listen(&app, &url, &token, generation); + + if !still_current(&app, generation) { + return; + } + + app.state::().set_status(&app, "offline"); + std::thread::sleep(RECONNECT_DELAY); + } +} diff --git a/src/lib/components/FileViewer.svelte b/src/lib/components/FileViewer.svelte index 8addaa0..5cd0f88 100644 --- a/src/lib/components/FileViewer.svelte +++ b/src/lib/components/FileViewer.svelte @@ -947,6 +947,13 @@ {/if} + {#if app.cloudOffline} +
+ + Offline — showing last synced data +
+ {/if} + {#if cloudTrail.length > 0}
diff --git a/src/lib/ts/api.ts b/src/lib/ts/api.ts index 1ec159d..d1bdd35 100644 --- a/src/lib/ts/api.ts +++ b/src/lib/ts/api.ts @@ -277,6 +277,24 @@ export const cloudLogout = () => invoke("cloud_logout"); export const cloudAccount = () => invoke("cloud_account"); +export const cloudWsStart = () => invoke("cloud_ws_start"); + +export const cloudWsStop = () => invoke("cloud_ws_stop"); + +export const cloudWsStatus = () => invoke("cloud_ws_status"); + +export interface DeviceEvent { + kind: "project" | "document" | "structure"; + project_id: string | null; + document_id: string | null; +} + +export const getCloudCache = (key: string) => + invoke("get_cloud_cache", { key }); + +export const saveCloudCache = (key: string, payload: string) => + invoke("save_cloud_cache", { key, payload }); + export const cloudListProjects = () => invoke("cloud_list_projects"); diff --git a/src/lib/ts/state.svelte.ts b/src/lib/ts/state.svelte.ts index a75f157..2dead66 100644 --- a/src/lib/ts/state.svelte.ts +++ b/src/lib/ts/state.svelte.ts @@ -1,3 +1,4 @@ +import { listen } from "@tauri-apps/api/event"; import * as api from "./api"; import type { Account, @@ -7,6 +8,7 @@ import type { CloudFolder, CompileResult, Conflict, + DeviceEvent, Diagnostic, DocumentLink, LinkedDocument, @@ -47,6 +49,8 @@ interface AppState { cloudDocuments: CloudDocument[]; cloudFiles: CloudFile[]; cloudLoading: boolean; + cloudOffline: boolean; + wsStatus: string; linkedDocuments: LinkedDocument[]; linkedProjects: LinkedProject[]; documentLink: DocumentLink | null; @@ -89,6 +93,8 @@ export const app = $state({ cloudDocuments: [], cloudFiles: [], cloudLoading: false, + cloudOffline: false, + wsStatus: "offline", linkedDocuments: [], linkedProjects: [], documentLink: null, @@ -340,6 +346,10 @@ export async function bootstrap() { try { app.settings = await api.getSettings(); restartAutoSync(); + await initWsSync(); + if (app.settings?.device_token) { + api.cloudWsStart().catch(() => {}); + } await browseTo(""); await refreshAccount(); } catch (error) { @@ -347,6 +357,37 @@ export async function bootstrap() { } } +async function initWsSync() { + await listen("cloud://ws-status", (event) => { + app.wsStatus = event.payload; + }); + + await listen("cloud://sync-event", (event) => { + handleDeviceEvent(event.payload); + }); + + app.wsStatus = await api.cloudWsStatus().catch(() => "offline"); +} + +function handleDeviceEvent(event: DeviceEvent) { + const linkedProject = app.target?.cloud_project_id; + const linkedDocument = app.documentLink?.document_id; + + const matchesOpenTarget = + (event.kind === "project" && + event.project_id && + event.project_id === linkedProject) || + (event.kind === "document" && + event.document_id && + event.document_id === linkedDocument); + + if (matchesOpenTarget) { + autoSync(); + } else if (app.scope === "cloud") { + refreshCloud(); + } +} + export async function browseTo(path: string) { try { app.entries = await api.browseWorkspace(path); @@ -382,20 +423,52 @@ export async function refreshCloudProjects() { } } +interface CloudSnapshot { + folders: CloudFolder[]; + documents: CloudDocument[]; + projects: ProjectSummary[]; + files: CloudFile[]; +} + +function cloudCacheKey() { + return `cloud:${app.cloudFolder ?? "root"}`; +} + +function applyCloudSnapshot(snapshot: CloudSnapshot) { + if (app.cloudFolder === "shared") { + app.cloudDocuments = snapshot.documents; + app.cloudProjects = snapshot.projects; + app.cloudFolders = []; + app.cloudFiles = []; + } else { + app.cloudFolderTree = snapshot.folders; + app.cloudFolders = snapshot.folders.filter( + (folder) => (folder.parent_id ?? null) === app.cloudFolder, + ); + app.cloudDocuments = snapshot.documents; + app.cloudProjects = snapshot.projects.filter( + (project) => + project.role !== "owner" || + (project.folder_id ?? null) === app.cloudFolder, + ); + app.cloudFiles = snapshot.files; + } +} + export async function refreshCloud() { if (!app.account) return; app.cloudLoading = true; + const cacheKey = cloudCacheKey(); + try { app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []); app.linkedProjects = await api.cloudLinkedProjects().catch(() => []); + let snapshot: CloudSnapshot; if (app.cloudFolder === "shared") { const shared = await api.cloudListShared(); - app.cloudDocuments = shared.documents; - app.cloudProjects = shared.projects; - app.cloudFolders = []; - app.cloudFiles = []; + snapshot = { folders: [], documents: shared.documents, projects: shared.projects, files: [] }; } else { const [folders, documents, projects, files] = await Promise.all([ api.cloudListFolders(), @@ -403,20 +476,20 @@ export async function refreshCloud() { api.cloudListProjects(), api.cloudListFiles(app.cloudFolder), ]); - app.cloudFolderTree = folders; - app.cloudFolders = folders.filter( - (folder) => (folder.parent_id ?? null) === app.cloudFolder, - ); - app.cloudDocuments = documents; - app.cloudProjects = projects.filter( - (project) => - project.role !== "owner" || - (project.folder_id ?? null) === app.cloudFolder, - ); - app.cloudFiles = files; + snapshot = { folders, documents, projects, files }; } + + applyCloudSnapshot(snapshot); + app.cloudOffline = false; + api.saveCloudCache(cacheKey, JSON.stringify(snapshot)).catch(() => {}); } catch (error) { - setError(error); + const cached = await api.getCloudCache(cacheKey).catch(() => null); + if (cached) { + applyCloudSnapshot(JSON.parse(cached)); + app.cloudOffline = true; + } else { + setError(error); + } } finally { app.cloudLoading = false; } @@ -668,7 +741,7 @@ export function restartAutoSync() { if (seconds <= 0) return; syncTimer = setInterval(() => { - autoSync(); + if (app.wsStatus !== "connected") autoSync(); }, seconds * 1000); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e48ee1d..4ff60c1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -512,6 +512,12 @@ on: "LSP ready", unavailable: "LSP unavailable", }; + + const wsLabel: Record = { + connected: "Live sync", + connecting: "Connecting…", + offline: "Offline (polling)", + }; @@ -588,6 +594,23 @@ {lspLabel[app.lspStatus]} + {#if app.account} + + + {wsLabel[app.wsStatus] ?? "Offline (polling)"} + + {/if} +