diff --git a/src-tauri/src/commands/screenshot.rs b/src-tauri/src/commands/screenshot.rs new file mode 100644 index 0000000..3fdb5c6 --- /dev/null +++ b/src-tauri/src/commands/screenshot.rs @@ -0,0 +1,264 @@ +use headless_chrome::{Browser, LaunchOptions}; +use headless_chrome::protocol::cdp::Page; +use std::fs; +use std::time::Duration; +use tauri::AppHandle; + +/// Captures a screenshot of a URL using headless Chrome +/// +/// This function launches a headless Chrome browser, navigates to the specified URL, +/// and captures a screenshot of either the entire page or a specific element. +/// +/// # Arguments +/// * `app` - The Tauri application handle +/// * `url` - The URL to capture +/// * `selector` - Optional CSS selector for a specific element to capture +/// * `full_page` - Whether to capture the entire page or just the viewport +/// +/// # Returns +/// * `Result` - The path to the saved screenshot file, or an error message +#[tauri::command] +pub async fn capture_url_screenshot( + _app: AppHandle, + url: String, + selector: Option, + full_page: bool, +) -> Result { + log::info!( + "Capturing screenshot of URL: {}, selector: {:?}, full_page: {}", + url, + selector, + full_page + ); + + // Run the browser operations in a blocking task since headless_chrome is not async + let result = tokio::task::spawn_blocking(move || { + capture_screenshot_sync(url, selector, full_page) + }) + .await + .map_err(|e| format!("Failed to spawn blocking task: {}", e))?; + + // Log the result of the headless Chrome capture before returning + match &result { + Ok(path) => log::info!("capture_url_screenshot returning path: {}", path), + Err(err) => log::error!("capture_url_screenshot encountered error: {}", err), + } + + result +} + +/// Synchronous helper function to capture screenshots using headless Chrome +fn capture_screenshot_sync( + url: String, + selector: Option, + full_page: bool, +) -> Result { + // Configure browser launch options + let launch_options = LaunchOptions { + headless: true, + window_size: Some((1920, 1080)), + ..Default::default() + }; + + // Launch the browser + let browser = Browser::new(launch_options) + .map_err(|e| format!("Failed to launch browser: {}", e))?; + + // Create a new tab + let tab = browser + .new_tab() + .map_err(|e| format!("Failed to create new tab: {}", e))?; + + // Set a reasonable timeout for page navigation + tab.set_default_timeout(Duration::from_secs(30)); + + // Navigate to the URL + tab.navigate_to(&url) + .map_err(|e| format!("Failed to navigate to URL: {}", e))?; + + // Wait for the page to load + // Try to wait for network idle, but don't fail if it times out + let _ = tab.wait_until_navigated(); + + // Additional wait to ensure dynamic content loads + std::thread::sleep(Duration::from_millis(500)); + + // Wait explicitly for the element to exist – this often prevents + // "Unable to capture screenshot" CDP errors on some pages + if let Err(e) = tab.wait_for_element("body") { + log::warn!("Timed out waiting for element: {} – continuing anyway", e); + } + + // Capture the screenshot + let screenshot_data = if let Some(selector) = selector { + // Wait for the element and capture it + log::info!("Waiting for element with selector: {}", selector); + + let element = tab + .wait_for_element(&selector) + .map_err(|e| format!("Failed to find element '{}': {}", selector, e))?; + + element + .capture_screenshot(Page::CaptureScreenshotFormatOption::Png) + .map_err(|e| format!("Failed to capture element screenshot: {}", e))? + } else { + // Capture the entire page or viewport + log::info!("Capturing {} screenshot", if full_page { "full page" } else { "viewport" }); + + // Get the page dimensions for full page screenshot + let clip = if full_page { + // Execute JavaScript to get the full page dimensions + let dimensions = tab + .evaluate( + r#" + ({ + width: Math.max( + document.body.scrollWidth, + document.documentElement.scrollWidth, + document.body.offsetWidth, + document.documentElement.offsetWidth, + document.documentElement.clientWidth + ), + height: Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight, + document.body.offsetHeight, + document.documentElement.offsetHeight, + document.documentElement.clientHeight + ) + }) + "#, + false, + ) + .map_err(|e| format!("Failed to get page dimensions: {}", e))?; + + // Extract dimensions from the result + let width = dimensions + .value + .as_ref() + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("width")) + .and_then(|v| v.as_f64()) + .unwrap_or(1920.0); + + let height = dimensions + .value + .as_ref() + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("height")) + .and_then(|v| v.as_f64()) + .unwrap_or(1080.0); + + Some(Page::Viewport { + x: 0.0, + y: 0.0, + width, + height, + scale: 1.0, + }) + } else { + None + }; + + let capture_result = tab.capture_screenshot( + Page::CaptureScreenshotFormatOption::Png, + None, + clip.clone(), + full_page, // capture_beyond_viewport only makes sense for full page + ); + + match capture_result { + Ok(data) => data, + Err(err) => { + // Retry once with capture_beyond_viewport=true which works around some Chromium bugs + log::warn!( + "Initial screenshot attempt failed: {}. Retrying with capture_beyond_viewport=true", + err + ); + + tab.capture_screenshot( + Page::CaptureScreenshotFormatOption::Png, + None, + clip, + true, + ) + .map_err(|e| format!("Failed to capture screenshot after retry: {}", e))? + } + } + }; + + // Save to temporary file + let temp_dir = std::env::temp_dir(); + let timestamp = chrono::Utc::now().timestamp_millis(); + let filename = format!("claudia_screenshot_{}.png", timestamp); + let file_path = temp_dir.join(filename); + + fs::write(&file_path, screenshot_data) + .map_err(|e| format!("Failed to save screenshot: {}", e))?; + + // Log the screenshot path prominently + println!("═══════════════════════════════════════════════════════════════"); + println!("📸 SCREENSHOT SAVED SUCCESSFULLY!"); + println!("📁 Location: {}", file_path.display()); + println!("═══════════════════════════════════════════════════════════════"); + + log::info!("Screenshot saved to: {:?}", file_path); + + Ok(file_path.to_string_lossy().to_string()) +} + +/// Cleans up old screenshot files from the temporary directory +/// +/// This function removes screenshot files older than the specified number of minutes +/// to prevent accumulation of temporary files. +/// +/// # Arguments +/// * `older_than_minutes` - Remove files older than this many minutes (default: 60) +/// +/// # Returns +/// * `Result` - The number of files deleted, or an error message +#[tauri::command] +pub async fn cleanup_screenshot_temp_files( + older_than_minutes: Option, +) -> Result { + let minutes = older_than_minutes.unwrap_or(60); + log::info!("Cleaning up screenshot files older than {} minutes", minutes); + + let temp_dir = std::env::temp_dir(); + let cutoff_time = chrono::Utc::now() - chrono::Duration::minutes(minutes as i64); + let mut deleted_count = 0; + + // Read directory entries + let entries = fs::read_dir(&temp_dir) + .map_err(|e| format!("Failed to read temp directory: {}", e))?; + + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + + // Check if it's a claudia screenshot file + if let Some(filename) = path.file_name() { + if let Some(filename_str) = filename.to_str() { + if filename_str.starts_with("claudia_screenshot_") && filename_str.ends_with(".png") { + // Check file age + if let Ok(metadata) = fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + let modified_time = chrono::DateTime::::from(modified); + if modified_time < cutoff_time { + // Delete the file + if fs::remove_file(&path).is_ok() { + deleted_count += 1; + log::debug!("Deleted old screenshot: {:?}", path); + } + } + } + } + } + } + } + } + } + + log::info!("Cleaned up {} old screenshot files", deleted_count); + Ok(deleted_count) +} \ No newline at end of file diff --git a/src/components/PreviewPromptDialog.tsx b/src/components/PreviewPromptDialog.tsx new file mode 100644 index 0000000..944645c --- /dev/null +++ b/src/components/PreviewPromptDialog.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { motion } from "framer-motion"; +import { Globe, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface PreviewPromptDialogProps { + /** + * Whether the dialog is open + */ + isOpen: boolean; + /** + * The detected URL to preview + */ + url: string; + /** + * Callback when user confirms opening preview + */ + onConfirm: () => void; + /** + * Callback when user cancels + */ + onCancel: () => void; +} + +/** + * Dialog component that prompts the user to open a detected URL in the preview pane + * + * @example + * openPreview(url)} + * onCancel={() => setShowPrompt(false)} + * /> + */ +export const PreviewPromptDialog: React.FC = ({ + isOpen, + url, + onConfirm, + onCancel, +}) => { + // Extract domain for display + const getDomain = (urlString: string) => { + try { + const urlObj = new URL(urlString); + return urlObj.hostname; + } catch { + return urlString; + } + }; + + const domain = getDomain(url); + const isLocalhost = domain.includes('localhost') || domain.includes('127.0.0.1'); + + return ( + !open && onCancel()}> + + + + + Open Preview? + + + A URL was detected in the terminal output. Would you like to open it in the preview pane? + + + +
+
+
+ +
+

+ {isLocalhost ? 'Local Development Server' : 'External URL'} +

+

+ {url} +

+
+
+
+ + + The preview will open in a split view on the right side of the screen. + +
+ + + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/WebviewPreview.tsx b/src/components/WebviewPreview.tsx new file mode 100644 index 0000000..5ab659d --- /dev/null +++ b/src/components/WebviewPreview.tsx @@ -0,0 +1,439 @@ +import React, { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + ArrowRight, + RefreshCw, + X, + Minimize2, + Maximize2, + Camera, + Loader2, + AlertCircle, + Globe, + Home, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { api } from "@/lib/api"; +// TODO: These imports will be used when implementing actual Tauri webview +// import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +// import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; + +interface WebviewPreviewProps { + /** + * Initial URL to load + */ + initialUrl: string; + /** + * Callback when close is clicked + */ + onClose: () => void; + /** + * Callback when screenshot is requested + */ + onScreenshot?: (imagePath: string) => void; + /** + * Whether the webview is maximized + */ + isMaximized?: boolean; + /** + * Callback when maximize/minimize is clicked + */ + onToggleMaximize?: () => void; + /** + * Callback when URL changes + */ + onUrlChange?: (url: string) => void; + /** + * Optional className for styling + */ + className?: string; +} + +/** + * WebviewPreview component - Browser-like webview with navigation controls + * + * @example + * setShowPreview(false)} + * onScreenshot={(path) => attachImage(path)} + * /> + */ +const WebviewPreviewComponent: React.FC = ({ + initialUrl, + onClose, + onScreenshot, + isMaximized = false, + onToggleMaximize, + onUrlChange, + className, +}) => { + const [currentUrl, setCurrentUrl] = useState(initialUrl); + const [inputUrl, setInputUrl] = useState(initialUrl); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + // TODO: These will be implemented with actual webview navigation + // const [canGoBack, setCanGoBack] = useState(false); + // const [canGoForward, setCanGoForward] = useState(false); + const [isCapturing, setIsCapturing] = useState(false); + const [showShutterAnimation, setShowShutterAnimation] = useState(false); + + // TODO: These will be used for actual Tauri webview implementation + // const webviewRef = useRef(null); + const iframeRef = useRef(null); + const containerRef = useRef(null); + const contentRef = useRef(null); + // const previewId = useRef(`preview-${Date.now()}`); + + // Handle ESC key to exit full screen + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isMaximized && onToggleMaximize) { + onToggleMaximize(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isMaximized, onToggleMaximize]); + + // Debug: Log initial URL on mount + useEffect(() => { + console.log('[WebviewPreview] Component mounted with initialUrl:', initialUrl, 'isMaximized:', isMaximized); + }, []); + + // Focus management for full screen mode + useEffect(() => { + if (isMaximized && containerRef.current) { + containerRef.current.focus(); + } + }, [isMaximized]); + + // For now, we'll use an iframe as a placeholder + // In the full implementation, this would create a Tauri webview window + useEffect(() => { + if (currentUrl) { + // This is where we'd create the actual webview + // For now, using iframe for demonstration + setIsLoading(true); + setHasError(false); + + // Simulate loading + const timer = setTimeout(() => { + setIsLoading(false); + }, 1000); + + return () => clearTimeout(timer); + } + }, [currentUrl]); + + const navigate = (url: string) => { + try { + // Validate URL + const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`); + const finalUrl = urlObj.href; + + console.log('[WebviewPreview] Navigating to:', finalUrl); + setCurrentUrl(finalUrl); + setInputUrl(finalUrl); + setHasError(false); + onUrlChange?.(finalUrl); + } catch (err) { + setHasError(true); + setErrorMessage("Invalid URL"); + } + }; + + const handleNavigate = () => { + if (inputUrl.trim()) { + navigate(inputUrl); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleNavigate(); + } + }; + + const handleGoBack = () => { + // In real implementation, this would call webview.goBack() + console.log("Go back"); + }; + + const handleGoForward = () => { + // In real implementation, this would call webview.goForward() + console.log("Go forward"); + }; + + const handleRefresh = () => { + setIsLoading(true); + // In real implementation, this would call webview.reload() + setTimeout(() => setIsLoading(false), 1000); + }; + + const handleGoHome = () => { + navigate(initialUrl); + }; + + const handleScreenshot = async () => { + if (isCapturing || !currentUrl) return; + + try { + setIsCapturing(true); + setShowShutterAnimation(true); + + // Wait for shutter animation to start + await new Promise(resolve => setTimeout(resolve, 100)); + + // Capture the current URL using headless Chrome + const filePath = await api.captureUrlScreenshot( + currentUrl, + null, // No specific selector - capture the whole viewport + false // Not full page, just viewport + ); + + console.log("Screenshot captured and saved to:", filePath); + + // Continue shutter animation + await new Promise(resolve => setTimeout(resolve, 200)); + setShowShutterAnimation(false); + + // Trigger callback with animation + onScreenshot?.(filePath); + } catch (err) { + console.error("Failed to capture screenshot:", err); + setShowShutterAnimation(false); + } finally { + setIsCapturing(false); + } + }; + + return ( +
+ {/* Browser Top Bar */} +
+ {/* Title Bar */} +
+
+ + Preview + {isLoading && ( + + )} +
+ +
+ {onToggleMaximize && ( + + + + + + + {isMaximized ? "Exit full screen (ESC)" : "Enter full screen"} + + + + )} + +
+
+ + {/* Navigation Bar */} +
+ {/* Navigation Buttons */} +
+ + + + +
+ + {/* URL Bar */} +
+ setInputUrl(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter URL..." + className="pr-10 h-8 text-sm font-mono" + /> + {inputUrl !== currentUrl && ( + + )} +
+ + {/* Screenshot Button */} + +
+
+ + {/* Webview Content */} +
+ {/* Shutter Animation */} + + {showShutterAnimation && ( + + + + )} + + + {/* Loading Overlay */} + + {isLoading && ( + +
+ +

Loading preview...

+
+
+ )} +
+ + {/* Error State */} + {hasError ? ( +
+ +

Failed to load preview

+

+ {errorMessage || "The page could not be loaded. Please check the URL and try again."} +

+ +
+ ) : currentUrl ? ( + // Placeholder iframe - in real implementation, this would be a Tauri webview +