diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5625c07..61a3437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,9 +18,15 @@ jobs: - platform: macos-latest args: '--bundles dmg --target aarch64-apple-darwin' arch: 'silicon' + rust_targets: 'aarch64-apple-darwin' - platform: macos-latest args: '--bundles dmg --target x86_64-apple-darwin' arch: 'intel' + rust_targets: 'x86_64-apple-darwin' + - platform: windows-latest + args: '--bundles msi' + arch: 'x64' + rust_targets: '' runs-on: ${{ matrix.platform }} steps: @@ -40,7 +46,7 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: ${{ matrix.rust_targets }} - name: Install frontend dependencies run: pnpm install @@ -59,9 +65,12 @@ jobs: |----------|--------------|----------| | macOS | Apple Silicon (M1/M2/M3) | `bettershot_*_aarch64.dmg` | | macOS | Intel | `bettershot_*_x64.dmg` | + | Windows | x64 | `bettershot_*_x64-setup.msi` | ## Installation + ### macOS + 1. Download the `.dmg` file for your Mac - **Apple Silicon** (M1, M2, M3): Download the `aarch64` version - **Intel Mac**: Download the `x64` version @@ -80,6 +89,14 @@ jobs: > **Note**: This is an ad-hoc signed indie app. macOS shows a warning for apps not notarized through Apple's $99/year developer program. The app is completely safe and open source. + ### Windows + + 1. Download the `.msi` installer + 2. Run the installer and follow the prompts + 3. Launch Better Shot from the Start Menu or Desktop shortcut + + > **Note**: Windows may show a SmartScreen warning for unsigned apps. Click "More info" → "Run anyway" to proceed. The app is completely safe and open source. + ## What's New - Initial release of Better Shot diff --git a/README.md b/README.md index dc6e31f..f4ae33b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Better Shot -> An open-source alternative to CleanShot X for macOS. Capture, edit, and enhance your screenshots with professional quality. +> An open-source alternative to CleanShot X for macOS and Windows. Capture, edit, and enhance your screenshots with professional quality. Better Shot is a fast, lightweight screenshot tool built with Tauri and React. It provides a powerful yet simple interface for capturing screenshots, editing them with beautiful backgrounds and effects, and sharing them instantly. @@ -11,9 +11,9 @@ Better Shot is a fast, lightweight screenshot tool built with Tauri and React. I ### Capture Modes -- **Region Capture** - Select any area of your screen with pixel-perfect precision (`⌘⇧2`) -- **Fullscreen Capture** - Capture your entire screen instantly (`⌘⇧3`) -- **Window Capture** - Capture a specific window with one click (`⌘⇧4`) +- **Region Capture** - Select any area of your screen with pixel-perfect precision (`Cmd/Ctrl+Shift+2`) +- **Fullscreen Capture** - Capture your entire screen instantly (`Cmd/Ctrl+Shift+3`) +- **Window Capture** - Capture a specific window with one click (`Cmd/Ctrl+Shift+4`) ### Image Editing @@ -41,7 +41,7 @@ Better Shot is a fast, lightweight screenshot tool built with Tauri and React. I - **Clipboard Integration** - Automatically copy screenshots to clipboard - **Custom Save Directory** - Choose where your screenshots are saved (defaults to Desktop) - **Settings Persistence** - All preferences are saved and restored automatically -- **System Tray Integration** - Access from the menu bar +- **System Tray Integration** - Access from the menu bar (macOS) or system tray (Windows) - **Native Performance** - Built with Rust and Tauri for minimal resource usage ### Preferences @@ -53,6 +53,7 @@ Better Shot is a fast, lightweight screenshot tool built with Tauri and React. I ### Why Better Shot? - **100% Free & Open Source** - No subscriptions, no paywalls +- **Cross-Platform** - Available for macOS and Windows - **Lightweight** - Minimal resource usage compared to Electron apps - **Beautiful UI** - Modern, dark-themed interface - **Privacy First** - All processing happens locally, no cloud uploads @@ -63,9 +64,12 @@ Better Shot is a fast, lightweight screenshot tool built with Tauri and React. I ### Download Pre-built Release 1. Go to [Releases](https://github.com/KartikLabhshetwar/better-shot/releases) -2. Download the appropriate DMG file: +2. Download the appropriate installer for your platform: + +#### macOS - **Apple Silicon** (M1, M2, M3): `bettershot_*_aarch64.dmg` - **Intel Mac**: `bettershot_*_x64.dmg` + 3. Open the DMG and drag Better Shot to Applications 4. **First Launch** (choose one method): @@ -81,6 +85,13 @@ Better Shot is a fast, lightweight screenshot tool built with Tauri and React. I > **Note**: Better Shot is ad-hoc signed (free indie app). macOS Gatekeeper shows a warning for apps not notarized through Apple's $99/year developer program. The app is safe - you can [view the source code](https://github.com/KartikLabhshetwar/better-shot) and build it yourself. +#### Windows + - Download `bettershot_*_x64-setup.msi` + - Run the installer and follow the prompts + - Launch Better Shot from the Start Menu or Desktop shortcut + +> **Note**: Windows may show a SmartScreen warning for unsigned apps. Click "More info" → "Run anyway" to proceed. The app is completely safe and open source. + ### From Source ```bash @@ -100,6 +111,7 @@ The installer will be located in `src-tauri/target/release/bundle/` ### Requirements - **macOS**: 10.15 or later +- **Windows**: Windows 10 or later - **Node.js**: 18 or higher - **pnpm**: Latest version - **Rust**: Latest stable version (for building from source) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 28e177c..fb59521 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,11 +1,32 @@ //! Clipboard operations module use crate::utils::AppResult; -use std::process::Command; + +/// Copy an image file to the system clipboard +/// Uses platform-specific methods for each OS +pub fn copy_image_to_clipboard(image_path: &str) -> AppResult<()> { + #[cfg(target_os = "macos")] + { + copy_image_to_clipboard_macos(image_path) + } + + #[cfg(target_os = "windows")] + { + copy_image_to_clipboard_windows(image_path) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err(format!("Clipboard copy not supported on this platform")) + } +} /// Copy an image file to the system clipboard using macOS native APIs /// This approach works with clipboard managers like Raycast -pub fn copy_image_to_clipboard(image_path: &str) -> AppResult<()> { +#[cfg(target_os = "macos")] +fn copy_image_to_clipboard_macos(image_path: &str) -> AppResult<()> { + use std::process::Command; + // Use osascript to copy the image file to clipboard // This method properly integrates with macOS clipboard and clipboard managers let script = format!( @@ -26,3 +47,46 @@ pub fn copy_image_to_clipboard(image_path: &str) -> AppResult<()> { Ok(()) } + +/// Copy an image file to the system clipboard using Windows APIs via PowerShell +#[cfg(target_os = "windows")] +fn copy_image_to_clipboard_windows(image_path: &str) -> AppResult<()> { + use std::path::Path; + use std::process::Command; + + // Validate that the file exists and is a regular file + let path = Path::new(image_path); + if !path.exists() { + return Err(format!("File not found: {}", image_path)); + } + if !path.is_file() { + return Err(format!("Path is not a file: {}", image_path)); + } + + // Get the canonical path to ensure it's a valid, absolute path + let canonical_path = path + .canonicalize() + .map_err(|e| format!("Failed to resolve path: {}", e))?; + let path_str = canonical_path.to_string_lossy(); + + // Use PowerShell to copy the image to clipboard + // This uses .NET's System.Windows.Forms.Clipboard class + // Escape single quotes for PowerShell string literal + let escaped_path = path_str.replace("'", "''"); + let script = format!( + r#"Add-Type -AssemblyName System.Windows.Forms; $image = [System.Drawing.Image]::FromFile('{}'); [System.Windows.Forms.Clipboard]::SetImage($image); $image.Dispose()"#, + escaped_path + ); + + let output = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + .map_err(|e| format!("Failed to execute PowerShell: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to copy image to clipboard: {}", stderr)); + } + + Ok(()) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 96554d3..9cc8a30 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,10 +1,12 @@ //! Tauri commands module use std::path::PathBuf; -use std::process::{Command, Stdio}; use std::sync::Mutex; use tauri::AppHandle; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::process::{Command, Stdio}; + use crate::clipboard::copy_image_to_clipboard; use crate::image::{copy_screenshot_to_dir, crop_image, save_base64_image, CropRegion}; use crate::screenshot::{ @@ -96,7 +98,8 @@ pub async fn get_temp_directory() -> Result { .ok_or_else(|| "Failed to convert temp directory path to string".to_string()) } -/// Check if screencapture is already running +/// Check if screencapture is already running (macOS only) +#[cfg(target_os = "macos")] fn is_screencapture_running() -> bool { let output = Command::new("pgrep") .arg("-x") @@ -109,8 +112,15 @@ fn is_screencapture_running() -> bool { } } -/// Check screen recording permission by attempting a minimal test +/// Stub for Windows - always returns false since we use different capture mechanism +#[cfg(target_os = "windows")] +fn is_screencapture_running() -> bool { + false +} + +/// Check screen recording permission by attempting a minimal test (macOS only) /// This helps macOS recognize the permission is already granted +#[cfg(target_os = "macos")] fn check_and_activate_permission() -> Result<(), String> { let test_path = std::env::temp_dir().join(format!("bs_test_{}.png", std::process::id())); @@ -151,10 +161,35 @@ fn check_and_activate_permission() -> Result<(), String> { } } -/// Capture screenshot using macOS native screencapture with interactive selection -/// This properly handles Screen Recording permissions through the system +/// Windows doesn't require special permission checks for screen capture +#[cfg(target_os = "windows")] +fn check_and_activate_permission() -> Result<(), String> { + Ok(()) +} + +/// Capture screenshot with interactive selection +/// Uses platform-specific capture mechanisms #[tauri::command] pub async fn native_capture_interactive(save_dir: String) -> Result { + #[cfg(target_os = "macos")] + { + native_capture_interactive_macos(save_dir).await + } + + #[cfg(target_os = "windows")] + { + native_capture_interactive_windows(save_dir).await + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err("Interactive capture not supported on this platform".to_string()) + } +} + +/// macOS implementation using screencapture +#[cfg(target_os = "macos")] +async fn native_capture_interactive_macos(save_dir: String) -> Result { let _lock = SCREENCAPTURE_LOCK .lock() .map_err(|e| format!("Failed to acquire lock: {}", e))?; @@ -207,9 +242,64 @@ pub async fn native_capture_interactive(save_dir: String) -> Result Result { + use xcap::Monitor; + + let _lock = SCREENCAPTURE_LOCK + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + // On Windows, we use xcap library for screen capture. + // For interactive selection, we capture all monitors and let the frontend handle selection. + let monitors = Monitor::all().map_err(|e| format!("Failed to get monitors: {}", e))?; + + if monitors.is_empty() { + return Err("No monitors available".to_string()); + } + + // Capture the primary monitor + let primary_monitor = &monitors[0]; + let image = primary_monitor + .capture_image() + .map_err(|e| format!("Failed to capture screen: {}", e))?; + + let filename = generate_filename("screenshot", "png")?; + let save_path = PathBuf::from(&save_dir); + let screenshot_path = save_path.join(&filename); + let path_str = screenshot_path.to_string_lossy().to_string(); + + image + .save(&screenshot_path) + .map_err(|e| format!("Failed to save screenshot: {}", e))?; + + Ok(path_str) +} + +/// Capture full screen +/// Uses platform-specific capture mechanisms #[tauri::command] pub async fn native_capture_fullscreen(save_dir: String) -> Result { + #[cfg(target_os = "macos")] + { + native_capture_fullscreen_macos(save_dir).await + } + + #[cfg(target_os = "windows")] + { + native_capture_fullscreen_windows(save_dir).await + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err("Fullscreen capture not supported on this platform".to_string()) + } +} + +/// macOS implementation using screencapture +#[cfg(target_os = "macos")] +async fn native_capture_fullscreen_macos(save_dir: String) -> Result { let _lock = SCREENCAPTURE_LOCK .lock() .map_err(|e| format!("Failed to acquire lock: {}", e))?; @@ -244,54 +334,181 @@ pub async fn native_capture_fullscreen(save_dir: String) -> Result Result { + use xcap::Monitor; + + let _lock = SCREENCAPTURE_LOCK + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + let monitors = Monitor::all().map_err(|e| format!("Failed to get monitors: {}", e))?; + + if monitors.is_empty() { + return Err("No monitors available".to_string()); + } + + // Capture the primary monitor + let primary_monitor = &monitors[0]; + let image = primary_monitor + .capture_image() + .map_err(|e| format!("Failed to capture screen: {}", e))?; + + let filename = generate_filename("screenshot", "png")?; + let save_path = PathBuf::from(&save_dir); + let screenshot_path = save_path.join(&filename); + let path_str = screenshot_path.to_string_lossy().to_string(); + + image + .save(&screenshot_path) + .map_err(|e| format!("Failed to save screenshot: {}", e))?; + + Ok(path_str) +} + +/// Play the screenshot sound +/// Uses platform-specific sound mechanisms #[tauri::command] pub async fn play_screenshot_sound() -> Result<(), String> { - // macOS system screenshot sound path - let sound_path = "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system/Screen Capture.aif"; - - // Use afplay to play the sound asynchronously (non-blocking) - std::thread::spawn(move || { - let _ = Command::new("afplay") - .arg(sound_path) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - }); - + #[cfg(target_os = "macos")] + { + // macOS system screenshot sound path + let sound_path = "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system/Screen Capture.aif"; + + // Use afplay to play the sound asynchronously (non-blocking) + std::thread::spawn(move || { + let _ = Command::new("afplay") + .arg(sound_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + }); + } + + #[cfg(target_os = "windows")] + { + // Use PowerShell to play Windows system sound + std::thread::spawn(move || { + let _ = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Media.SystemSounds]::Asterisk.Play()", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + }); + } + Ok(()) } /// Get the current mouse cursor position (for determining which screen to open editor on) #[tauri::command] pub async fn get_mouse_position() -> Result<(f64, f64), String> { - // Use AppleScript to get mouse position - it's the most reliable cross-version approach + #[cfg(target_os = "macos")] + { + get_mouse_position_macos().await + } + + #[cfg(target_os = "windows")] + { + get_mouse_position_windows().await + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err("Mouse position not supported on this platform".to_string()) + } +} + +/// macOS implementation using AppleScript +#[cfg(target_os = "macos")] +async fn get_mouse_position_macos() -> Result<(f64, f64), String> { let output = Command::new("osascript") .arg("-e") .arg("tell application \"System Events\" to return (get position of mouse)") .output() .map_err(|e| format!("Failed to get mouse position: {}", e))?; - + if !output.status.success() { return Err("Failed to get mouse position".to_string()); } - + let position_str = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = position_str.trim().split(", ").collect(); - + + if parts.len() != 2 { + return Err("Invalid mouse position format".to_string()); + } + + let x: f64 = parts[0] + .parse() + .map_err(|_| "Failed to parse X coordinate")?; + let y: f64 = parts[1] + .parse() + .map_err(|_| "Failed to parse Y coordinate")?; + + Ok((x, y)) +} + +/// Windows implementation using PowerShell +#[cfg(target_os = "windows")] +async fn get_mouse_position_windows() -> Result<(f64, f64), String> { + let script = r#"Add-Type -AssemblyName System.Windows.Forms; $pos = [System.Windows.Forms.Cursor]::Position; Write-Output "$($pos.X),$($pos.Y)""#; + + let output = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .map_err(|e| format!("Failed to get mouse position: {}", e))?; + + if !output.status.success() { + return Err("Failed to get mouse position".to_string()); + } + + let position_str = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = position_str.trim().split(',').collect(); + if parts.len() != 2 { return Err("Invalid mouse position format".to_string()); } - - let x: f64 = parts[0].parse().map_err(|_| "Failed to parse X coordinate")?; - let y: f64 = parts[1].parse().map_err(|_| "Failed to parse Y coordinate")?; - + + let x: f64 = parts[0] + .parse() + .map_err(|_| "Failed to parse X coordinate")?; + let y: f64 = parts[1] + .parse() + .map_err(|_| "Failed to parse Y coordinate")?; + Ok((x, y)) } -/// Capture specific window using macOS native screencapture +/// Capture specific window +/// Uses platform-specific capture mechanisms #[tauri::command] pub async fn native_capture_window(save_dir: String) -> Result { + #[cfg(target_os = "macos")] + { + native_capture_window_macos(save_dir).await + } + + #[cfg(target_os = "windows")] + { + native_capture_window_windows(save_dir).await + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Err("Window capture not supported on this platform".to_string()) + } +} + +/// macOS implementation using screencapture +#[cfg(target_os = "macos")] +async fn native_capture_window_macos(save_dir: String) -> Result { let _lock = SCREENCAPTURE_LOCK .lock() .map_err(|e| format!("Failed to acquire lock: {}", e))?; @@ -343,3 +560,12 @@ pub async fn native_capture_window(save_dir: String) -> Result { Err("Screenshot was cancelled or failed".to_string()) } } + +/// Windows implementation - falls back to fullscreen capture +/// since programmatic window capture requires more complex Windows APIs +#[cfg(target_os = "windows")] +async fn native_capture_window_windows(save_dir: String) -> Result { + // On Windows, capturing a specific window programmatically is complex + // Fall back to fullscreen capture and let user crop in the editor + native_capture_fullscreen_windows(save_dir).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3300ada..edf7233 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,8 +12,8 @@ mod utils; use commands::{ capture_all_monitors, capture_once, capture_region, get_desktop_directory, get_mouse_position, - get_temp_directory, native_capture_fullscreen, native_capture_interactive, native_capture_window, - play_screenshot_sound, save_edited_image, + get_temp_directory, native_capture_fullscreen, native_capture_interactive, + native_capture_window, play_screenshot_sound, save_edited_image, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src-tauri/src/screenshot.rs b/src-tauri/src/screenshot.rs index 413a3ad..2df6e1e 100644 --- a/src-tauri/src/screenshot.rs +++ b/src-tauri/src/screenshot.rs @@ -20,8 +20,7 @@ pub struct MonitorShot { /// Capture screenshots of all available monitors pub fn capture_all_monitors(save_dir: &str) -> AppResult> { - let monitors = Monitor::all() - .map_err(|e| format!("Failed to get monitors: {}", e))?; + let monitors = Monitor::all().map_err(|e| format!("Failed to get monitors: {}", e))?; if monitors.is_empty() { return Err("No monitors available".into()); @@ -42,11 +41,13 @@ pub fn capture_all_monitors(save_dir: &str) -> AppResult> { /// Capture a single monitor screenshot fn capture_single_monitor(monitor: &Monitor, save_path: &PathBuf) -> AppResult { - let monitor_id = monitor.id() + let monitor_id = monitor + .id() .map_err(|e| format!("Failed to get monitor id: {}", e))?; // Capture the screenshot - let image = monitor.capture_image() + let image = monitor + .capture_image() .map_err(|e| format!("Failed to capture monitor {}: {}", monitor_id, e))?; // Generate unique filename @@ -54,19 +55,25 @@ fn capture_single_monitor(monitor: &Monitor, save_path: &PathBuf) -> AppResult AppResult AppResult { +pub async fn capture_primary_monitor(app_handle: tauri::AppHandle) -> AppResult { use tauri_plugin_screenshots::{get_monitor_screenshot, get_screenshotable_monitors}; let monitors = get_screenshotable_monitors() @@ -94,8 +99,7 @@ pub async fn capture_primary_monitor( return Err("No monitors available".into()); } - let primary_monitor = monitors.first() - .ok_or("No monitor found")?; + let primary_monitor = monitors.first().ok_or("No monitor found")?; let screenshot_path = get_monitor_screenshot(app_handle, primary_monitor.id) .await diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 66b6eb6..760ce1d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,7 +37,9 @@ "$HOME/Pictures/**", "$PICTURES/**", "$TEMP/**", - "$APPDATA/**" + "$APPDATA/**", + "$LOCALAPPDATA/**", + "$DESKTOP/**" ] } }, @@ -55,6 +57,11 @@ ], "macOS": { "signingIdentity": "-" + }, + "windows": { + "wix": { + "language": "en-US" + } } } } diff --git a/src/App.tsx b/src/App.tsx index e3bde0c..c77d51c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,16 +28,7 @@ const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [ { id: "window", action: "Capture Window", shortcut: "CommandOrControl+Shift+4", enabled: false }, ]; -function formatShortcut(shortcut: string): string { - return shortcut - .replace(/CommandOrControl/g, "⌘") - .replace(/Command/g, "⌘") - .replace(/Control/g, "⌃") - .replace(/Shift/g, "⇧") - .replace(/Alt/g, "⌥") - .replace(/Option/g, "⌥") - .replace(/\+/g, ""); -} +import { formatShortcutForPlatform, isMacOS } from "@/lib/utils"; async function restoreWindowOnScreen(mouseX?: number, mouseY?: number) { const appWindow = getCurrentWindow(); @@ -423,11 +414,11 @@ function App() { const getShortcutDisplay = (actionId: string): string => { const shortcut = shortcuts.find(s => s.id === actionId); if (shortcut && shortcut.enabled) { - return formatShortcut(shortcut.shortcut); + return formatShortcutForPlatform(shortcut.shortcut); } // Fallback to defaults const defaultShortcut = DEFAULT_SHORTCUTS.find(s => s.id === actionId); - return defaultShortcut ? formatShortcut(defaultShortcut.shortcut) : "—"; + return defaultShortcut ? formatShortcutForPlatform(defaultShortcut.shortcut) : "—"; }; if (mode === "editing" && tempScreenshotPath) { @@ -551,7 +542,7 @@ function App() {
Save - ⌘S + {isMacOS() ? "⌘S" : "Ctrl+S"}
diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index b310a8f..c2c28fc 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -4,260 +4,281 @@ import { Card, CardContent } from "@/components/ui/card"; import { OnboardingStep } from "./OnboardingStep"; import { OnboardingProgress } from "./OnboardingProgress"; import { markOnboardingComplete } from "@/lib/onboarding"; +import { isMacOS } from "@/lib/utils"; interface OnboardingFlowProps { onComplete: () => void; } -const ONBOARDING_STEPS = [ - { - id: "welcome", - title: "Welcome to Better Shot", - description: - "Your open-source alternative to CleanShot X. Let's get you started with a quick tour of the app.", - icon: ( - - - - ), - }, - { - id: "capture-modes", - title: "Capture Modes", - description: "Better Shot offers three ways to capture your screen. Choose the one that fits your needs.", - icon: ( - - - - - ), - content: ( -
-
-
-
- - - -
-
Region
-
Select area
+interface OnboardingStepConfig { + id: string; + title: string; + description: string; + icon: React.ReactNode; + content?: React.ReactNode; +} + +function getOnboardingSteps(): OnboardingStepConfig[] { + const isMac = isMacOS(); + const modKey = isMac ? "⌘" : "Ctrl+"; + const shiftKey = isMac ? "⇧" : "Shift+"; + + const steps: OnboardingStepConfig[] = [ + { + id: "welcome", + title: "Welcome to Better Shot", + description: + "Your open-source alternative to CleanShot X. Let's get you started with a quick tour of the app.", + icon: ( + + + + ), + }, + { + id: "capture-modes", + title: "Capture Modes", + description: "Better Shot offers three ways to capture your screen. Choose the one that fits your needs.", + icon: ( + + + + + ), + content: ( +
+
+
+
+ + + +
+
Region
+
Select area
+
-
-
-
- - - -
-
Fullscreen
-
Entire screen
+
+
+ + + +
+
Fullscreen
+
Entire screen
+
-
-
-
- - - - -
-
Window
-
Single window
+
+
+ + + + +
+
Window
+
Single window
+
-
- ), - }, - { - id: "shortcuts", - title: "Keyboard Shortcuts", - description: "Work faster with global hotkeys. They work even when the app is hidden, and you can customize them in Preferences.", - icon: ( - - - - ), - content: ( -
-
- Capture Region - - ⌘⇧2 - -
-
- Capture Fullscreen - - ⌘⇧3 - -
-
- Capture Window - - ⌘⇧4 - -
-

- Customize these shortcuts anytime in Preferences -

-
- ), - }, - { - id: "settings", - title: "Settings & Preferences", - description: "Customize your workflow with powerful settings. Access them anytime via the gear icon.", - icon: ( - - - - - ), - content: ( -
-
-
Auto-apply Background
-

- Enable this on the main screen to instantly apply your default background and save - no editor needed. Perfect for quick captures. -

-
-
-
Default Background
-

- Set your preferred background in Preferences. It will be used for auto-apply mode and as the default in the editor. -

-
-
-
Save Directory
-

- Screenshots save to your Desktop by default. Change this in Preferences. -

-
-
-
Copy to Clipboard
-

- Automatically copy screenshots to your clipboard for quick sharing. + ), + }, + { + id: "shortcuts", + title: "Keyboard Shortcuts", + description: "Work faster with global hotkeys. They work even when the app is hidden, and you can customize them in Preferences.", + icon: ( + + + + ), + content: ( +

+
+ Capture Region + + {modKey}{shiftKey}2 + +
+
+ Capture Fullscreen + + {modKey}{shiftKey}3 + +
+
+ Capture Window + + {modKey}{shiftKey}4 + +
+

+ Customize these shortcuts anytime in Preferences

-
- ), - }, - { - id: "permissions", - title: "Screen Recording Permission", - description: - "Better Shot needs Screen Recording permission to capture your screen. This is required by macOS for security.", - icon: ( - - - - ), - content: ( -
-
-
- - - -
-
Important
-

- When you first try to capture a screenshot, macOS will show a permission dialog. You - must grant Screen Recording permission for Better Shot to work. -

-
+ ), + }, + { + id: "settings", + title: "Settings & Preferences", + description: "Customize your workflow with powerful settings. Access them anytime via the gear icon.", + icon: ( + + + + + ), + content: ( +
+
+
Auto-apply Background
+

+ Enable this on the main screen to instantly apply your default background and save - no editor needed. Perfect for quick captures. +

-
-
-
How to Grant Permission
-
    -
  1. Click "Open System Settings" when the permission dialog appears
  2. -
  3. Or go to System Settings → Privacy & Security → Screen Recording
  4. -
  5. Toggle on "bettershot" in the list
  6. -
  7. Restart Better Shot for the permission to take effect
  8. -
+
Default Background
+

+ Set your preferred background in Preferences. It will be used for auto-apply mode and as the default in the editor. +

+
+
+
Save Directory
+

+ Screenshots save to your Desktop by default. Change this in Preferences. +

-
What You'll See
+
Copy to Clipboard

- A system dialog will appear saying: "bettershot" would like to record this computer's screen and audio. Click "Open System Settings" to grant access. + Automatically copy screenshots to your clipboard for quick sharing.

-
- ), - }, - { + ), + }, + ]; + + // Add macOS-specific permissions step + if (isMac) { + steps.push({ + id: "permissions", + title: "Screen Recording Permission", + description: + "Better Shot needs Screen Recording permission to capture your screen. This is required by macOS for security.", + icon: ( + + + + ), + content: ( +
+
+
+ + + +
+
Important
+

+ When you first try to capture a screenshot, macOS will show a permission dialog. You + must grant Screen Recording permission for Better Shot to work. +

+
+
+
+
+
+
How to Grant Permission
+
    +
  1. Click "Open System Settings" when the permission dialog appears
  2. +
  3. Or go to System Settings → Privacy & Security → Screen Recording
  4. +
  5. Toggle on "bettershot" in the list
  6. +
  7. Restart Better Shot for the permission to take effect
  8. +
+
+
+
What You'll See
+

+ A system dialog will appear saying: "bettershot" would like to record this computer's screen and audio. Click "Open System Settings" to grant access. +

+
+
+
+ ), + }); + } + + // Add final step + steps.push({ id: "ready", title: "You're All Set!", description: "Start capturing screenshots and editing them with beautiful backgrounds and effects.", @@ -274,7 +295,7 @@ const ONBOARDING_STEPS = [ content: (

- Press ⌘⇧2 to capture a region, or use the buttons on the main screen. + Press {modKey}{shiftKey}2 to capture a region, or use the buttons on the main screen.

@@ -283,14 +304,17 @@ const ONBOARDING_STEPS = [

), - }, -]; + }); + + return steps; +} export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const [currentStep, setCurrentStep] = useState(0); + const steps = getOnboardingSteps(); const handleNext = () => { - if (currentStep < ONBOARDING_STEPS.length - 1) { + if (currentStep < steps.length - 1) { setCurrentStep(currentStep + 1); } else { markOnboardingComplete(); @@ -309,14 +333,14 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { onComplete(); }; - const step = ONBOARDING_STEPS[currentStep]; + const step = steps[currentStep]; const isFirstStep = currentStep === 0; - const isLastStep = currentStep === ONBOARDING_STEPS.length - 1; + const isLastStep = currentStep === steps.length - 1; return (
- +