diff --git a/package.json b/package.json index 6382e9b73..bf986b8f7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "compile": "tsc --noEmit", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev", + "tauri:dev": "RUST_LOG=debug source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri dev", "macos:build:debug": "yarn macos:build:release --debug", "macos:build:release": "source scripts/load-dotenv.sh 2>/dev/null; (cd src-tauri && bash scripts/download-tdlib.sh) && LOCAL_TDLIB_PATH=$(pwd)/src-tauri/tdlib-cache/macos-$(uname -m | sed 's/arm64/aarch64/; s/x86_64/x86_64/') tauri build --bundles app dmg", "macos:build:sign:release": "source scripts/load-env.sh && tauri build --bundles app dmg", diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs index 80e451092..a8f038210 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_net.rs @@ -3,84 +3,123 @@ use parking_lot::RwLock; use rquickjs::{function::Async, Ctx, Function, Object}; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use super::types::{js_err, WebSocketConnection, WebSocketState}; -pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc>) -> rquickjs::Result<()> { +/// Shared HTTP client — built once, reused across all fetch calls. +/// Using a shared client enables connection pooling, persistent TLS sessions, +/// and prevents per-request TLS handshake overhead that can cause hangs. +static HTTP_CLIENT: OnceLock = OnceLock::new(); + +fn get_http_client() -> &'static reqwest::Client { + HTTP_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .use_rustls_tls() + .connect_timeout(std::time::Duration::from_secs(10)) + .pool_idle_timeout(std::time::Duration::from_secs(90)) + .pool_max_idle_per_host(10) + .build() + .expect("failed to build shared HTTP client") + }) +} + +pub fn register<'js>( + ctx: &Ctx<'js>, + ops: &Object<'js>, + ws_state: Arc>, +) -> rquickjs::Result<()> { // ======================================================================== // Fetch (1) - ASYNC // ======================================================================== - ops.set("fetch", Function::new(ctx.clone(), - Async(move |url: String, options: String| async move { - let opts: serde_json::Value = - serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?; + ops.set( + "fetch", + Function::new( + ctx.clone(), + Async(move |url: String, options: String| async move { + let opts: serde_json::Value = + serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?; - let method = opts["method"].as_str().unwrap_or("GET"); - let headers_obj = opts["headers"].as_object(); - let body = opts["body"].as_str(); - let timeout_secs = opts["timeout"] - .as_u64() - .or_else(|| opts["timeout"].as_f64().map(|f| f as u64)) - .unwrap_or(30); + let method = opts["method"].as_str().unwrap_or("GET"); + let headers_obj = opts["headers"].as_object(); + let body = opts["body"].as_str(); + let timeout_secs = opts["timeout"] + .as_u64() + .or_else(|| opts["timeout"].as_f64().map(|f| f as u64)) + .unwrap_or(30); - let client = reqwest::Client::builder() - .use_rustls_tls() - .build() - .map_err(|e| js_err(e.to_string()))?; - let mut req = match method { - "GET" => client.get(&url), - "POST" => client.post(&url), - "PUT" => client.put(&url), - "PATCH" => client.patch(&url), - "DELETE" => client.delete(&url), - _ => client.get(&url), - }; + let client = get_http_client(); + let mut req = match method { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "PATCH" => client.patch(&url), + "DELETE" => client.delete(&url), + _ => client.get(&url), + }; - req = req.timeout(std::time::Duration::from_secs(timeout_secs)); + req = req.timeout(std::time::Duration::from_secs(timeout_secs)); - if let Some(h) = headers_obj { - for (k, v) in h { - if let Some(val_str) = v.as_str() { - req = req.header(k, val_str); + if let Some(h) = headers_obj { + for (k, v) in h { + if let Some(val_str) = v.as_str() { + req = req.header(k, val_str); + } } } - } - if let Some(b) = body { - req = req.body(b.to_string()); - } - - let response = req.send().await.map_err(|e| { - let mut msg = e.to_string(); - let mut source = std::error::Error::source(&e); - while let Some(cause) = source { - msg.push_str(&format!(" | caused by: {cause}")); - source = std::error::Error::source(cause); + if let Some(b) = body { + req = req.body(b.to_string()); } - js_err(msg) - })?; - let status = response.status().as_u16(); - let status_text = response.status().canonical_reason().unwrap_or("").to_string(); - let headers: HashMap = response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - let body_text = response.text().await.map_err(|e| js_err(e.to_string()))?; + // Hard safety net: tokio timeout wraps send+body read so a stalled + // connection cannot block the QuickJS event loop indefinitely even + // if the per-request timeout on the reqwest builder fails to fire. + let total_deadline = std::time::Duration::from_secs(timeout_secs + 5); + let response = tokio::time::timeout(total_deadline, req.send()) + .await + .map_err(|_| js_err(format!("request timed out after {}s", timeout_secs + 5)))? + .map_err(|e| { + let mut msg = e.to_string(); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + msg.push_str(&format!(" | caused by: {cause}")); + source = std::error::Error::source(cause); + } + js_err(msg) + })?; - let result = serde_json::json!({ - "status": status, - "statusText": status_text, - "headers": headers, - "body": body_text, - }); + let status = response.status().as_u16(); + let status_text = response + .status() + .canonical_reason() + .unwrap_or("") + .to_string(); + let headers: HashMap = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + let body_text = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs + 5), + response.text(), + ) + .await + .map_err(|_| js_err(format!("body read timed out after {}s", timeout_secs + 5)))? + .map_err(|e| js_err(e.to_string()))?; - Ok::(result.to_string()) - }), - ))?; + let result = serde_json::json!({ + "status": status, + "statusText": status_text, + "headers": headers, + "body": body_text, + }); + + Ok::(result.to_string()) + }), + ), + )?; // ======================================================================== // WebSocket (4) - placeholders @@ -88,43 +127,57 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc(id) - } - }), - ))?; + ops.set( + "ws_connect", + Function::new( + ctx.clone(), + Async(move |url: String| { + let ws = ws.clone(); + async move { + let mut state = ws.write(); + let id = state.next_id; + state.next_id += 1; + state.connections.insert(id, WebSocketConnection { url }); + Ok::(id) + } + }), + ), + )?; } { let ws = ws_state.clone(); - ops.set("ws_send", Function::new(ctx.clone(), move |_id: u32, _data: String| { - let _state = ws.read(); - }))?; + ops.set( + "ws_send", + Function::new(ctx.clone(), move |_id: u32, _data: String| { + let _state = ws.read(); + }), + )?; } { let ws = ws_state.clone(); - ops.set("ws_recv", Function::new(ctx.clone(), - Async(move |_id: u32| { - let _ws = ws.clone(); - async move { Ok::, rquickjs::Error>(None) } - }), - ))?; + ops.set( + "ws_recv", + Function::new( + ctx.clone(), + Async(move |_id: u32| { + let _ws = ws.clone(); + async move { Ok::, rquickjs::Error>(None) } + }), + ), + )?; } { let ws = ws_state; - ops.set("ws_close", Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| { - let mut state = ws.write(); - state.connections.remove(&id); - }))?; + ops.set( + "ws_close", + Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| { + let mut state = ws.write(); + state.connections.remove(&id); + }), + )?; } Ok(()) } diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index b823f4e21..3ba94c333 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -20,7 +20,7 @@ import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { - GmailEmailSummary, + GmailEmailBatch, type GmailProfile, setGmailEmails, setGmailProfile, @@ -100,11 +100,7 @@ function syncGmailStateToSlice( : null ) ); - dispatch( - setGmailEmails( - Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] - ) - ); + dispatch(setGmailEmails(gmailState.emails as GmailEmailBatch | null)); syncGmailMetadataToBackend(gmailState as GmailStateForSync); } diff --git a/src/store/gmailSlice.ts b/src/store/gmailSlice.ts index 2622132f2..79a8b5bd4 100644 --- a/src/store/gmailSlice.ts +++ b/src/store/gmailSlice.ts @@ -1,12 +1,30 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -export interface GmailEmailSummary { - id: string; +export interface GmailEmailEntity { + identifier: string; + kind: 'sender' | 'recipient' | 'recipient_cc' | string; + name: string; +} + +export interface GmailEmailMetadata { + emailId: string; threadId: string; - snippet?: string; - subject?: string; - from?: string; - date?: string; + date: number; +} + +export interface GmailEmailChunk { + content: string; + entities: GmailEmailEntity[]; + labels: string[]; + metadata: GmailEmailMetadata; + title: string; +} + +export interface GmailEmailBatch { + chunks: GmailEmailChunk[]; // up to 20 in your example + createdAt: number; // when this batch was generated + emailIds: string[]; // same ids as in chunks[*].emailId + total: number; // total emails in this batch } export interface GmailProfile { @@ -18,22 +36,22 @@ export interface GmailProfile { interface GmailState { /** Emails fetched after OAuth connection (from Gmail skill) */ - emails: GmailEmailSummary[]; + emails: GmailEmailBatch | null; /** Profile of the connected Gmail user (from Gmail skill) */ profile: GmailProfile | null; } -const initialState: GmailState = { emails: [], profile: null }; +const initialState: GmailState = { emails: null, profile: null }; const gmailSlice = createSlice({ name: 'gmail', initialState, reducers: { - setGmailEmails(state, action: PayloadAction) { + setGmailEmails(state, action: PayloadAction) { state.emails = action.payload; }, clearGmailEmails(state) { - state.emails = []; + state.emails = null; }, setGmailProfile(state, action: PayloadAction) { state.profile = action.payload; diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index db5208b69..29989abf6 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -240,6 +240,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { } await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential); + await skillManager.triggerSync(skillId); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); }