Connect to server over websocket for live cloud sync
Desktop app now holds a persistent websocket to the server and syncs on push notifications instead of a fixed timer, falling back to the old polling interval only when the socket is down. Cloud folder/file listings are cached locally so the browser stays usable offline. Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
Generated
+48
@@ -947,6 +947,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3889,6 +3895,8 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3907,6 +3915,9 @@ name = "rand_core"
|
|||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
@@ -4558,6 +4569,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -5705,6 +5727,25 @@ dependencies = [
|
|||||||
"core_maths",
|
"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]]
|
[[package]]
|
||||||
name = "two-face"
|
name = "two-face"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@@ -5778,6 +5819,7 @@ dependencies = [
|
|||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tungstenite",
|
||||||
"typst",
|
"typst",
|
||||||
"typst-assets",
|
"typst-assets",
|
||||||
"typst-html",
|
"typst-html",
|
||||||
@@ -6345,6 +6387,12 @@ dependencies = [
|
|||||||
"xmlwriter",
|
"xmlwriter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf16_iter"
|
name = "utf16_iter"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ base64 = "0.22"
|
|||||||
diffy = "0.4"
|
diffy = "0.4"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
ureq = { version = "2.12", features = ["json"] }
|
ureq = { version = "2.12", features = ["json"] }
|
||||||
|
tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -19,7 +19,7 @@ pub struct DocumentLink {
|
|||||||
pub synced_at: Option<String>,
|
pub synced_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA: [&str; 5] = [
|
const SCHEMA: [&str; 6] = [
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
"CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -51,6 +51,11 @@ const SCHEMA: [&str; 5] = [
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
source_modified INTEGER 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] = [
|
const MIGRATIONS: [&str; 2] = [
|
||||||
@@ -416,4 +421,30 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cloud_cache(&self, key: &str) -> Result<Option<String>, 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(())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-4
@@ -6,6 +6,7 @@ mod sync;
|
|||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
mod world;
|
mod world;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -16,6 +17,7 @@ use assets::Asset;
|
|||||||
use db::Store;
|
use db::Store;
|
||||||
use compiler::{CompileResult, Diagnostic};
|
use compiler::{CompileResult, Diagnostic};
|
||||||
use lsp::{LspHandle, LspState};
|
use lsp::{LspHandle, LspState};
|
||||||
|
use ws::WsState;
|
||||||
use sync::{Account, ProjectSummary, SyncReport};
|
use sync::{Account, ProjectSummary, SyncReport};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
|
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()
|
store.clear_thumbnails()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_cloud_cache(store: State<'_, Store>, key: String) -> Result<Option<String>, 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]
|
#[tauri::command]
|
||||||
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
|
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
|
||||||
assets::list_assets(&app, &store)
|
assets::list_assets(&app, &store)
|
||||||
@@ -703,6 +715,7 @@ fn cloud_check_compatibility(server_url: String) -> sync::CompatibilityStatus {
|
|||||||
fn cloud_login(
|
fn cloud_login(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
store: State<'_, Store>,
|
store: State<'_, Store>,
|
||||||
|
ws_state: State<'_, WsState>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
@@ -712,12 +725,14 @@ fn cloud_login(
|
|||||||
let response = sync::login(&server_url, &email, &password, &device_name)?;
|
let response = sync::login(&server_url, &email, &password, &device_name)?;
|
||||||
|
|
||||||
let mut settings = load_settings(&app, &store)?;
|
let mut settings = load_settings(&app, &store)?;
|
||||||
settings.server_url = server_url;
|
settings.server_url = server_url.clone();
|
||||||
settings.device_token = Some(response.token);
|
settings.device_token = Some(response.token.clone());
|
||||||
settings.account_email = Some(response.email.clone());
|
settings.account_email = Some(response.email.clone());
|
||||||
settings.account_username = Some(response.username.clone());
|
settings.account_username = Some(response.username.clone());
|
||||||
save_settings(&store, &settings)?;
|
save_settings(&store, &settings)?;
|
||||||
|
|
||||||
|
ws_state.start(app, server_url, response.token);
|
||||||
|
|
||||||
Ok(Account {
|
Ok(Account {
|
||||||
user_id: response.user_id,
|
user_id: response.user_id,
|
||||||
username: response.username,
|
username: response.username,
|
||||||
@@ -726,7 +741,11 @@ fn cloud_login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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)?;
|
let mut settings = load_settings(&app, &store)?;
|
||||||
if let Some(token) = &settings.device_token {
|
if let Some(token) = &settings.device_token {
|
||||||
let _ = sync::logout(&settings.server_url, 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.device_token = None;
|
||||||
settings.account_email = None;
|
settings.account_email = None;
|
||||||
settings.account_username = 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<String, String> {
|
||||||
|
Ok(ws_state.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1311,6 +1354,7 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.manage(LspState::default())
|
.manage(LspState::default())
|
||||||
|
.manage(WsState::default())
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if matches!(event, tauri::WindowEvent::Destroyed) {
|
if matches!(event, tauri::WindowEvent::Destroyed) {
|
||||||
if let Some(state) = window.app_handle().try_state::<LspState>() {
|
if let Some(state) = window.app_handle().try_state::<LspState>() {
|
||||||
@@ -1342,6 +1386,8 @@ pub fn run() {
|
|||||||
thumbnail,
|
thumbnail,
|
||||||
read_image,
|
read_image,
|
||||||
clear_thumbnails,
|
clear_thumbnails,
|
||||||
|
get_cloud_cache,
|
||||||
|
save_cloud_cache,
|
||||||
list_assets,
|
list_assets,
|
||||||
list_resources,
|
list_resources,
|
||||||
list_font_families,
|
list_font_families,
|
||||||
@@ -1357,6 +1403,9 @@ pub fn run() {
|
|||||||
cloud_login,
|
cloud_login,
|
||||||
cloud_logout,
|
cloud_logout,
|
||||||
cloud_account,
|
cloud_account,
|
||||||
|
cloud_ws_start,
|
||||||
|
cloud_ws_stop,
|
||||||
|
cloud_ws_status,
|
||||||
cloud_list_projects,
|
cloud_list_projects,
|
||||||
cloud_list_folders,
|
cloud_list_folders,
|
||||||
cloud_create_folder,
|
cloud_create_folder,
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WsState {
|
||||||
|
generation: AtomicU64,
|
||||||
|
status: Mutex<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<WsState>().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<TcpStream>) {
|
||||||
|
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::<WsState>().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::<DeviceEvent>(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::<WsState>().set_status(&app, "offline");
|
||||||
|
std::thread::sleep(RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -947,6 +947,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if app.cloudOffline}
|
||||||
|
<div class="mb-3 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-3 py-2 text-xs text-[var(--color-ink-muted)]">
|
||||||
|
<Icon icon="ph:wifi-slash" class="text-base" />
|
||||||
|
Offline — showing last synced data
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if cloudTrail.length > 0}
|
{#if cloudTrail.length > 0}
|
||||||
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
|
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -454,8 +454,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span class="text-[var(--color-ink-muted)]">
|
<span class="text-[var(--color-ink-muted)]">
|
||||||
Pulls and pushes cloud-linked projects on a timer. Conflicts pause
|
Pulls and pushes cloud-linked projects on a timer. Used as a
|
||||||
syncing until they are resolved.
|
fallback when live sync (websocket) is unavailable. Conflicts
|
||||||
|
pause syncing until they are resolved.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,6 +277,24 @@ export const cloudLogout = () => invoke<void>("cloud_logout");
|
|||||||
|
|
||||||
export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
||||||
|
|
||||||
|
export const cloudWsStart = () => invoke<void>("cloud_ws_start");
|
||||||
|
|
||||||
|
export const cloudWsStop = () => invoke<void>("cloud_ws_stop");
|
||||||
|
|
||||||
|
export const cloudWsStatus = () => invoke<string>("cloud_ws_status");
|
||||||
|
|
||||||
|
export interface DeviceEvent {
|
||||||
|
kind: "project" | "document" | "structure";
|
||||||
|
project_id: string | null;
|
||||||
|
document_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCloudCache = (key: string) =>
|
||||||
|
invoke<string | null>("get_cloud_cache", { key });
|
||||||
|
|
||||||
|
export const saveCloudCache = (key: string, payload: string) =>
|
||||||
|
invoke<void>("save_cloud_cache", { key, payload });
|
||||||
|
|
||||||
export const cloudListProjects = () =>
|
export const cloudListProjects = () =>
|
||||||
invoke<ProjectSummary[]>("cloud_list_projects");
|
invoke<ProjectSummary[]>("cloud_list_projects");
|
||||||
|
|
||||||
|
|||||||
+89
-16
@@ -1,3 +1,4 @@
|
|||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
@@ -7,6 +8,7 @@ import type {
|
|||||||
CloudFolder,
|
CloudFolder,
|
||||||
CompileResult,
|
CompileResult,
|
||||||
Conflict,
|
Conflict,
|
||||||
|
DeviceEvent,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
DocumentLink,
|
DocumentLink,
|
||||||
LinkedDocument,
|
LinkedDocument,
|
||||||
@@ -47,6 +49,8 @@ interface AppState {
|
|||||||
cloudDocuments: CloudDocument[];
|
cloudDocuments: CloudDocument[];
|
||||||
cloudFiles: CloudFile[];
|
cloudFiles: CloudFile[];
|
||||||
cloudLoading: boolean;
|
cloudLoading: boolean;
|
||||||
|
cloudOffline: boolean;
|
||||||
|
wsStatus: string;
|
||||||
linkedDocuments: LinkedDocument[];
|
linkedDocuments: LinkedDocument[];
|
||||||
linkedProjects: LinkedProject[];
|
linkedProjects: LinkedProject[];
|
||||||
documentLink: DocumentLink | null;
|
documentLink: DocumentLink | null;
|
||||||
@@ -89,6 +93,8 @@ export const app = $state<AppState>({
|
|||||||
cloudDocuments: [],
|
cloudDocuments: [],
|
||||||
cloudFiles: [],
|
cloudFiles: [],
|
||||||
cloudLoading: false,
|
cloudLoading: false,
|
||||||
|
cloudOffline: false,
|
||||||
|
wsStatus: "offline",
|
||||||
linkedDocuments: [],
|
linkedDocuments: [],
|
||||||
linkedProjects: [],
|
linkedProjects: [],
|
||||||
documentLink: null,
|
documentLink: null,
|
||||||
@@ -340,6 +346,10 @@ export async function bootstrap() {
|
|||||||
try {
|
try {
|
||||||
app.settings = await api.getSettings();
|
app.settings = await api.getSettings();
|
||||||
restartAutoSync();
|
restartAutoSync();
|
||||||
|
await initWsSync();
|
||||||
|
if (app.settings?.device_token) {
|
||||||
|
api.cloudWsStart().catch(() => {});
|
||||||
|
}
|
||||||
await browseTo("");
|
await browseTo("");
|
||||||
await refreshAccount();
|
await refreshAccount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -347,6 +357,37 @@ export async function bootstrap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initWsSync() {
|
||||||
|
await listen<string>("cloud://ws-status", (event) => {
|
||||||
|
app.wsStatus = event.payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
await listen<DeviceEvent>("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) {
|
export async function browseTo(path: string) {
|
||||||
try {
|
try {
|
||||||
app.entries = await api.browseWorkspace(path);
|
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() {
|
export async function refreshCloud() {
|
||||||
if (!app.account) return;
|
if (!app.account) return;
|
||||||
|
|
||||||
app.cloudLoading = true;
|
app.cloudLoading = true;
|
||||||
|
const cacheKey = cloudCacheKey();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []);
|
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []);
|
||||||
app.linkedProjects = await api.cloudLinkedProjects().catch(() => []);
|
app.linkedProjects = await api.cloudLinkedProjects().catch(() => []);
|
||||||
|
|
||||||
|
let snapshot: CloudSnapshot;
|
||||||
if (app.cloudFolder === "shared") {
|
if (app.cloudFolder === "shared") {
|
||||||
const shared = await api.cloudListShared();
|
const shared = await api.cloudListShared();
|
||||||
app.cloudDocuments = shared.documents;
|
snapshot = { folders: [], documents: shared.documents, projects: shared.projects, files: [] };
|
||||||
app.cloudProjects = shared.projects;
|
|
||||||
app.cloudFolders = [];
|
|
||||||
app.cloudFiles = [];
|
|
||||||
} else {
|
} else {
|
||||||
const [folders, documents, projects, files] = await Promise.all([
|
const [folders, documents, projects, files] = await Promise.all([
|
||||||
api.cloudListFolders(),
|
api.cloudListFolders(),
|
||||||
@@ -403,20 +476,20 @@ export async function refreshCloud() {
|
|||||||
api.cloudListProjects(),
|
api.cloudListProjects(),
|
||||||
api.cloudListFiles(app.cloudFolder),
|
api.cloudListFiles(app.cloudFolder),
|
||||||
]);
|
]);
|
||||||
app.cloudFolderTree = folders;
|
snapshot = { folders, documents, projects, files };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyCloudSnapshot(snapshot);
|
||||||
|
app.cloudOffline = false;
|
||||||
|
api.saveCloudCache(cacheKey, JSON.stringify(snapshot)).catch(() => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const cached = await api.getCloudCache(cacheKey).catch(() => null);
|
||||||
|
if (cached) {
|
||||||
|
applyCloudSnapshot(JSON.parse(cached));
|
||||||
|
app.cloudOffline = true;
|
||||||
|
} else {
|
||||||
setError(error);
|
setError(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
app.cloudLoading = false;
|
app.cloudLoading = false;
|
||||||
}
|
}
|
||||||
@@ -668,7 +741,7 @@ export function restartAutoSync() {
|
|||||||
if (seconds <= 0) return;
|
if (seconds <= 0) return;
|
||||||
|
|
||||||
syncTimer = setInterval(() => {
|
syncTimer = setInterval(() => {
|
||||||
autoSync();
|
if (app.wsStatus !== "connected") autoSync();
|
||||||
}, seconds * 1000);
|
}, seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,12 @@
|
|||||||
on: "LSP ready",
|
on: "LSP ready",
|
||||||
unavailable: "LSP unavailable",
|
unavailable: "LSP unavailable",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wsLabel: Record<string, string> = {
|
||||||
|
connected: "Live sync",
|
||||||
|
connecting: "Connecting…",
|
||||||
|
offline: "Offline (polling)",
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
@@ -588,6 +594,23 @@
|
|||||||
{lspLabel[app.lspStatus]}
|
{lspLabel[app.lspStatus]}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{#if app.account}
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||||
|
title="Cloud sync connection"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full
|
||||||
|
{app.wsStatus === 'connected'
|
||||||
|
? 'bg-[var(--color-success)]'
|
||||||
|
: app.wsStatus === 'connecting'
|
||||||
|
? 'bg-[var(--color-accent)]'
|
||||||
|
: 'bg-[var(--color-ink-muted)]'}"
|
||||||
|
></span>
|
||||||
|
{wsLabel[app.wsStatus] ?? "Offline (polling)"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||||
onclick={saveAndCompile}
|
onclick={saveAndCompile}
|
||||||
|
|||||||
Reference in New Issue
Block a user