Files
zerogravity/src/warmup.rs
Nikketryhard 6a07786c4e feat: implement headless LS authentication via state sync
Reverse-engineered the UnifiedStateSyncUpdate protocol:
- initial_state field is bytes (not string), contains serialized Topic proto
- Map key for OAuth is 'oauthTokenInfoSentinelKey'
- Row.value is base64-encoded OAuthTokenInfo protobuf
- OAuthTokenInfo includes access_token, token_type, expiry (Timestamp)
- Set far-future expiry (2099) to prevent token expiry errors

Also fixed:
- PushUnifiedStateSyncUpdate returns proper empty proto response
- Stream keep-alive avoids sending empty envelopes (LS rejects nil updates)
- uss-enterprisePreferences topic handled (empty initial state)
2026-02-15 21:40:35 -06:00

78 lines
3.1 KiB
Rust

//! Startup warmup and periodic heartbeat — mimics real webview lifecycle.
//!
//! The real Electron webview calls these methods on startup and then sends
//! Heartbeat every ~30 seconds. Without this, the LS sees a "user" that
//! never initializes and never heartbeats — an obvious bot fingerprint.
use crate::backend::Backend;
use rand::Rng;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
/// Run the exact startup sequence the real webview performs on load.
///
/// Called BEFORE accepting any API requests. Each call is fire-and-forget
/// (we don't care if some fail — the LS might not support all methods).
pub async fn warmup_sequence(backend: &Backend) {
info!("Running webview warmup sequence...");
let calls: &[(&str, serde_json::Value)] = &[
("GetStatus", serde_json::json!({})),
("Heartbeat", serde_json::json!({})),
("GetUserStatus", serde_json::json!({})),
("GetCascadeModelConfigs", serde_json::json!({})),
("GetCascadeModelConfigData", serde_json::json!({})),
("GetWorkspaceInfos", serde_json::json!({})),
("GetWorkingDirectories", serde_json::json!({})),
("GetAllCascadeTrajectories", serde_json::json!({})),
("GetMcpServerStates", serde_json::json!({})),
("GetWebDocsOptions", serde_json::json!({})),
("GetRepoInfos", serde_json::json!({})),
("GetAllSkills", serde_json::json!({})),
("InitializeCascadePanelState", serde_json::json!({})),
];
for (method, body) in calls {
// Timeout per call — in headless mode, the LS can't reach Google's API
// so these would hang forever without a timeout. Warmup is best-effort.
match tokio::time::timeout(
Duration::from_secs(5),
backend.call_json(method, body),
)
.await
{
Ok(Ok((status, _))) => debug!("Warmup {method}: {status}"),
Ok(Err(e)) => warn!("Warmup {method} failed: {e}"),
Err(_) => warn!("Warmup {method} timed out"),
}
// Small delay between calls — real webview doesn't blast them instantly
let delay = rand::thread_rng().gen_range(50..200);
tokio::time::sleep(Duration::from_millis(delay)).await;
}
info!("Warmup complete");
}
/// Spawn a background task that sends Heartbeat every ~30s ± jitter.
///
/// Returns a JoinHandle that runs until the task is aborted (on shutdown).
pub fn start_heartbeat(backend: Arc<Backend>) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
// ~30s interval (± 500ms) — matches real setInterval(30000) precision
let interval_ms = rand::thread_rng().gen_range(29_500..30_500);
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
match backend
.call_json("Heartbeat", &serde_json::json!({}))
.await
{
Ok((status, _)) => debug!("Heartbeat: {status}"),
Err(e) => warn!("Heartbeat failed: {e}"),
}
}
})
}