feat(gui): add Tauri v2 desktop GUI application#17
Conversation
Tauri v2 + React + Tailwind + shadcn/ui 기반 데스크톱 GUI 설계. 사이드바 네비게이션 구조로 5개 기능 전체 지원, 단일/배치 처리, 전/후 비교 미리보기 포함.
10단계 구현 계획: 스캐폴딩 → Tailwind/shadcn → Rust IPC → Sidebar → DropZone → Options → 처리 실행 → 미리보기 → 배치 → Settings
Add Tauri v2 with React + TypeScript frontend scaffolding: - gui/src-tauri: Rust backend with slimg-core dependency - gui/: React + Vite frontend with bun package manager - Add gui/src-tauri to Cargo workspace members - Configure product name as "slimg" with io.clroot.slimg identifier
- Install Tailwind CSS v4 with Vite plugin (no PostCSS config needed) - Initialize shadcn/ui with New York style, Zinc base color, CSS variables - Add shadcn components: button, select, slider, input, label, radio-group, checkbox, progress, separator, tooltip - Configure path alias (@/) in tsconfig.json and vite.config.ts - Remove scaffold boilerplate: greet command, demo SVGs, App.css - Remove tauri-plugin-opener dependency (Rust + JS) - Update index.html title to "slimg"
Add 4 Tauri commands bridging the frontend to slimg-core: - load_image: decode file and return metadata with base64 thumbnail - process_image: apply pipeline (convert/optimize/resize/crop/extend) and save - preview_image: same as process_image but returns base64 without saving - process_batch: process multiple files with batch-progress events
Add a sidebar with 5 feature navigation items (Convert, Optimize, Resize, Crop, Extend) and a Settings button. Update App.tsx with the sidebar + main content area layout using sidebar theme variables.
- Create typed API wrapper (lib/tauri.ts) matching Rust IPC commands - Add DropZone component with native OS drag-and-drop via Tauri events and file picker dialog using @tauri-apps/plugin-dialog - Integrate DropZone in App.tsx with image loading and thumbnail display - Register tauri-plugin-dialog in Rust backend and capabilities
- Add useImageProcess hook for single-file processing state management - Add Process button with loading state in App.tsx - Add ProcessResultCard component with size comparison and compression % - Add cross-platform basename/capitalize utilities - Reset options and result when switching features
Add ImagePreview component with side-by-side before/after comparison view showing original and result thumbnails, file metadata, and compression ratio. Extend useImageProcess hook to automatically load result file info for thumbnail display. Update App.tsx to show the comparison view after processing completes with Process Again and Clear action buttons.
- Extract formatBytes utility to shared lib/format.ts, removing duplicates from App.tsx, ImagePreview.tsx, and ProcessResultCard.tsx - Add useBatchProcess hook that listens to Tauri "batch-progress" events and tracks per-file processing status (pending/processing/completed/error) - Add BatchList component with progress bar, per-file status icons, size change display, and completion summary - Update App.tsx to branch into batch mode when 2+ files are selected, showing "Process All" button and real-time BatchList during processing - Completed batch items are clickable to show before/after comparison
Add Settings page with default output directory, quality, and overwrite options. Settings are persisted to localStorage and applied when processing images. Also set minimum window size to 800x600.
…reads All Tauri commands (load_image, process_image, preview_image, process_batch) now use tauri::async_runtime::spawn_blocking to avoid blocking the main thread and causing the WebView to freeze during image processing. Also adds scan_directory command for recursive image file discovery.
- DropZone: support drag-and-drop folders with recursive scanning - DropZone: add "Browse Folder" button alongside "Browse Files" - App: add overwrite checkbox to processing options - App: fix options state merge to prevent mutual overwrites
Builds Tauri app for macOS (ARM + Intel), Linux, and Windows using tauri-apps/tauri-action. Includes platform-specific native dependency setup for libjxl, dav1d, and mozjpeg. Creates a draft GitHub release on gui-v* tag push.
Summary of ChangesHello @clroot, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 slimg 프로젝트에 Tauri v2 기반의 데스크톱 GUI 애플리케이션을 도입하여 비개발자 및 디자이너 사용자들이 이미지 처리 기능을 더욱 쉽게 활용할 수 있도록 합니다. 기존 Rust 코어 로직을 재사용하면서 React 프론트엔드로 직관적인 인터페이스를 구축하고, 다양한 이미지 조작 기능을 제공합니다. 또한, 효율적인 이미지 처리를 위한 비동기 로직과 여러 플랫폼을 지원하는 빌드 시스템을 포함하여 애플리케이션의 접근성과 유용성을 크게 향상시킵니다. Highlights
Changelog
Ignored Files
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
- Remove unused maintainAspect state from ResizeOptions - Extract calcSavingsPercent util to eliminate duplication - Add eslint-disable for intentional mount-only useEffect deps - Wrap handleFilesSelected in useCallback to stabilize DropZone listener - Add pull_request trigger to release-gui.yml for CI build testing - Only create GitHub release on tag push, not on PR builds
There was a problem hiding this comment.
Code Review
The pull request successfully adds a GUI application using Tauri v2 and React, demonstrating good architecture and component separation. However, critical security vulnerabilities have been identified, including a disabled Content Security Policy (CSP) and unrestricted file system operations (std::fs) using user-supplied paths, which elevate the risk of XSS. The recursive directory scanning also presents a Denial of Service (DoS) risk due to symlink loops. Furthermore, the implementation could benefit from improvements in stability for large file processing (concurrency control), safety in directory traversal (recursive calls), and efficiency for large file loading and processing (memory and unnecessary re-decoding). A minor issue is that custom commands are missing from capabilities/default.json.
gui/src/App.tsx
Outdated
| const loaded = await Promise.all( | ||
| paths.map(async (path) => ({ | ||
| path, | ||
| info: await api.loadImage(path), | ||
| })) | ||
| ); |
There was a problem hiding this comment.
Promise.all과 map을 조합하여 모든 이미지를 동시에 로드하고 있습니다. 파일 개수가 많을 경우 브라우저와 백엔드 리소스를 과도하게 점유하여 앱이 불안정해질 수 있습니다. 순차적으로 처리하거나 동시 실행 개수를 제한하는 방식을 권장합니다.
| const loaded = await Promise.all( | |
| paths.map(async (path) => ({ | |
| path, | |
| info: await api.loadImage(path), | |
| })) | |
| ); | |
| const loaded = []; | |
| for (const path of paths) { | |
| loaded.push({ | |
| path, | |
| info: await api.loadImage(path), | |
| }); | |
| } |
| pub fn scan_directory(path: String) -> Result<Vec<String>, String> { | ||
| let dir_path = Path::new(&path); | ||
| if !dir_path.is_dir() { | ||
| return Err(format!("Not a directory: {}", path)); | ||
| } | ||
|
|
||
| let mut files = Vec::new(); | ||
| collect_images(dir_path, &mut files)?; | ||
| files.sort(); | ||
| Ok(files) | ||
| } | ||
|
|
||
| fn collect_images(dir: &Path, out: &mut Vec<String>) -> Result<(), String> { | ||
| let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; | ||
| for entry in entries { | ||
| let entry = entry.map_err(|e| e.to_string())?; | ||
| let path = entry.path(); | ||
| if path.is_dir() { | ||
| collect_images(&path, out)?; | ||
| } else if Format::from_extension(&path).is_some() { | ||
| out.push(path.to_string_lossy().to_string()); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[tauri::command] | ||
| pub async fn load_image(path: String) -> Result<ImageInfo, String> { | ||
| tauri::async_runtime::spawn_blocking(move || { | ||
| let file_path = Path::new(&path); | ||
|
|
||
| if Format::from_extension(file_path).is_none() { | ||
| let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); | ||
| return Err(format!("Unsupported file type: {}", ext)); | ||
| } | ||
|
|
||
| let raw_bytes = std::fs::read(file_path).map_err(|e| e.to_string())?; | ||
| let size_bytes = raw_bytes.len() as u64; | ||
|
|
||
| let (image, format) = slimg_core::decode(&raw_bytes).map_err(|e| e.to_string())?; | ||
|
|
||
| let thumbnail = slimg_core::resize::resize( | ||
| &image, | ||
| &ResizeMode::Fit(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION), | ||
| ) | ||
| .map_err(|e| e.to_string())?; | ||
|
|
||
| let png_bytes = encode_as_png(&thumbnail)?; | ||
| let thumbnail_base64 = BASE64.encode(&png_bytes); | ||
|
|
||
| Ok(ImageInfo { | ||
| width: image.width, | ||
| height: image.height, | ||
| format: format.extension().to_string(), | ||
| size_bytes, | ||
| thumbnail_base64, | ||
| }) | ||
| }) | ||
| .await | ||
| .map_err(|e| format!("Task failed: {}", e))? | ||
| } | ||
|
|
||
| #[tauri::command] | ||
| pub async fn process_image(input: String, options: ProcessOptions) -> Result<ProcessResult, String> { | ||
| tauri::async_runtime::spawn_blocking(move || process_single_file(&input, &options)) | ||
| .await | ||
| .map_err(|e| format!("Task failed: {}", e))? | ||
| } | ||
|
|
||
| #[tauri::command] | ||
| pub async fn preview_image(input: String, options: ProcessOptions) -> Result<PreviewResult, String> { | ||
| tauri::async_runtime::spawn_blocking(move || { | ||
| let input_path = Path::new(&input); | ||
|
|
||
| let raw_bytes = std::fs::read(input_path).map_err(|e| e.to_string())?; | ||
| let (image, source_format) = slimg_core::decode(&raw_bytes).map_err(|e| e.to_string())?; | ||
|
|
||
| let pipeline_result = if matches!(options.operation, Operation::Optimize) { | ||
| slimg_core::optimize(&raw_bytes, options.quality).map_err(|e| e.to_string())? | ||
| } else { | ||
| let pipeline_options = build_pipeline_options(&options, source_format)?; | ||
| slimg_core::convert(&image, &pipeline_options).map_err(|e| e.to_string())? | ||
| }; | ||
|
|
||
| let (decoded_result, _) = | ||
| slimg_core::decode(&pipeline_result.data).map_err(|e| e.to_string())?; | ||
|
|
||
| let data_base64 = BASE64.encode(&pipeline_result.data); | ||
|
|
||
| Ok(PreviewResult { | ||
| data_base64, | ||
| size_bytes: pipeline_result.data.len() as u64, | ||
| width: decoded_result.width, | ||
| height: decoded_result.height, | ||
| format: pipeline_result.format.extension().to_string(), | ||
| }) | ||
| }) | ||
| .await | ||
| .map_err(|e| format!("Task failed: {}", e))? | ||
| } | ||
|
|
||
| #[tauri::command] | ||
| pub async fn process_batch( | ||
| inputs: Vec<String>, | ||
| options: ProcessOptions, | ||
| window: tauri::Window, | ||
| ) -> Result<(), String> { | ||
| let total = inputs.len(); | ||
|
|
||
| for (index, file_path) in inputs.iter().enumerate() { | ||
| let progress_processing = BatchProgress { | ||
| index, | ||
| total, | ||
| file_path: file_path.clone(), | ||
| status: "processing".to_string(), | ||
| result: None, | ||
| error: None, | ||
| }; | ||
| let _ = window.emit("batch-progress", &progress_processing); | ||
|
|
||
| let fp = file_path.clone(); | ||
| let opts = options.clone(); | ||
| let result = tauri::async_runtime::spawn_blocking(move || process_single_file(&fp, &opts)) | ||
| .await | ||
| .map_err(|e| format!("Task failed: {}", e))?; |
There was a problem hiding this comment.
The scan_directory command, along with others, accepts arbitrary file paths from the frontend and uses them directly with std::fs operations without validation or scoping. This poses a significant security risk, as a compromised frontend (e.g., via XSS) could read or overwrite arbitrary files. It is crucial to implement strict path validation or utilize Tauri's scoped file system permissions to restrict operations to intended directories. Furthermore, the current recursive directory traversal method is susceptible to stack overflow in very deep folder structures; an iterative approach using a Vec as a stack would be safer and more robust.
fn collect_images(dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
let mut stack = vec![dir.to_path_buf()];
while let Some(current_dir) = stack.pop() {
let entries = std::fs::read_dir(current_dir).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if Format::from_extension(&path).is_some() {
out.push(path.to_string_lossy().to_string());
}
}
}
Ok(())
}| ], | ||
| "security": { | ||
| "csp": null | ||
| } |
There was a problem hiding this comment.
The Content Security Policy (CSP) is explicitly disabled (csp: null). This removes a critical layer of defense against Cross-Site Scripting (XSS) attacks. In a Tauri application, an XSS vulnerability can be escalated to execute sensitive Rust commands, potentially leading to full system compromise. It is highly recommended to implement a restrictive CSP that only allows trusted sources.
| fn collect_images(dir: &Path, out: &mut Vec<String>) -> Result<(), String> { | ||
| let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; | ||
| for entry in entries { | ||
| let entry = entry.map_err(|e| e.to_string())?; | ||
| let path = entry.path(); | ||
| if path.is_dir() { | ||
| collect_images(&path, out)?; | ||
| } else if Format::from_extension(&path).is_some() { | ||
| out.push(path.to_string_lossy().to_string()); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
The collect_images function recursively scans directories using path.is_dir(), which follows symbolic links. A directory structure containing a symlink loop will cause infinite recursion, leading to a stack overflow and crashing the backend process.
Recommendation: Use path.symlink_metadata()?.is_dir() to avoid following symlinks during recursion, and consider implementing a maximum recursion depth.
| return Err(format!("Unsupported file type: {}", ext)); | ||
| } | ||
|
|
||
| let raw_bytes = std::fs::read(file_path).map_err(|e| e.to_string())?; |
gui/src-tauri/src/commands.rs
Outdated
| let (decoded_result, _) = | ||
| slimg_core::decode(&pipeline_result.data).map_err(|e| e.to_string())?; |
- Add width/height to PipelineResult to avoid redundant re-decoding - Limit concurrent image loading to 5 at a time - Enable CSP for defense-in-depth security - Use entry.file_type() to prevent symlink loop in directory scan
Option components were initializing quality to hardcoded 80, ignoring the user's configured default quality in settings. Now defaultQuality is passed as a prop through OptionsPanel to all option components.
PipelineResult now includes width/height fields, which is a public API addition. Bump all dependent crates to reflect the updated dependency.
Summary
Changes
GUI Application (
gui/)load_image,process_image,preview_image,process_batch,scan_directoryspawn_blocking으로 비동기 처리 (UI 프리징 방지)CI (
release-gui.yml)tauri-apps/tauri-action으로 4개 플랫폼 빌드gui-v*태그 푸시 시 Draft Release 자동 생성Test plan
cd gui && bun run tauri dev로 앱 실행 확인release-gui.yml워크플로우 빌드 성공 확인