From c7ae93c8f3142396abd1f694bffdf8e979a2a9f2 Mon Sep 17 00:00:00 2001 From: silenthillzeroq Date: Tue, 21 Apr 2026 19:08:40 +0300 Subject: [PATCH 1/2] Release 0.3.4 with Groq support and keyboard hook fix --- extension/manifest.json | 4 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 4 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/local-ai-protocol/Cargo.toml | 2 +- .../sidecar/clipnova-ai-worker/Cargo.lock | 4 +- .../sidecar/clipnova-ai-worker/Cargo.toml | 2 +- src-tauri/src/ai.rs | 143 +++++++++++++++++- src-tauri/src/clipboard.rs | 4 +- src-tauri/src/commands.rs | 1 + src-tauri/src/keyboard_hook.rs | 32 ++-- src-tauri/src/local_ai_router.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/__tests__/lib/aiCapabilities.test.ts | 10 ++ src/components/settings/AiWelcomeWizard.tsx | 54 +++++-- src/components/settings/SettingsView.css | 13 ++ src/components/settings/SettingsView.tsx | 102 ++++++++++--- src/i18n/locales/de.json | 11 +- src/i18n/locales/en.json | 11 +- src/i18n/locales/es.json | 11 +- src/i18n/locales/fr.json | 11 +- src/i18n/locales/pl.json | 11 +- src/i18n/locales/ru.json | 11 +- src/i18n/locales/uk.json | 11 +- src/i18n/locales/zh.json | 11 +- src/lib/aiCapabilities.ts | 31 +++- src/lib/types.ts | 12 ++ 28 files changed, 408 insertions(+), 111 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index 0aa6f0a..e015429 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "Clipnova — Clipboard Companion", + "name": "Clipnova — Clipboard Companion", "description": "Your clipboard history right in the browser. Synced with the Clipnova desktop app.", - "version": "0.3.0", + "version": "0.3.4", "browser_specific_settings": { "gecko": { "id": "clipnova@clipnova.app", diff --git a/package-lock.json b/package-lock.json index b51599a..0371c2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipnova", - "version": "0.3.0", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipnova", - "version": "0.3.0", + "version": "0.3.4", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index 9a3364f..cdddd39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clipnova", "private": true, - "version": "0.3.0", + "version": "0.3.4", "license": "GPL-3.0-only", "description": "Open-source clipboard manager for Windows with AI, privacy-first workflows, 36 themes, and QuickPaste.", "author": "silenthillzeroq", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5d7a3a..2e9794e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "clipnova" -version = "0.3.0" +version = "0.3.4" dependencies = [ "aes-gcm", "arboard", @@ -2454,7 +2454,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ai-protocol" -version = "0.3.0" +version = "0.3.4" dependencies = [ "serde", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1b45cd6..b5b73cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "clipnova" -version = "0.3.0" -description = "Clipboard manager of the future — Mission Control for everything you copy" +version = "0.3.4" +description = "Clipboard manager of the future — Mission Control for everything you copy" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/local-ai-protocol/Cargo.toml b/src-tauri/crates/local-ai-protocol/Cargo.toml index 20c3245..afec4f3 100644 --- a/src-tauri/crates/local-ai-protocol/Cargo.toml +++ b/src-tauri/crates/local-ai-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "local-ai-protocol" -version = "0.3.0" +version = "0.3.4" edition = "2021" [dependencies] diff --git a/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock b/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock index 1800dae..516b27a 100644 --- a/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock +++ b/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock @@ -56,7 +56,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clipnova-ai-worker" -version = "0.3.0" +version = "0.3.4" dependencies = [ "local-ai-protocol", "reqwest", @@ -415,7 +415,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ai-protocol" -version = "0.3.0" +version = "0.3.4" dependencies = [ "serde", ] diff --git a/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml b/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml index be56021..461ef69 100644 --- a/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml +++ b/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clipnova-ai-worker" -version = "0.3.0" +version = "0.3.4" edition = "2021" [dependencies] diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs index 154bd1a..0392cc3 100644 --- a/src-tauri/src/ai.rs +++ b/src-tauri/src/ai.rs @@ -2,10 +2,17 @@ use serde::{Deserialize, Serialize}; /// Supported AI providers #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] pub enum AiProvider { + #[serde(alias = "Gemini")] Gemini, + #[serde(alias = "OpenAI")] OpenAI, + #[serde(alias = "Anthropic")] Anthropic, + #[serde(alias = "Groq")] + Groq, + #[serde(alias = "Ollama")] Ollama, } @@ -58,6 +65,7 @@ pub fn provider_label(provider: &AiProvider) -> &'static str { AiProvider::Gemini => "Gemini", AiProvider::OpenAI => "OpenAI", AiProvider::Anthropic => "Anthropic", + AiProvider::Groq => "Groq", AiProvider::Ollama => "Ollama", } } @@ -140,6 +148,65 @@ async fn fetch_openai_models(api_key: &str) -> Result, String> { Ok(models) } +fn is_groq_generation_model(model_id: &str) -> bool { + let id = model_id.to_ascii_lowercase(); + !(id.contains("whisper") + || id.contains("orpheus") + || id.contains("prompt-guard") + || id.contains("safeguard") + || id.starts_with("groq/compound")) +} + +fn groq_base_url(base_url: Option<&str>) -> &str { + base_url + .filter(|value| !value.trim().is_empty()) + .unwrap_or("https://api.groq.com/openai") +} + +/// Validate API key and fetch currently active text-generation models from Groq. +async fn fetch_groq_models(api_key: &str) -> Result, String> { + let client = reqwest::Client::new(); + let resp = client + .get("https://api.groq.com/openai/v1/models") + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Invalid API key (HTTP {})", resp.status().as_u16())); + } + + #[derive(Deserialize)] + struct GroqModel { + id: String, + owned_by: Option, + } + #[derive(Deserialize)] + struct GroqResponse { + data: Vec, + } + + let body: GroqResponse = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; + + let mut models: Vec = body + .data + .into_iter() + .filter(|model| is_groq_generation_model(&model.id)) + .map(|model| ModelInfo { + name: model.id.clone(), + id: model.id, + description: model.owned_by, + }) + .collect(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) +} + /// Validate API key and fetch models from Google Gemini async fn fetch_gemini_models(api_key: &str) -> Result, String> { let client = reqwest::Client::new(); @@ -326,6 +393,7 @@ pub async fn validate_and_list_models( "openai" => fetch_openai_models(api_key).await, "gemini" => fetch_gemini_models(api_key).await, "anthropic" => fetch_anthropic_models(api_key).await, + "groq" => fetch_groq_models(api_key).await, "ollama" => fetch_ollama_models(base_url).await, _ => Err(format!("Unknown provider: {}", provider)), }; @@ -558,7 +626,7 @@ pub(crate) fn build_clip_analysis_prompt(content: &str, content_type: &str) -> S ) } -/// Call OpenAI-compatible API (works for OpenAI + Ollama with base_url) +/// Call OpenAI-compatible API (works for OpenAI, Groq, and Ollama with base_url) async fn call_openai_api( content: &str, api_key: &str, @@ -753,6 +821,7 @@ pub async fn process_clip_with_ai( AiProvider::OpenAI => "gpt-4o-mini", AiProvider::Gemini => "gemini-2.0-flash", AiProvider::Anthropic => "claude-3-5-haiku-20241022", + AiProvider::Groq => "llama-3.3-70b-versatile", AiProvider::Ollama => "llama3.2", }); @@ -779,6 +848,15 @@ pub async fn process_clip_with_ai( AiProvider::OpenAI => call_openai_api(content, &config.api_key, model, None).await, AiProvider::Gemini => call_gemini_api(content, &config.api_key, model).await, AiProvider::Anthropic => call_anthropic_api(content, &config.api_key, model).await, + AiProvider::Groq => { + call_openai_api( + content, + &config.api_key, + model, + Some(groq_base_url(config.base_url.as_deref())), + ) + .await + } AiProvider::Ollama => { let base = config .base_url @@ -987,6 +1065,10 @@ pub async fn generate_embedding(text: &str, config: &AiConfig) -> Result Err( + "Groq does not support embeddings in this cloud route. Use OpenAI, Gemini, or Ollama." + .to_string(), + ), AiProvider::Ollama => { let base = config .base_url @@ -1142,6 +1224,7 @@ pub async fn compose_ai_transform( AiProvider::OpenAI => "gpt-4o-mini", AiProvider::Gemini => "gemini-2.0-flash", AiProvider::Anthropic => "claude-3-5-haiku-20241022", + AiProvider::Groq => "llama-3.3-70b-versatile", AiProvider::Ollama => "llama3.2", }); @@ -1157,6 +1240,16 @@ pub async fn compose_ai_transform( AiProvider::Anthropic => { compose_call_anthropic(system_prompt, truncated, &config.api_key, model).await } + AiProvider::Groq => { + compose_call_openai( + system_prompt, + truncated, + &config.api_key, + model, + Some(groq_base_url(config.base_url.as_deref())), + ) + .await + } AiProvider::Ollama => { let base = config .base_url @@ -1182,6 +1275,7 @@ pub async fn generate_text_with_ai( AiProvider::OpenAI => "gpt-4o-mini", AiProvider::Gemini => "gemini-2.0-flash", AiProvider::Anthropic => "claude-3-5-haiku-20241022", + AiProvider::Groq => "llama-3.3-70b-versatile", AiProvider::Ollama => "llama3.2", }); @@ -1193,6 +1287,16 @@ pub async fn generate_text_with_ai( AiProvider::Anthropic => { call_anthropic_raw(prompt, &config.api_key, model, max_tokens).await } + AiProvider::Groq => { + call_openai_raw( + prompt, + &config.api_key, + model, + Some(groq_base_url(config.base_url.as_deref())), + max_tokens, + ) + .await + } AiProvider::Ollama => { let base = config .base_url @@ -1571,6 +1675,7 @@ pub async fn transform_with_ai( AiProvider::OpenAI => "gpt-4o-mini", AiProvider::Gemini => "gemini-2.0-flash", AiProvider::Anthropic => "claude-3-5-haiku-20241022", + AiProvider::Groq => "llama-3.3-70b-versatile", AiProvider::Ollama => "llama3.2", }); @@ -1578,6 +1683,16 @@ pub async fn transform_with_ai( AiProvider::OpenAI => call_openai_raw(&prompt, &config.api_key, model, None, 2000).await, AiProvider::Gemini => call_gemini_raw(&prompt, &config.api_key, model, 2000).await, AiProvider::Anthropic => call_anthropic_raw(&prompt, &config.api_key, model, 2000).await, + AiProvider::Groq => { + call_openai_raw( + &prompt, + &config.api_key, + model, + Some(groq_base_url(config.base_url.as_deref())), + 2000, + ) + .await + } AiProvider::Ollama => { let base = config .base_url @@ -1766,7 +1881,9 @@ async fn call_anthropic_raw( #[cfg(test)] mod tests { - use super::{generate_text_with_ai, AiConfig}; + use super::{ + generate_text_with_ai, provider_supports_task, AiConfig, AiProvider, AiTaskCapability, + }; #[test] fn generate_text_requires_ai_configuration_for_cloud_providers() { @@ -1779,4 +1896,26 @@ mod tests { "AI not configured. Set up an API key in Settings -> AI." ); } + + #[test] + fn ai_provider_serializes_lowercase_and_keeps_legacy_aliases() { + let serialized = serde_json::to_string(&AiProvider::Groq).expect("serialize provider"); + assert_eq!(serialized, "\"groq\""); + + let legacy = serde_json::from_str::("\"OpenAI\"").expect("legacy provider"); + assert_eq!(legacy, AiProvider::OpenAI); + } + + #[test] + fn groq_is_generation_only_provider() { + assert!(provider_supports_task( + &AiProvider::Groq, + AiTaskCapability::Generation + )); + assert!(!provider_supports_task( + &AiProvider::Groq, + AiTaskCapability::Embedding + )); + assert!(!provider_supports_task(&AiProvider::Groq, AiTaskCapability::Ocr)); + } } diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index e0908f7..a22a7d8 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -503,6 +503,7 @@ fn detect_sensitivity_level(text: &str) -> Option<&'static str> { "AIza", // Google API keys "ya29.", // Google OAuth tokens "anthropic-", // Anthropic keys + "gsk_", // Groq API keys "hf_", // HuggingFace tokens "npm_", // npm tokens "pypi-", // PyPI tokens @@ -873,7 +874,8 @@ pub fn start_monitor(app: AppHandle, db: Arc, ws_state: crate::ws_brid let mut clipboard = match Clipboard::new() { Ok(instance) => instance, Err(error) => { - consecutive_clipboard_failures = consecutive_clipboard_failures.saturating_add(1); + consecutive_clipboard_failures = + consecutive_clipboard_failures.saturating_add(1); if last_clipboard_error_log.elapsed() >= Duration::from_secs(2) { eprintln!( "[Clipnova] Clipboard monitor waiting for clipboard access: {}", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b7bea9d..bff162c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -732,6 +732,7 @@ pub fn save_ai_config( "openai" => crate::ai::AiProvider::OpenAI, "gemini" => crate::ai::AiProvider::Gemini, "anthropic" => crate::ai::AiProvider::Anthropic, + "groq" => crate::ai::AiProvider::Groq, "ollama" => crate::ai::AiProvider::Ollama, _ => return Err(format!("Unknown provider: {}", provider)), }; diff --git a/src-tauri/src/keyboard_hook.rs b/src-tauri/src/keyboard_hook.rs index 73a1a1b..22cb1fb 100644 --- a/src-tauri/src/keyboard_hook.rs +++ b/src-tauri/src/keyboard_hook.rs @@ -2,7 +2,8 @@ //! it to open ClipNova's QuickPaste popup instead of Windows Clipboard History. //! //! Uses a low-level keyboard hook (WH_KEYBOARD_LL) which sees keystrokes -//! before the Shell processes them, allowing us to "eat" Win+V. +//! before the Shell processes them, allowing us to suppress the Win+V key pair +//! while leaving the Win modifier state untouched. //! //! IMPORTANT: The hook callback must return within ~300ms (LowLevelHooksTimeout). //! If it takes longer, Windows silently removes the hook. Therefore, we NEVER @@ -36,10 +37,10 @@ mod win { static HOOK_HANDLE: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); static HOOK_THREAD_ID: AtomicU32 = AtomicU32::new(0); - /// Set after we eat Win+V so we also eat the subsequent Win key release. - /// Without this, the Shell sees "Win pressed → Win released" and opens - /// the clipboard/emoji panel on some Windows versions. - static ATE_WIN_V: AtomicBool = AtomicBool::new(false); + /// Set after we eat Win+V so we also eat the corresponding V key release. + /// We intentionally do NOT swallow Win key release events because that can + /// leave the OS behaving as if the Win key is still held down. + static SUPPRESS_V_KEYUP: AtomicBool = AtomicBool::new(false); /// Check if either Win key is currently physically held down. /// Uses GetAsyncKeyState which queries the hardware state directly, @@ -58,24 +59,22 @@ mod win { let vk = kb.vkCode; let msg = w_param as u32; - // --- Eat Win key UP after intercepting Win+V --- - // This prevents Shell from seeing a complete Win press/release - // cycle which would trigger the clipboard/emoji panel. - if (vk == VK_LWIN as u32 || vk == VK_RWIN as u32) + // Eat the matching V key release after we handled Win+V so no stray + // keyup reaches the foreground application. + if vk == VK_V && (msg == WM_KEYUP || msg == WM_SYSKEYUP) - && ATE_WIN_V.load(Ordering::SeqCst) + && SUPPRESS_V_KEYUP.swap(false, Ordering::SeqCst) { - ATE_WIN_V.store(false, Ordering::SeqCst); - return 1; // Eat the Win key UP + return 1; } - // Reset the flag if any OTHER key is pressed (not part of Win+V combo) + // Reset the suppression marker if a different key starts a new sequence. if vk != VK_V && vk != VK_LWIN as u32 && vk != VK_RWIN as u32 && (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN) { - ATE_WIN_V.store(false, Ordering::SeqCst); + SUPPRESS_V_KEYUP.store(false, Ordering::SeqCst); } // --- Intercept Win+V --- @@ -84,8 +83,9 @@ mod win { && is_win_key_held() && HOOK_ACTIVE.load(Ordering::SeqCst) { - // Mark that we ate Win+V so the next Win UP is also eaten - ATE_WIN_V.store(true, Ordering::SeqCst); + // Swallow the Win+V key pair, but let the actual Win release + // propagate normally so Windows never ends up with a "stuck" Win state. + SUPPRESS_V_KEYUP.store(true, Ordering::SeqCst); // CRITICAL: Do NOT call open_popup() here! // The LL hook callback must return within ~300ms or Windows diff --git a/src-tauri/src/local_ai_router.rs b/src-tauri/src/local_ai_router.rs index fd7c961..1ff44be 100644 --- a/src-tauri/src/local_ai_router.rs +++ b/src-tauri/src/local_ai_router.rs @@ -957,6 +957,7 @@ fn cloud_model_label(config: &AiConfig) -> String { ai::AiProvider::OpenAI => "text-embedding-3-small".to_string(), ai::AiProvider::Gemini => "gemini-embedding-001".to_string(), ai::AiProvider::Anthropic => "anthropic-no-embeddings".to_string(), + ai::AiProvider::Groq => "groq-no-embeddings".to_string(), ai::AiProvider::Ollama => "nomic-embed-text".to_string(), }) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 02f871c..bc608a0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Clipnova", - "version": "0.3.0", + "version": "0.3.4", "identifier": "com.clipnova.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/__tests__/lib/aiCapabilities.test.ts b/src/__tests__/lib/aiCapabilities.test.ts index 39cc93f..246262c 100644 --- a/src/__tests__/lib/aiCapabilities.test.ts +++ b/src/__tests__/lib/aiCapabilities.test.ts @@ -34,6 +34,16 @@ describe('aiCapabilities', () => { expect(readiness.capabilities.find((item) => item.id === 'embeddings')?.status).toBe('provider_limit'); }); + it('marks Groq embedding features as provider-limited while keeping generation ready', () => { + const readiness = getAiReadiness(makeConfig({ provider: 'groq' })); + + expect(readiness.canProcessClips).toBe(true); + expect(readiness.canUseSemanticSearch).toBe(false); + expect(readiness.canManageEmbeddings).toBe(false); + expect(readiness.capabilities.find((item) => item.id === 'clip_processing')?.status).toBe('ready'); + expect(readiness.capabilities.find((item) => item.id === 'semantic_search')?.status).toBe('provider_limit'); + }); + it('requires setup for cloud providers without credentials', () => { const readiness = getAiReadiness(makeConfig({ provider: 'gemini', api_key: '' })); diff --git a/src/components/settings/AiWelcomeWizard.tsx b/src/components/settings/AiWelcomeWizard.tsx index 71d746a..0881985 100644 --- a/src/components/settings/AiWelcomeWizard.tsx +++ b/src/components/settings/AiWelcomeWizard.tsx @@ -8,7 +8,10 @@ import type { SensitiveDataPolicy, LocalAiHardwareProbe, AiConfig, + AiModelInfo, + AiValidationResult, } from '../../lib/types'; +import { getAiProviderDefaultModelId, normalizeAiProvider } from '../../lib/aiCapabilities'; import { effectiveSensitivePolicy } from '../../lib/localAi'; import { notifyAiConfigChanged } from '../../lib/aiReadiness'; import './AiWelcomeWizard.css'; @@ -24,12 +27,7 @@ const MODES: { id: LocalAiMode; star?: boolean }[] = [ const POLICIES: SensitiveDataPolicy[] = ['never_send', 'ask_every_time', 'allow_cloud']; -const PROVIDERS = [ - { id: 'openai', label: 'OpenAI' }, - { id: 'gemini', label: 'Google Gemini' }, - { id: 'anthropic', label: 'Anthropic' }, - { id: 'ollama', label: 'Ollama (local)' }, -]; +const PROVIDERS = ['openai', 'gemini', 'anthropic', 'groq', 'ollama'] as const; interface ModeCapabilities { works: string[]; @@ -119,6 +117,8 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { const [apiKey, setApiKey] = useState(''); const [model, setModel] = useState(''); const [keyValid, setKeyValid] = useState(null); + const [validationMessage, setValidationMessage] = useState(''); + const [availableModels, setAvailableModels] = useState([]); // ── Hardware probe state ── const [hwProbe, setHwProbe] = useState(null); @@ -129,7 +129,7 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { // Load existing AI config on mount useEffect(() => { invoke('load_ai_config').then((config) => { - if (config.provider) setProvider(config.provider); + if (config.provider) setProvider(normalizeAiProvider(config.provider)); if (config.api_key) setApiKey(config.api_key); if (config.model) setModel(config.model); }).catch(() => { /* first launch — no config yet */ }); @@ -144,6 +144,12 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { } }, [selectedMode]); + useEffect(() => { + setKeyValid(null); + setValidationMessage(''); + setAvailableModels([]); + }, [provider, apiKey]); + // ── Step count (dynamic) ── const needsSetup = selectedMode !== 'no_ai'; const needsPolicyStep = selectedMode === 'hybrid' || selectedMode === 'cloud_only'; @@ -202,16 +208,24 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { // ── API key validation ── const handleValidateKey = useCallback(async () => { try { - const result = await invoke<{ valid: boolean }>('validate_ai_key', { + const result = await invoke('validate_ai_key', { provider, apiKey, baseUrl: null, }); setKeyValid(result.valid); - } catch { + setValidationMessage(result.message); + setAvailableModels(result.models); + if (result.valid && result.models.length > 0) { + const matchingModel = result.models.find((entry) => entry.id === model); + setModel(matchingModel?.id ?? result.models[0].id); + } + } catch (error) { setKeyValid(false); + setValidationMessage(String(error)); + setAvailableModels([]); } - }, [provider, apiKey]); + }, [apiKey, model, provider]); // ── Finish wizard ── const handleFinish = useCallback(async () => { @@ -379,11 +393,13 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { value={provider} onChange={(e) => { setProvider(e.target.value); - setKeyValid(null); + setModel(''); }} > - {PROVIDERS.map((p) => ( - + {PROVIDERS.map((providerId) => ( + ))} @@ -396,7 +412,6 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { placeholder="sk-..." onChange={(e) => { setApiKey(e.target.value); - setKeyValid(null); }} /> @@ -406,9 +421,18 @@ export function AiWelcomeWizard({ onComplete, onSkip }: AiWelcomeWizardProps) { className="cn-ai-wizard__input" type="text" value={model} - placeholder={provider === 'openai' ? 'gpt-4o' : provider === 'gemini' ? 'gemini-2.0-flash' : provider === 'anthropic' ? 'claude-sonnet-4-20250514' : ''} + list="clipnova-ai-wizard-model-list" + placeholder={getAiProviderDefaultModelId(provider)} onChange={(e) => setModel(e.target.value)} /> + + {availableModels.map((entry) => ( + + {validationMessage ? ( + {validationMessage} + ) : null}
-
- {t('settings.ai.provider')} - -
+
+ {t('settings.ai.provider')} + +
{t('settings.ai.api_key')} setAiApiKey(e.target.value)} />
-
- {t('settings.ai.model')} - setAiModel(e.target.value)} /> -
+
+ {t('settings.ai.model')} +
+ setAiModel(e.target.value)} + /> + + {aiAvailableModels.map((model) => ( + + {aiValidationMessage ? ( + {aiValidationMessage} + ) : null} + {aiSelectedModelInfo?.description ? ( + {aiSelectedModelInfo.description} + ) : null} +
+
{aiProvider === 'ollama' && (
{t('settings.ai.base_url')} @@ -4275,7 +4335,7 @@ export function SettingsView({ requestedTab = null }: SettingsViewProps) {
{t('settings.general.about')}
{t('settings.general.version')} - CLIPNOVA v0.2.1 + {appVersion ? `CLIPNOVA v${appVersion}` : 'CLIPNOVA v...'}
@@ -4563,7 +4623,7 @@ export function SettingsView({ requestedTab = null }: SettingsViewProps) { notifyAiConfigChanged(); // Reload AI config invoke('load_ai_config').then((cfg) => { - setAiProvider(cfg.provider); + setAiProvider(normalizeAiProvider(cfg.provider)); setAiApiKey(cfg.api_key); setAiModel(cfg.model ?? ''); setAiBaseUrl(cfg.base_url ?? ''); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 596afe6..43455d0 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -259,11 +259,12 @@ "ai": { "provider": "ANBIETER", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "API-SCHLÜSSEL", "model": "MODELL", "base_url": "BASIS-URL", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6fcc121..09016cd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -296,11 +296,12 @@ "cloud_title": "CLOUD / API AI", "provider": "PROVIDER", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "API KEY", "model": "MODEL", "base_url": "BASE URL", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 51581c1..aa1b6c6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -259,11 +259,12 @@ "ai": { "provider": "PROVEEDOR", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "CLAVE API", "model": "MODELO", "base_url": "URL BASE", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 927a647..6f9a43d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -259,11 +259,12 @@ "ai": { "provider": "FOURNISSEUR", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "CLÉ API", "model": "MODÈLE", "base_url": "URL DE BASE", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index d6f11d2..778a6da 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -259,11 +259,12 @@ "ai": { "provider": "DOSTAWCA", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "KLUCZ API", "model": "MODEL", "base_url": "BAZOWY URL", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e667210..5c043d5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -216,11 +216,12 @@ "ai": { "provider": "ПРОВАЙДЕР", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "API КЛЮЧ", "model": "МОДЕЛЬ", "base_url": "БАЗОВЫЙ URL", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7d2da12..c96f239 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -216,11 +216,12 @@ "ai": { "provider": "ПРОВАЙДЕР", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "API КЛЮЧ", "model": "МОДЕЛЬ", "base_url": "БАЗОВИЙ URL", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 76e9a98..cdc696e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -259,11 +259,12 @@ "ai": { "provider": "供应商", "providers": { - "openai": "OPENAI", - "gemini": "GEMINI", - "anthropic": "ANTHROPIC", - "ollama": "OLLAMA" - }, + "openai": "OPENAI", + "gemini": "GEMINI", + "anthropic": "ANTHROPIC", + "groq": "GROQ", + "ollama": "OLLAMA" + }, "api_key": "API 密钥", "model": "模型", "base_url": "基础 URL", diff --git a/src/lib/aiCapabilities.ts b/src/lib/aiCapabilities.ts index 5481228..43a3448 100644 --- a/src/lib/aiCapabilities.ts +++ b/src/lib/aiCapabilities.ts @@ -8,7 +8,7 @@ import type { TransformInfo, } from './types'; -export type AiProviderId = 'openai' | 'gemini' | 'anthropic' | 'ollama'; +export type AiProviderId = 'openai' | 'gemini' | 'anthropic' | 'groq' | 'ollama'; export type AiCapabilityId = | 'clip_processing' | 'ai_transforms' @@ -145,6 +145,16 @@ const AI_PROVIDER_PROFILES: Record = { supportsSemanticSearch: false, supportsOcr: false, }, + groq: { + id: 'groq', + label: 'GROQ', + isLocal: false, + requiresApiKey: true, + supportsGeneration: true, + supportsEmbeddings: false, + supportsSemanticSearch: false, + supportsOcr: false, + }, ollama: { id: 'ollama', label: 'OLLAMA', @@ -308,6 +318,8 @@ export function normalizeAiProvider(provider: string | null | undefined): AiProv return 'gemini'; case 'anthropic': return 'anthropic'; + case 'groq': + return 'groq'; case 'ollama': return 'ollama'; case 'openai': @@ -316,6 +328,22 @@ export function normalizeAiProvider(provider: string | null | undefined): AiProv } } +export function getAiProviderDefaultModelId(provider: string | null | undefined): string { + switch (normalizeAiProvider(provider)) { + case 'gemini': + return 'gemini-2.0-flash'; + case 'anthropic': + return 'claude-3-5-haiku-20241022'; + case 'groq': + return 'llama-3.3-70b-versatile'; + case 'ollama': + return 'llama3.2'; + case 'openai': + default: + return 'gpt-4o-mini'; + } +} + export function getAiProviderProfile(provider: string | null | undefined): AiProviderProfile { return AI_PROVIDER_PROFILES[normalizeAiProvider(provider)]; } @@ -588,4 +616,3 @@ export function navigateToAiSetup(): void { }); }); } - diff --git a/src/lib/types.ts b/src/lib/types.ts index 0e35057..99b4687 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -262,6 +262,18 @@ export interface AiConfig { auto_summary: boolean; } +export interface AiModelInfo { + id: string; + name: string; + description: string | null; +} + +export interface AiValidationResult { + valid: boolean; + message: string; + models: AiModelInfo[]; +} + export type LocalAiMode = 'no_ai' | 'local_only' | 'hybrid' | 'cloud_only'; export type SensitiveDataPolicy = 'never_send' | 'ask_every_time' | 'allow_cloud'; export type LocalAiProfileMode = 'auto' | 'manual'; From 7d10cceab3b80e8977746cd8dea0aaf6b67f6bbf Mon Sep 17 00:00:00 2001 From: silenthillzeroq Date: Tue, 21 Apr 2026 23:41:51 +0300 Subject: [PATCH 2/2] Release 0.3.5: vault hardening and Win+V fix --- extension/background.js | 2 +- extension/content.js | 160 +++++- extension/manifest.json | 4 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 4 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/local-ai-protocol/Cargo.toml | 2 +- .../sidecar/clipnova-ai-worker/Cargo.lock | 4 +- .../sidecar/clipnova-ai-worker/Cargo.toml | 2 +- src-tauri/src/clipboard.rs | 8 +- src-tauri/src/http_bridge.rs | 191 ++++++- src-tauri/src/keyboard_hook.rs | 48 +- src-tauri/src/vault.rs | 476 +++++++++++++++--- src-tauri/tauri.conf.json | 2 +- src/__tests__/lib/formMemoryContent.test.ts | 89 ++++ 16 files changed, 876 insertions(+), 126 deletions(-) diff --git a/extension/background.js b/extension/background.js index ac05344..30f3cb6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -282,7 +282,7 @@ function sendToWS(message) { return true; } // Queue important messages during reconnect - if (message.type === 'CLIP_META' || message.type === 'FORM_SAVE') { + if (message.type === 'CLIP_META') { if (messageQueue.length < 50) { messageQueue.push(message); } diff --git a/extension/content.js b/extension/content.js index 61301fb..e447427 100644 --- a/extension/content.js +++ b/extension/content.js @@ -626,6 +626,132 @@ function collectSubmittableFields(form) { return fields; } +function getBridgeTimeoutSignal(timeoutMs) { + if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') { + return AbortSignal.timeout(timeoutMs); + } + return undefined; +} + +async function postBridgeJson(path, payload, timeoutMs = 2500) { + const response = await fetch(`${BRIDGE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: getBridgeTimeoutSignal(timeoutMs), + }); + + let data = {}; + try { + data = await response.json(); + } catch { + data = {}; + } + + return { response, data }; +} + +function mapFormSaveReason(reason) { + const normalized = String(reason || '').toLowerCase(); + if (!normalized) return null; + if (normalized.includes('blocked')) { + return t('content.formBlocked', 'Form Memory is blocked for this site'); + } + if (normalized.includes('disabled')) { + return t('content.formDisabled', 'Form Memory is currently disabled'); + } + return t('content.formSaveFailed', 'Could not save form'); +} + +async function saveFormCapture(url, formName, fields) { + const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'clipnova-form-submit', + request_id: requestId, + url, + form_name: formName, + fields, + }); + if (response?.ok) { + return { ok: true, transport: 'ws', requestId, reason: null, sessionId: null }; + } + } catch { + // Fall through to HTTP bridge fallback. + } + + try { + const { response, data } = await postBridgeJson('/form-save', { + request_id: requestId, + url, + form_name: formName, + fields, + }, 3000); + + if (response.ok && data?.ok !== false) { + return { + ok: true, + transport: 'http', + requestId, + reason: null, + sessionId: data?.session_id || null, + }; + } + + return { + ok: false, + transport: 'http', + requestId, + reason: typeof data?.reason === 'string' ? data.reason : null, + sessionId: null, + }; + } catch { + return { + ok: false, + transport: 'none', + requestId, + reason: null, + sessionId: null, + }; + } +} + +async function requestSavedForms(url, forms) { + const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'clipnova-form-request', + request_id: requestId, + url, + forms, + }); + if (response?.ok) { + return { ok: true, transport: 'ws', requestId, sessions: [] }; + } + } catch { + // Fall through to HTTP bridge fallback. + } + + try { + const { response, data } = await postBridgeJson('/form-request', { + request_id: requestId, + url, + forms, + }, 3000); + + return { + ok: response.ok && data?.ok !== false, + transport: response.ok ? 'http' : 'none', + requestId, + sessions: Array.isArray(data?.sessions) ? data.sessions : [], + }; + } catch { + return { ok: false, transport: 'none', requestId, sessions: [] }; + } +} + function describePageForms(root = document) { return Array.from(root.querySelectorAll('form')).map((form) => { const fieldNames = Array.from(form.querySelectorAll('input, select, textarea')) @@ -728,19 +854,16 @@ document.addEventListener('submit', (event) => { const fields = collectSubmittableFields(form); if (fields.length === 0) return; - const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; - chrome.runtime.sendMessage({ - type: 'clipnova-form-submit', - request_id: requestId, - url: window.location.href, - form_name: deriveFormName(form), - fields, - }).then((response) => { - if (response && response.ok === false) { - showPageToast(t('content.formSaveFailed', 'Could not save form'), 'error'); + void saveFormCapture(window.location.href, deriveFormName(form), fields).then((result) => { + if (result.ok && result.transport === 'http') { + showPageToast(t('content.formSaved', 'Form saved'), 'success'); + return; + } + + const errorMessage = mapFormSaveReason(result.reason); + if (errorMessage) { + showPageToast(errorMessage, 'error'); } - }).catch(() => { - showPageToast(t('content.formSaveFailed', 'Could not save form'), 'error'); }); }, true); @@ -855,12 +978,11 @@ function showFormMemoryPanel(sessions) { setTimeout(() => { const forms = describePageForms(); if (forms.length > 0) { - chrome.runtime.sendMessage({ - type: 'clipnova-form-request', - request_id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, - url: window.location.href, - forms, - }).catch(() => { }); + void requestSavedForms(window.location.href, forms).then((result) => { + if (result.ok && result.transport === 'http') { + showFormMemoryPanel(result.sessions || []); + } + }); } }, 1500); @@ -873,4 +995,6 @@ globalThis.__clipnovaFormMemoryTestApi = { pickBestFormElement, applySessionToRoot, deriveFormName, + saveFormCapture, + requestSavedForms, }; diff --git a/extension/manifest.json b/extension/manifest.json index e015429..43ab39d 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "Clipnova — Clipboard Companion", + "name": "Clipnova — Clipboard Companion", "description": "Your clipboard history right in the browser. Synced with the Clipnova desktop app.", - "version": "0.3.4", + "version": "0.3.5", "browser_specific_settings": { "gecko": { "id": "clipnova@clipnova.app", diff --git a/package-lock.json b/package-lock.json index 0371c2c..7a00336 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipnova", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipnova", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index cdddd39..60db09e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clipnova", "private": true, - "version": "0.3.4", + "version": "0.3.5", "license": "GPL-3.0-only", "description": "Open-source clipboard manager for Windows with AI, privacy-first workflows, 36 themes, and QuickPaste.", "author": "silenthillzeroq", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2e9794e..9ef0535 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "clipnova" -version = "0.3.4" +version = "0.3.5" dependencies = [ "aes-gcm", "arboard", @@ -2454,7 +2454,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ai-protocol" -version = "0.3.4" +version = "0.3.5" dependencies = [ "serde", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5b73cf..8f53f29 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "clipnova" -version = "0.3.4" -description = "Clipboard manager of the future — Mission Control for everything you copy" +version = "0.3.5" +description = "Clipboard manager of the future — Mission Control for everything you copy" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/local-ai-protocol/Cargo.toml b/src-tauri/crates/local-ai-protocol/Cargo.toml index afec4f3..956e669 100644 --- a/src-tauri/crates/local-ai-protocol/Cargo.toml +++ b/src-tauri/crates/local-ai-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "local-ai-protocol" -version = "0.3.4" +version = "0.3.5" edition = "2021" [dependencies] diff --git a/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock b/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock index 516b27a..49ff218 100644 --- a/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock +++ b/src-tauri/sidecar/clipnova-ai-worker/Cargo.lock @@ -56,7 +56,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clipnova-ai-worker" -version = "0.3.4" +version = "0.3.5" dependencies = [ "local-ai-protocol", "reqwest", @@ -415,7 +415,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ai-protocol" -version = "0.3.4" +version = "0.3.5" dependencies = [ "serde", ] diff --git a/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml b/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml index 461ef69..8cf8e47 100644 --- a/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml +++ b/src-tauri/sidecar/clipnova-ai-worker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clipnova-ai-worker" -version = "0.3.4" +version = "0.3.5" edition = "2021" [dependencies] diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index a22a7d8..a55fc34 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1238,7 +1238,13 @@ pub fn start_monitor(app: AppHandle, db: Arc, ws_state: crate::ws_brid }); } - if crate::vault::is_password_like(trimmed) { + if crate::vault::should_prompt_for_detected_password( + trimmed, + clip.created_at, + clip.source_app.as_deref(), + clip.source_title.as_deref(), + clip.source_url.as_deref(), + ) { let payload = crate::vault::build_detected_password_payload( trimmed, clip.created_at, diff --git a/src-tauri/src/http_bridge.rs b/src-tauri/src/http_bridge.rs index c9992f1..25f6a43 100644 --- a/src-tauri/src/http_bridge.rs +++ b/src-tauri/src/http_bridge.rs @@ -282,6 +282,173 @@ fn handle_request( } } + // Save browser form memory payload via HTTP fallback when WS is unavailable. + ("POST", "/form-save") => match read_request_body(request) { + Ok(body_str) => match serde_json::from_str::(&body_str) { + Ok(json) => { + let url = json["url"].as_str().unwrap_or("").trim().to_string(); + let form_name = json["form_name"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); + let fields_raw = + json.get("fields").cloned().unwrap_or(serde_json::json!([])); + + if url.is_empty() { + return ( + 400, + serde_json::json!({ + "ok": false, + "reason": "url is required", + }) + .to_string(), + ); + } + + let fields = match serde_json::from_value::>( + fields_raw, + ) { + Ok(value) => value, + Err(error) => { + return ( + 400, + serde_json::json!({ + "ok": false, + "reason": format!("Invalid fields payload: {error}"), + }) + .to_string(), + ); + } + }; + + if fields.is_empty() { + return ( + 200, + serde_json::json!({ + "ok": false, + "reason": "No fields to save", + }) + .to_string(), + ); + } + + match crate::form_memory::save_form_session(db, &url, &form_name, &fields) { + Ok(session_id) => ( + 200, + serde_json::json!({ + "ok": true, + "session_id": session_id, + }) + .to_string(), + ), + Err(reason) => ( + 200, + serde_json::json!({ + "ok": false, + "reason": reason, + }) + .to_string(), + ), + } + } + Err(error) => ( + 400, + serde_json::json!({ + "ok": false, + "reason": format!("Invalid JSON: {error}"), + }) + .to_string(), + ), + }, + Err(error) => ( + 400, + serde_json::json!({ + "ok": false, + "reason": error, + }) + .to_string(), + ), + }, + + // Resolve saved browser form sessions via HTTP fallback when WS is unavailable. + ("POST", "/form-request") => match read_request_body(request) { + Ok(body_str) => match serde_json::from_str::(&body_str) { + Ok(json) => { + let url = json["url"].as_str().unwrap_or("").trim().to_string(); + let forms_raw = json.get("forms").cloned().unwrap_or(serde_json::json!([])); + + if url.is_empty() { + return ( + 400, + serde_json::json!({ + "ok": false, + "reason": "url is required", + "sessions": [], + }) + .to_string(), + ); + } + + let descriptors = match serde_json::from_value::< + Vec, + >(forms_raw) + { + Ok(value) => value, + Err(error) => { + return ( + 400, + serde_json::json!({ + "ok": false, + "reason": format!("Invalid forms payload: {error}"), + "sessions": [], + }) + .to_string(), + ); + } + }; + + match crate::form_memory::get_sessions_for_request(db, &url, &descriptors) { + Ok(sessions) => ( + 200, + serde_json::json!({ + "ok": true, + "sessions": sessions, + }) + .to_string(), + ), + Err(reason) => ( + 500, + serde_json::json!({ + "ok": false, + "reason": reason, + "sessions": [], + }) + .to_string(), + ), + } + } + Err(error) => ( + 400, + serde_json::json!({ + "ok": false, + "reason": format!("Invalid JSON: {error}"), + "sessions": [], + }) + .to_string(), + ), + }, + Err(error) => ( + 400, + serde_json::json!({ + "ok": false, + "reason": error, + "sessions": [], + }) + .to_string(), + ), + }, + // Receive URL from extension ("POST", "/clip-url") => { match read_request_body(request) { @@ -476,13 +643,11 @@ fn handle_request( source_project_name: None, source_project_path: None, ocr_text: None, - ocr_status: Some( - if auto_ocr_images && content_type == "image" { - "pending".to_string() - } else { - "none".to_string() - }, - ), + ocr_status: Some(if auto_ocr_images && content_type == "image" { + "pending".to_string() + } else { + "none".to_string() + }), ocr_updated_at: None, ocr_error: None, file_operation: None, @@ -494,9 +659,9 @@ fn handle_request( match db.insert_clip(&new_clip) { Ok(_) => { if auto_ocr_images && content_type == "image" { - if let Some(queue) = - app_handle.try_state::() - { + if let Some(queue) = app_handle + .try_state::( + ) { if let Err(error) = queue.enqueue_clip(id.clone()) { eprintln!( "[Clipnova OCR] extension image enqueue failed for {}: {}", @@ -697,9 +862,9 @@ fn handle_request( match db.insert_clip(&new_clip) { Ok(_) => { if auto_ocr_images { - if let Some(queue) = - app_handle.try_state::() - { + if let Some(queue) = app_handle + .try_state::( + ) { if let Err(error) = queue.enqueue_clip(id.clone()) { eprintln!( "[Clipnova OCR] extension screenshot enqueue failed for {}: {}", diff --git a/src-tauri/src/keyboard_hook.rs b/src-tauri/src/keyboard_hook.rs index 22cb1fb..612c7bf 100644 --- a/src-tauri/src/keyboard_hook.rs +++ b/src-tauri/src/keyboard_hook.rs @@ -37,6 +37,7 @@ mod win { static HOOK_HANDLE: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); static HOOK_THREAD_ID: AtomicU32 = AtomicU32::new(0); + static HOTKEY_REGISTERED: AtomicBool = AtomicBool::new(false); /// Set after we eat Win+V so we also eat the corresponding V key release. /// We intentionally do NOT swallow Win key release events because that can /// leave the OS behaving as if the Win key is still held down. @@ -49,6 +50,19 @@ mod win { unsafe { GetAsyncKeyState(VK_LWIN) < 0 || GetAsyncKeyState(VK_RWIN) < 0 } } + fn trigger_quickpaste_async() { + if let Some(app) = APP_HANDLE.get() { + let app = app.clone(); + std::thread::spawn(move || { + if crate::paste_stack::is_active() { + crate::paste_stack::do_stack_next(&app); + } else { + crate::quick_paste_window::open_popup(&app); + } + }); + } + } + unsafe extern "system" fn ll_keyboard_proc( n_code: i32, w_param: WPARAM, @@ -78,11 +92,19 @@ mod win { } // --- Intercept Win+V --- + // Prefer the registered WM_HOTKEY path when available because Windows + // then treats Win+V as a real hotkey instead of a lone Win tap. + // The low-level hook only acts as a fallback when RegisterHotKey + // could not claim the combination. if vk == VK_V && (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN) && is_win_key_held() && HOOK_ACTIVE.load(Ordering::SeqCst) { + if HOTKEY_REGISTERED.load(Ordering::SeqCst) { + return CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param); + } + // Swallow the Win+V key pair, but let the actual Win release // propagate normally so Windows never ends up with a "stuck" Win state. SUPPRESS_V_KEYUP.store(true, Ordering::SeqCst); @@ -92,16 +114,7 @@ mod win { // will silently remove the hook. Tauri window operations // (show, position, focus, JS eval) can easily exceed that. // Instead, fire-and-forget on a new thread. - if let Some(app) = APP_HANDLE.get() { - let app = app.clone(); - std::thread::spawn(move || { - if crate::paste_stack::is_active() { - crate::paste_stack::do_stack_next(&app); - } else { - crate::quick_paste_window::open_popup(&app); - } - }); - } + trigger_quickpaste_async(); // Eat the keystroke — do not pass to next hook return 1; } @@ -160,8 +173,10 @@ mod win { VK_V_U32, ); if hot_ok != 0 { + HOTKEY_REGISTERED.store(true, Ordering::SeqCst); eprintln!("[keyboard_hook] RegisterHotKey(Win+V) succeeded"); } else { + HOTKEY_REGISTERED.store(false, Ordering::SeqCst); eprintln!("[keyboard_hook] RegisterHotKey(Win+V) failed — restart PC to apply"); } @@ -183,16 +198,7 @@ mod win { // Handle our registered Win+V hotkey if msg.message == WM_HOTKEY && msg.wParam as i32 == HOTKEY_ID_WIN_V { - if let Some(app) = APP_HANDLE.get() { - let app = app.clone(); - std::thread::spawn(move || { - if crate::paste_stack::is_active() { - crate::paste_stack::do_stack_next(&app); - } else { - crate::quick_paste_window::open_popup(&app); - } - }); - } + trigger_quickpaste_async(); continue; // Don't dispatch — we handled it } @@ -202,6 +208,7 @@ mod win { // Clean up UnregisterHotKey(0, HOTKEY_ID_WIN_V); + HOTKEY_REGISTERED.store(false, Ordering::SeqCst); let handle = HOOK_HANDLE.swap(std::ptr::null_mut(), Ordering::SeqCst); if !handle.is_null() { UnhookWindowsHookEx(handle); @@ -217,6 +224,7 @@ mod win { pub fn uninstall_hook() { HOOK_ACTIVE.store(false, Ordering::SeqCst); + HOTKEY_REGISTERED.store(false, Ordering::SeqCst); let thread_id = HOOK_THREAD_ID.load(Ordering::SeqCst); if thread_id != 0 { unsafe { diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index fe15ab5..05d2dbc 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -7,9 +7,11 @@ use base64::Engine; use rand::RngCore; use rusqlite::params; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::Mutex; +use std::sync::OnceLock; use std::time::Instant; use tauri::State; @@ -67,6 +69,10 @@ const VAULT_ENCRYPTION_WRAPPED_KEY: &str = "wrapped_master_key"; const VAULT_KEY_BYTES: usize = 32; const VAULT_SALT_BYTES: usize = 16; const VAULT_NONCE_BYTES: usize = 12; +const VAULT_DETECTION_DEDUP_MS: i64 = 10 * 60 * 1000; +const VAULT_DETECTION_CACHE_LIMIT: usize = 128; + +static DETECTED_SECRET_CACHE: OnceLock>> = OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VaultHardeningStatus { @@ -1660,6 +1666,349 @@ pub fn build_detected_password_payload( } } +#[derive(Debug, Clone, Copy)] +struct SecretCandidateAnalysis { + len: usize, + class_count: usize, + unique_chars: usize, + has_special: bool, + looks_like_token: bool, + strong_password: bool, + medium_password: bool, +} + +fn detected_secret_cache() -> &'static Mutex> { + DETECTED_SECRET_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cache_detected_secret(text: &str, detected_at: i64) -> bool { + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + let hash = hex::encode(hasher.finalize()); + + let Ok(mut cache) = detected_secret_cache().lock() else { + return true; + }; + + cache.retain(|_, ts| detected_at.saturating_sub(*ts) <= VAULT_DETECTION_DEDUP_MS); + if cache + .get(&hash) + .is_some_and(|previous| detected_at.saturating_sub(*previous) < VAULT_DETECTION_DEDUP_MS) + { + return false; + } + + if cache.len() >= VAULT_DETECTION_CACHE_LIMIT { + if let Some(oldest_key) = cache + .iter() + .min_by_key(|(_, ts)| *ts) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest_key); + } + } + + cache.insert(hash, detected_at); + true +} + +fn looks_like_structured_numeric_value(text: &str) -> bool { + let mut digits = 0; + let mut separators = 0; + + for ch in text.chars() { + if ch.is_ascii_digit() { + digits += 1; + } else if matches!(ch, '-' | '/' | '.' | ':' | ' ' | 'T' | 'Z' | '+') { + separators += 1; + } else { + return false; + } + } + + digits >= 4 && separators > 0 +} + +fn looks_like_uuid(text: &str) -> bool { + if text.len() != 36 { + return false; + } + + text.chars().enumerate().all(|(idx, ch)| match idx { + 8 | 13 | 18 | 23 => ch == '-', + _ => ch.is_ascii_hexdigit(), + }) +} + +fn looks_like_semver(text: &str) -> bool { + let normalized = text + .trim() + .trim_start_matches('v') + .split(['-', '+']) + .next() + .unwrap_or(""); + let parts: Vec<&str> = normalized.split('.').collect(); + if !(2..=4).contains(&parts.len()) { + return false; + } + parts.iter().all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) +} + +fn looks_like_hex_color(text: &str) -> bool { + let trimmed = text.trim(); + if !trimmed.starts_with('#') { + return false; + } + + let normalized = trimmed.trim_start_matches('#'); + matches!(normalized.len(), 3 | 6 | 8) && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn looks_like_sentence(text: &str) -> bool { + let words: Vec<&str> = text + .split_whitespace() + .filter(|word| word.chars().any(|ch| ch.is_alphabetic())) + .collect(); + words.len() >= 3 +} + +fn looks_like_known_secret(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + let prefixes = [ + "sk-", + "sk_live_", + "sk_test_", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "glpat-", + "xoxb-", + "xoxp-", + "xoxo-", + "anthropic-", + "gsk_", + "hf_", + "npm_", + "pypi-", + "sbp_", + "pk_live_", + "pk_test_", + "ya29.", + ]; + if prefixes.iter().any(|prefix| lower.starts_with(prefix)) { + return true; + } + + if text.starts_with("AKIA") || text.starts_with("ASIA") { + return true; + } + + let jwt_parts: Vec<&str> = text.split('.').collect(); + if jwt_parts.len() == 3 + && jwt_parts.iter().all(|part| { + part.len() >= 6 + && part + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + }) + { + return true; + } + + if text.len() >= 32 && text.chars().all(|ch| ch.is_ascii_hexdigit()) { + return true; + } + + if text.len() >= 24 + && text + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '=' | '.')) + { + let has_upper = text.chars().any(|ch| ch.is_ascii_uppercase()); + let has_lower = text.chars().any(|ch| ch.is_ascii_lowercase()); + let has_digit = text.chars().any(|ch| ch.is_ascii_digit()); + if [has_upper, has_lower, has_digit] + .iter() + .filter(|&&item| item) + .count() + >= 2 + { + return true; + } + } + + false +} + +fn analyze_secret_candidate(text: &str) -> Option { + let trimmed = text.trim(); + let len = trimmed.len(); + + if !(8..=128).contains(&len) { + return None; + } + + if trimmed.contains(['\n', '\r', '\t']) { + return None; + } + + if trimmed.starts_with("http://") + || trimmed.starts_with("https://") + || trimmed.contains("://") + || (trimmed.contains('@') && trimmed.contains('.')) + || trimmed.starts_with('/') + || (trimmed.len() > 2 && trimmed.chars().nth(1) == Some(':')) + { + return None; + } + + if looks_like_structured_numeric_value(trimmed) + || looks_like_uuid(trimmed) + || looks_like_semver(trimmed) + || looks_like_hex_color(trimmed) + || looks_like_sentence(trimmed) + { + return None; + } + + let whitespace_count = trimmed.chars().filter(|ch| ch.is_whitespace()).count(); + if whitespace_count > 1 || (whitespace_count == 1 && len > 24) { + return None; + } + + let has_upper = trimmed.chars().any(|ch| ch.is_uppercase()); + let has_lower = trimmed.chars().any(|ch| ch.is_lowercase()); + let has_digit = trimmed.chars().any(|ch| ch.is_ascii_digit()); + let has_special = trimmed + .chars() + .any(|ch| !ch.is_alphanumeric() && !ch.is_whitespace()); + let class_count = [has_upper, has_lower, has_digit, has_special] + .iter() + .filter(|&&value| value) + .count(); + let unique_chars = trimmed.chars().collect::>().len(); + + if !has_special && !has_digit { + return None; + } + + let looks_like_token = looks_like_known_secret(trimmed); + let strong_password = + has_special && class_count >= 3 && len >= 10 && unique_chars >= 6; + let medium_password = class_count >= 3 && len >= 8 && unique_chars >= 5; + + if !(looks_like_token || strong_password || medium_password) { + return None; + } + + Some(SecretCandidateAnalysis { + len, + class_count, + unique_chars, + has_special, + looks_like_token, + strong_password, + medium_password, + }) +} + +fn combined_context_text( + source_app: Option<&str>, + source_title: Option<&str>, + source_url: Option<&str>, +) -> String { + [source_app, source_title, source_url] + .into_iter() + .flatten() + .map(|value| value.to_ascii_lowercase()) + .collect::>() + .join(" | ") +} + +fn has_auth_context(source_app: Option<&str>, source_title: Option<&str>, source_url: Option<&str>) -> bool { + const AUTH_HINTS: &[&str] = &[ + "login", + "log in", + "signin", + "sign in", + "auth", + "oauth", + "password", + "passkey", + "credential", + "credentials", + "account", + "session", + "unlock", + "vault", + "2fa", + "otp", + "reset-password", + "reset password", + ]; + + let context = combined_context_text(source_app, source_title, source_url); + AUTH_HINTS.iter().any(|hint| context.contains(hint)) +} + +fn is_code_like_context( + source_app: Option<&str>, + source_title: Option<&str>, + source_url: Option<&str>, +) -> bool { + const CODE_HINTS: &[&str] = &[ + "codex", + "visual studio code", + "code.exe", + "cursor", + "terminal", + "powershell", + "cmd.exe", + "command prompt", + "bash", + "notepad", + "webstorm", + "intellij", + "pycharm", + "rider", + "developer tools", + ]; + + let context = combined_context_text(source_app, source_title, source_url); + CODE_HINTS.iter().any(|hint| context.contains(hint)) +} + +pub fn should_prompt_for_detected_password( + text: &str, + detected_at: i64, + source_app: Option<&str>, + source_title: Option<&str>, + source_url: Option<&str>, +) -> bool { + let Some(analysis) = analyze_secret_candidate(text) else { + return false; + }; + + let auth_context = has_auth_context(source_app, source_title, source_url); + let code_like_context = is_code_like_context(source_app, source_title, source_url); + let looks_like_strong_secret = analysis.looks_like_token + || analysis.strong_password + || (analysis.has_special && analysis.class_count >= 4 && analysis.len >= 9) + || (analysis.unique_chars >= 10 && analysis.len >= 16 && analysis.class_count >= 3); + + if code_like_context && !looks_like_strong_secret { + return false; + } + + if !(looks_like_strong_secret || (analysis.medium_password && auth_context)) { + return false; + } + + cache_detected_secret(text.trim(), detected_at) +} + /// Get the active window title and suggest matching vault entries /// Matches against title, url, username, and notes fields, then explains why. #[tauri::command] @@ -2007,64 +2356,7 @@ pub fn vault_detect_password(text: String) -> bool { /// Heuristic: detect if a string looks like a password pub fn is_password_like(text: &str) -> bool { - let trimmed = text.trim(); - let len = trimmed.len(); - - // Too short or too long → not a password - if len < 6 || len > 128 { - return false; - } - - // Multi-line → not a password - if trimmed.contains('\n') { - return false; - } - - // URLs, emails, file paths → not passwords - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - return false; - } - if trimmed.contains("://") { - return false; - } - if trimmed.contains('@') && trimmed.contains('.') { - return false; - } // email - if trimmed.starts_with('/') || (trimmed.len() > 2 && trimmed.chars().nth(1) == Some(':')) { - return false; - } // paths - - // If it contains spaces → probably not a password (unless very few) - let space_count = trimmed.chars().filter(|c| *c == ' ').count(); - if space_count > 1 { - return false; - } - if space_count == 1 && len > 30 { - return false; - } - - // Check character classes - let has_upper = trimmed.chars().any(|c| c.is_uppercase()); - let has_lower = trimmed.chars().any(|c| c.is_lowercase()); - let has_digit = trimmed.chars().any(|c| c.is_ascii_digit()); - let has_special = trimmed.chars().any(|c| !c.is_alphanumeric() && c != ' '); - - let class_count = [has_upper, has_lower, has_digit, has_special] - .iter() - .filter(|&&b| b) - .count(); - - // Strong indicator: 3+ character classes - if class_count >= 3 { - return true; - } - - // Medium indicator: 2 classes + reasonable length - if class_count >= 2 && len >= 8 && len <= 64 { - return true; - } - - false + analyze_secret_candidate(text).is_some() } #[cfg(test)] @@ -2082,6 +2374,14 @@ mod tests { Mutex::new(VaultSession::new()) } + fn clear_detected_secret_cache_for_tests() { + if let Some(cache) = DETECTED_SECRET_CACHE.get() { + if let Ok(mut guard) = cache.lock() { + guard.clear(); + } + } + } + fn candidate(title: &str, username: &str, password: &str, url: &str) -> VaultImportCandidate { VaultImportCandidate { title: title.to_string(), @@ -2524,11 +2824,69 @@ mod tests { assert!(is_password_like("Sup3r!Pass")); assert!(is_password_like("Abc12345")); assert!(is_password_like("2Words2026")); + assert!(is_password_like("ghp_abcdefghijklmnopqrstuvwxyz1234567890")); assert!(!is_password_like("https://github.com")); assert!(!is_password_like("me@example.com")); assert!(!is_password_like("C:\\\\Users\\\\Meow\\\\secret.txt")); assert!(!is_password_like("line1\nline2")); assert!(!is_password_like("just words in a sentence")); + assert!(!is_password_like("2026-04-21")); + assert!(!is_password_like("v0.3.4")); + assert!(!is_password_like("550e8400-e29b-41d4-a716-446655440000")); + } + + #[test] + fn password_prompt_requires_context_for_medium_candidates() { + clear_detected_secret_cache_for_tests(); + + assert!(!should_prompt_for_detected_password( + "Abc12345", + 1_000, + Some("Codex.exe"), + Some("Chat"), + None, + )); + assert!(should_prompt_for_detected_password( + "Abc12345", + 2_000, + Some("chrome.exe"), + Some("Sign in - GitHub"), + Some("https://github.com/login"), + )); + assert!(should_prompt_for_detected_password( + "ghp_abcdefghijklmnopqrstuvwxyz1234567890", + 3_000, + Some("Code.exe"), + Some("settings.json"), + None, + )); + } + + #[test] + fn password_prompt_deduplicates_recent_candidates() { + clear_detected_secret_cache_for_tests(); + + assert!(should_prompt_for_detected_password( + "Sup3r!Pass", + 10_000, + Some("chrome.exe"), + Some("Sign in"), + Some("https://example.com/login"), + )); + assert!(!should_prompt_for_detected_password( + "Sup3r!Pass", + 10_500, + Some("chrome.exe"), + Some("Sign in"), + Some("https://example.com/login"), + )); + assert!(should_prompt_for_detected_password( + "Sup3r!Pass", + 10_000 + VAULT_DETECTION_DEDUP_MS + 1, + Some("chrome.exe"), + Some("Sign in"), + Some("https://example.com/login"), + )); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc608a0..886f8cd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Clipnova", - "version": "0.3.4", + "version": "0.3.5", "identifier": "com.clipnova.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/__tests__/lib/formMemoryContent.test.ts b/src/__tests__/lib/formMemoryContent.test.ts index 8fe2868..94238d1 100644 --- a/src/__tests__/lib/formMemoryContent.test.ts +++ b/src/__tests__/lib/formMemoryContent.test.ts @@ -20,6 +20,15 @@ declare global { pickBestFormElement: (session: { form_name?: string; fields?: Array<{ name: string }> }, root?: Document) => HTMLFormElement | null; applySessionToRoot: (session: { form_name?: string; fields?: Array<{ name: string; value: string }> }, root?: Document) => number; deriveFormName: (form: HTMLFormElement) => string; + saveFormCapture: ( + url: string, + formName: string, + fields: Array<{ name: string; value: string; field_type: string }>, + ) => Promise<{ ok: boolean; transport: string; reason: string | null; sessionId: string | null }>; + requestSavedForms: ( + url: string, + forms: Array<{ form_name: string; field_names: string[] }>, + ) => Promise<{ ok: boolean; transport: string; sessions: Array> }>; }; } @@ -182,4 +191,84 @@ describe('form memory content helpers', () => { expect(loginEmail.value).toBe('nova@example.com'); expect(loginPassword.value).toBe('s3cret'); }); + + it('falls back to the local bridge when websocket form save is unavailable', async () => { + global.chrome.runtime.sendMessage = vi.fn(() => Promise.resolve({ ok: false })); + global.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/locales/')) { + return Promise.resolve( + new Response(JSON.stringify(localePayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + if (url.endsWith('/form-save')) { + return Promise.resolve( + new Response(JSON.stringify({ ok: true, session_id: 'session-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response('{}', { status: 200 })); + }) as typeof fetch; + + const api = await loadApi(); + const result = await api.saveFormCapture('https://app.example.com/login', 'login_form', [ + { name: 'email', value: 'nova@example.com', field_type: 'text' }, + ]); + + expect(result).toMatchObject({ + ok: true, + transport: 'http', + reason: null, + sessionId: 'session-1', + }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/form-save'), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('falls back to the local bridge when websocket form lookup is unavailable', async () => { + global.chrome.runtime.sendMessage = vi.fn(() => Promise.resolve({ ok: false })); + global.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/locales/')) { + return Promise.resolve( + new Response(JSON.stringify(localePayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + if (url.endsWith('/form-request')) { + return Promise.resolve( + new Response(JSON.stringify({ + ok: true, + sessions: [{ id: 'session-1', form_name: 'login_form', fields: [], updated_at: Date.now() }], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response('{}', { status: 200 })); + }) as typeof fetch; + + const api = await loadApi(); + const result = await api.requestSavedForms('https://app.example.com/login', [ + { form_name: 'login_form', field_names: ['email'] }, + ]); + + expect(result.ok).toBe(true); + expect(result.transport).toBe('http'); + expect(result.sessions).toHaveLength(1); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/form-request'), + expect.objectContaining({ method: 'POST' }), + ); + }); });