From 3b3fb05ebc6336a7a2e80be808e06e649df7ca4c Mon Sep 17 00:00:00 2001 From: Yann Birba Date: Mon, 2 Mar 2026 23:02:14 +0100 Subject: [PATCH 1/3] Integrate tauri-specta across backend/frontend IPC contracts --- PLAN.md | 234 +++++++++++ package.json | 2 + scripts/check-ipc-contract.sh | 67 +++ src-tauri/Cargo.lock | 84 ++++ src-tauri/Cargo.toml | 5 +- src-tauri/src/ai_processing.rs | 8 +- src-tauri/src/culling.rs | 9 +- src-tauri/src/file_management.rs | 65 ++- src-tauri/src/image_processing.rs | 15 +- src-tauri/src/lens_correction.rs | 8 +- src-tauri/src/main.rs | 340 +++++++++------ src-tauri/src/mask_generation.rs | 12 +- src-tauri/src/negative_conversion.rs | 7 +- src-tauri/src/tagging.rs | 5 + src/App.tsx | 245 +++++++---- src/bindings.ts | 394 ++++++++++++++++++ src/components/modals/CollageModal.tsx | 8 +- src/components/modals/CullingModal.tsx | 6 +- src/components/modals/LensCorrectionModal.tsx | 36 +- .../modals/NegativeConversionModal.tsx | 18 +- src/components/modals/TransformModal.tsx | 16 +- src/components/panel/CommunityPage.tsx | 23 +- src/components/panel/Editor.tsx | 16 +- src/components/panel/MainLibrary.tsx | 5 +- src/components/panel/SettingsPanel.tsx | 22 +- src/components/panel/right/ExportPanel.tsx | 33 +- .../panel/right/LibraryExportPanel.tsx | 30 +- src/components/panel/right/MetadataPanel.tsx | 8 +- src/components/panel/right/PresetsPanel.tsx | 12 +- src/components/ui/AppProperties.tsx | 77 ---- src/context/TaggingSubMenu.tsx | 7 +- src/hooks/usePresets.ts | 16 +- src/hooks/useThumbnails.tsx | 8 +- src/utils/frontendLogBridge.ts | 7 +- 34 files changed, 1364 insertions(+), 484 deletions(-) create mode 100644 PLAN.md create mode 100644 scripts/check-ipc-contract.sh create mode 100644 src/bindings.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..e20faf881 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,234 @@ +# Plan strict d’intégration `tauri-specta` (complet + POC final) + +**Résumé** +Objectif: remplacer progressivement le contrat IPC “stringly-typed” (`invoke('...')`, `listen('...')`) par un contrat typé généré (`commands.*`, `events.*`) via `tauri-specta`, sans régression runtime, avec un POC final visible dans l’app. + +Le plan couvre: +1. l’infra Rust `tauri-specta`, +2. le typage complet commandes + events, +3. la migration frontend progressive puis totale, +4. le nettoyage des éléments obsolètes, +5. les tests de non-régression et le POC de démonstration. + +**Constat initial vérifié dans le repo** +1. Backend Tauri: 89 commandes `#[tauri::command]` exposées, actuellement enregistrées via `tauri::generate_handler!` dans [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs#L3866). +2. Frontend: enum `Invokes` central dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx#L8) + de nombreux `invoke('...')` en dur (ex: [LensCorrectionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/LensCorrectionModal.tsx#L208), [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx#L653)). +3. Incohérences commandes: +`image_processing::generate_waveform` dans l’enum (backend expose `generate_waveform`), +`invoke_generative_replace` dans l’enum sans commande backend correspondante. +4. Incohérences events: +frontend écoute `export-cancelled`, `hdr-error`, `culling-error` mais backend ne les émet pas; +backend émet `indexing-error`, `panorama-warning`, `thumbnail-generation-error`, `export-complete-with-errors` mais frontend n’en fait pas de traitement central. +5. Edge technique majeur: +3 commandes renvoient `Result` (`generate_original_transformed_preview`, `generate_preset_preview`, `generate_preview_for_path`) et doivent être migrées vers `Vec` pour un contrat Specta propre (migration complète, mais hors POC comme demandé). + +--- + +## Plan d’implémentation détaillé + +### 1. Phase 0 — Préparation et garde-fous +Actions: +1. Geler un baseline technique: `cargo check` backend + `npm run build` frontend. +2. Ajouter une checklist de validation contractuelle (script shell repo) pour comparer: +backend commands vs usage frontend, backend events vs listeners frontend. +3. Fixer dès cette phase la convention de nommage officielle: +commandes Rust snake_case -> bindings TS camelCase. + +Fichiers concernés: +[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx), [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx). + +Critère de sortie: +baseline vert + inventaire automatique des écarts reproduisible. + +### 2. Phase 1 — Infrastructure `tauri-specta` côté Rust +Actions: +1. Mettre à jour [Cargo.toml](/home/yann/dev/RapidRAW/src-tauri/Cargo.toml): +ajouter feature `specta` sur `tauri`, +ajouter `tauri-specta = "=2.0.0-rc.21"` avec features `derive, typescript`, +ajouter `specta` version compatible (`=2.0.0-rc.22`) avec features nécessaires (`derive`, `function`, `serde_json`), +ajouter `specta-typescript = "0.0.9"`. +2. Dans [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs): +créer un `tauri_specta::Builder::::new()`, +déclarer `.commands(collect_commands![...])` avec la liste complète actuelle, +déclarer `.events(collect_events![...])` (initialement vide puis complétée en phase events), +activer `.error_handling(ErrorHandlingMode::Throw)` pour conserver la sémantique frontend actuelle (`try/catch`), +remplacer `.invoke_handler(tauri::generate_handler![...])` par `.invoke_handler(builder.invoke_handler())`, +appeler `builder.mount_events(app)` dans `setup`. +3. Export TS: +générer `src/bindings.ts` depuis Rust en debug, en évitant les réécritures inutiles (comparaison `export_str` vs contenu existant avant écriture). + +Fichiers concernés: +[Cargo.toml](/home/yann/dev/RapidRAW/src-tauri/Cargo.toml), [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), nouveau [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts). + +Critère de sortie: +`cargo check` passe avec `tauri-specta`, `bindings.ts` généré avec `commands` compilables. + +### 3. Phase 2 — Couverture complète commandes et types Specta +Actions: +1. Ajouter `#[specta::specta]` sur les 89 commandes `#[tauri::command]` (dans `main.rs` + modules). +2. Dériver `specta::Type` sur tous les types transitant dans signatures commandes/events: +types de `main.rs` (ex: `ImageDimensions`, `LoadImageResult`, `ExportSettings`, `ResizeOptions`, `Watermark*`, `CommunityPreset`, `LutParseResult`), +types modules `file_management` (`AppSettings`, `ImageFile`, `FolderNode`, `ImportSettings`, `PresetItem`, etc.), +types modules `image_processing` (`ImageMetadata`, `Crop`, `GeometryParams`, `HistogramData`, `WaveformData`), +types modules `culling`, `negative_conversion`, `lens_correction`, `mask_generation`, `ai_processing`. +3. Traiter les erreurs de compilation Specta de façon itérative jusqu’à couverture totale des types imbriqués. +4. Migration binaire complète (hors POC mais dans l’intégration finale): +`Result` -> `Result, String>` pour: +`generate_original_transformed_preview`, +`generate_preset_preview`, +`generate_preview_for_path`. + +Fichiers concernés: +[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), +[file_management.rs](/home/yann/dev/RapidRAW/src-tauri/src/file_management.rs), +[image_processing.rs](/home/yann/dev/RapidRAW/src-tauri/src/image_processing.rs), +[culling.rs](/home/yann/dev/RapidRAW/src-tauri/src/culling.rs), +[negative_conversion.rs](/home/yann/dev/RapidRAW/src-tauri/src/negative_conversion.rs), +[lens_correction.rs](/home/yann/dev/RapidRAW/src-tauri/src/lens_correction.rs), +[mask_generation.rs](/home/yann/dev/RapidRAW/src-tauri/src/mask_generation.rs), +[ai_processing.rs](/home/yann/dev/RapidRAW/src-tauri/src/ai_processing.rs). + +Critère de sortie: +`bindings.ts` contient toutes les commandes avec signatures TS strictes, sans fallback `any` côté génération. + +### 4. Phase 3 — Contrat events typé et unifié +Actions: +1. Définir des structs d’events typés (`Serialize`, `Deserialize`, `Type`, `Event`) pour chaque event métier actuellement utilisé/émis. +2. Enregistrer ces events via `collect_events![...]`. +3. Unifier le contrat event backend/frontend: +ajouter émissions backend manquantes pour préserver l’UX existante (`export-cancelled`, `hdr-error`, `culling-error`), +ajouter traitement frontend pour les events backend actuellement non gérés (`indexing-error`, `panorama-warning`, `thumbnail-generation-error`, `export-complete-with-errors`). +4. Convertir progressivement les `app_handle.emit("string", payload)` vers émissions typées `MyEvent { ... }.emit(...)` pour verrouiller les noms au compile-time. + +Fichiers concernés: +[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), +[file_management.rs](/home/yann/dev/RapidRAW/src-tauri/src/file_management.rs), +[tagging.rs](/home/yann/dev/RapidRAW/src-tauri/src/tagging.rs), +[culling.rs](/home/yann/dev/RapidRAW/src-tauri/src/culling.rs), +[denoising.rs](/home/yann/dev/RapidRAW/src-tauri/src/denoising.rs), +[panorama_stitching.rs](/home/yann/dev/RapidRAW/src-tauri/src/panorama_stitching.rs), +[App.tsx](/home/yann/dev/RapidRAW/src/App.tsx), +[useThumbnails.tsx](/home/yann/dev/RapidRAW/src/hooks/useThumbnails.tsx). + +Critère de sortie: +`events.*` générés dans `bindings.ts` couvrent tous les events utiles et le backend/front utilisent le même contrat. + +### 5. Phase 4 — Migration frontend progressive vers `commands`/`events` +Actions: +1. Introduire l’import `commands, events` depuis [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts). +2. Migrer par zones fonctionnelles pour minimiser le risque: +zone App core ([App.tsx](/home/yann/dev/RapidRAW/src/App.tsx)), +modales géométrie/lens/négatif, +exports, +presets, +thumbnails, +settings. +3. Remplacer `invoke(Invokes.X, ...)` par `commands.x(...)`. +4. Remplacer `listen('event-name', ...)` par `events.eventName.listen(...)`. +5. Remplacer les `any` de payload lorsque le type généré existe. +6. Conserver temporairement compatibilité mixte si nécessaire (zones non migrées), puis converger à 100%. + +Fichiers principaux: +[App.tsx](/home/yann/dev/RapidRAW/src/App.tsx), +[AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx), +[useThumbnails.tsx](/home/yann/dev/RapidRAW/src/hooks/useThumbnails.tsx), +[SettingsPanel.tsx](/home/yann/dev/RapidRAW/src/components/panel/SettingsPanel.tsx), +[LensCorrectionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/LensCorrectionModal.tsx), +[NegativeConversionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/NegativeConversionModal.tsx), +[TransformModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/TransformModal.tsx). + +Critère de sortie: +plus aucun appel IPC “critique” en string brut dans les zones migrées, et types TS stricts compilent. + +### 6. Phase 5 — Nettoyage (remove) et normalisation +Actions: +1. Supprimer l’enum `Invokes` dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx#L8) une fois migration frontend complète. +2. Supprimer les imports inutiles `invoke`/`listen` restants quand remplacés par `commands`/`events`. +3. Supprimer les entrées incohérentes historiques: +`invoke_generative_replace`, +`image_processing::generate_waveform`. +4. Supprimer les paths “double contrat” (fallback legacy) une fois validés. +5. Ajouter une vérification CI/lint simple qui échoue si des `invoke('...')`/`listen('...')` non autorisés réapparaissent. + +Critère de sortie: +contrat IPC centralisé dans `bindings.ts`, plus de dette legacy active. + +### 7. Phase 6 — Validation finale et verrouillage +Actions: +1. Valider builds: +`cargo check`, +`npm run build`, +`npm run lint` (si applicable dans le flux équipe). +2. Vérifier par tests manuels guidés (voir section tests). +3. Vérifier par script de cohérence: +toutes commandes backend exposées dans bindings, +pas de commandes frontend orphelines, +events frontend alignés avec backend. + +Critère de sortie: +pipeline vert + checklist fonctionnelle signée. + +### 8. Phase 7 — POC final (petit flux démonstrateur) +POC choisi: +migrer le flux “AI connector status” pour montrer commande typée + event typé de bout en bout. + +Implémentation POC: +1. Backend: +`check_ai_connector_status` reste la commande de trigger, +event `ai-connector-status-update` devient typé et enregistré dans `collect_events!`. +2. Frontend: +dans [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx#L684), +remplacer `listen('ai-connector-status-update', ...)` par `events.aiConnectorStatusUpdate.listen(...)`, +remplacer `invoke(Invokes.CheckAIConnectorStatus)` par `commands.checkAiConnectorStatus()`. +3. Démo: +au lancement, statut AI se met à jour toutes les 10s avec contrat 100% typé sans string IPC. + +Critère de sortie POC: +flux visible fonctionnel, compilé strict TS, aucun `any` nécessaire sur ce flux. + +--- + +## Changements d’API / interfaces publiques (frontend-backend) +1. Nouveau contrat généré [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts) exposant `commands` et `events`. +2. Côté frontend, l’API d’appel devient `commands.()` au lieu de `invoke('')`. +3. Côté frontend, l’API event devient `events..listen(...)` au lieu de `listen('')`. +4. Gestion d’erreurs maintenue en mode “throw” (`ErrorHandlingMode::Throw`) pour ne pas casser `try/catch`. +5. Migration complète prévue des retours binaires `Response` vers `Vec` pour typings robustes. + +## Liste explicite des suppressions prévues +1. Suppression de `Invokes` dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx). +2. Suppression des constantes invalides `invoke_generative_replace` et `image_processing::generate_waveform`. +3. Suppression des appels `invoke('...')` et `listen('...')` legacy une fois tous migrés. +4. Suppression des handlers/branches de compatibilité temporaires après validation. +5. Suppression des incohérences events non alignées (ou remplacement par émissions backend correspondantes). + +## Edge cases couverts explicitement +1. Compatibilité Tauri/Specta: feature `tauri/specta` obligatoire pour masquer `State`, `AppHandle`, `Window` des signatures JS. +2. `serde_json::Value` dans signatures: feature Specta correspondante requise. +3. Retours binaires `Response`: migration vers `Vec` pour éviter les trous de typing. +4. Contrat erreurs: mode `Throw` imposé pour préserver comportement actuel frontend. +5. Noms events/commands: normalisation snake_case/kebab-case -> camelCase dans bindings. +6. Écarts event payload (string vs object) traités par structs explicites. +7. Événements “oubliés” frontend/backend harmonisés avant finalisation. +8. Régressions cancellation export traitées via event explicite `export-cancelled`. +9. Structures récursives (`FolderNode`) typées et validées. +10. Tuples/Options (`Option<(String, String)>`) vérifiés dans bindings générés. + +## Tests et scénarios d’acceptation +1. Build backend: `cd src-tauri && cargo check` doit passer. +2. Build frontend: `npm run build` doit passer sans erreur TS. +3. Smoke test commandes: +chargement image, réglages, export simple, export batch, presets, import, lens tools, negative conversion. +4. Smoke test events: +preview updates, histogram/waveform, thumbnails, import/export progress, denoise/panorama/hdr, indexing. +5. Test contractuel: +aucune commande frontend orpheline, aucun event frontend mort, aucun nom IPC string non migré en zone finalisée. +6. Test POC: +statut AI connector fonctionnel via `commands + events` générés. + +## Assumptions et defaults retenus +1. Objectif final: migration complète “tout ce qui est possible” vers contrat typé. +2. Stratégie: progressive, sans big-bang, avec compat temporaire contrôlée. +3. Les 3 commandes binaires sont migrées dans l’intégration complète, mais pas dans le POC initial. +4. Les bindings générés `src/bindings.ts` sont versionnés dans le repo. +5. Le contrat event final est aligné bidirectionnellement, en privilégiant la conservation du comportement UX existant. diff --git a/package.json b/package.json index a15379a79..a434b92fc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "vite build", "tauri": "tauri", "start": "tauri dev", + "bindings:gen": "cd src-tauri && RAPIDRAW_EXPORT_BINDINGS_ONLY=1 cargo run --quiet", + "check:ipc": "./scripts/check-ipc-contract.sh", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/scripts/check-ipc-contract.sh b/scripts/check-ipc-contract.sh new file mode 100644 index 000000000..bc7cef864 --- /dev/null +++ b/scripts/check-ipc-contract.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +backend_cmds="$TMP_DIR/backend_cmds.txt" +front_enum_cmds="$TMP_DIR/front_enum_cmds.txt" +front_literal_cmds="$TMP_DIR/front_literal_cmds.txt" +backend_events="$TMP_DIR/backend_events.txt" +front_events="$TMP_DIR/front_events.txt" + +rg -n "#\\[tauri::command\\]" src-tauri/src -A2 --no-heading \ + | rg -o "fn [a-zA-Z0-9_]+" \ + | awk '{print $2}' \ + | sort -u > "$backend_cmds" + +if rg -q "export enum Invokes" src/components/ui/AppProperties.tsx; then + sed -n '/export enum Invokes {/,/^}/p' src/components/ui/AppProperties.tsx \ + | rg -o "'[^']+'" \ + | tr -d "'" \ + | sort -u > "$front_enum_cmds" +else + : > "$front_enum_cmds" +fi + +rg -n "invoke\\(\\s*['\"][^'\"]+['\"]" src --glob '!src-tauri/**' -o \ + | sed -E "s/.*invoke\\(\\s*['\"]([^'\"]+)['\"].*/\\1/" \ + | sort -u > "$front_literal_cmds" + +rg --files src-tauri/src \ + | xargs perl -0777 -ne 'while (/\.emit(?:_to|_filter)?\(\s*"([^"]+)"/g) { print "$1\n"; }' \ + | sort -u > "$backend_events" + +rg --files src --glob '!src-tauri/**' \ + | xargs perl -ne 'while (/(?:listen|once)\(\s*["\x27]([^"\x27]+)["\x27]/g) { print "$1\n"; }' \ + | sort -u > "$front_events" + +if [[ -s "$front_enum_cmds" ]]; then + echo "=== Commands in frontend enum but missing in backend ===" + comm -23 "$front_enum_cmds" "$backend_cmds" || true + echo + + echo "=== Commands in backend but missing in frontend enum ===" + comm -13 "$front_enum_cmds" "$backend_cmds" || true + echo +else + echo "=== Frontend enum command check skipped (Invokes enum removed) ===" + echo +fi + +echo "=== Literal invoke commands (not enum) ===" +cat "$front_literal_cmds" +echo + +echo "=== Events listened in frontend but not emitted by backend ===" +comm -23 "$front_events" "$backend_events" || true +echo + +echo "=== Events emitted by backend but not listened in frontend ===" +comm -13 "$front_events" "$backend_events" || true +echo + +echo "IPC contract check complete." diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 956355f61..a809578f8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "RapidRAW" version = "0.0.0" @@ -49,6 +55,8 @@ dependencies = [ "serde_bytes", "serde_json", "sha2", + "specta", + "specta-typescript", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -57,6 +65,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-specta", "tempfile", "tokenizers", "tokio", @@ -6572,6 +6581,52 @@ dependencies = [ "system-deps", ] +[[package]] +name = "specta" +version = "2.0.0-rc.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971" +dependencies = [ + "paste", + "serde_json", + "specta-macros", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "specta-serde" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63" +dependencies = [ + "specta", + "thiserror 1.0.69", +] + +[[package]] +name = "specta-typescript" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d" +dependencies = [ + "specta", + "specta-serde", + "thiserror 1.0.69", +] + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -6859,6 +6914,7 @@ dependencies = [ "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", "tauri-build", "tauri-macros", @@ -7111,6 +7167,34 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tauri-utils" version = "2.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 977388b3b..15c6115c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.92" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tauri = { version = "2.9", features = [ "macos-private-api", "rustls-tls" ] } +tauri = { version = "2.9", features = [ "macos-private-api", "rustls-tls", "specta" ] } tauri-plugin-dialog = "2.4.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -66,6 +66,9 @@ quick-xml = { version = "0.36", features = ["serialize"] } fuzzy-matcher = "0.3.7" image-hdr = { version = "0.6.0", default-features = false } mozjpeg-rs = "0.8.0" +tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } +specta = { version = "=2.0.0-rc.22", features = ["derive", "function", "serde_json", "tokio"] } +specta-typescript = "0.0.9" [build-dependencies] tauri-build = { version = "2.5", features = [] } diff --git a/src-tauri/src/ai_processing.rs b/src-tauri/src/ai_processing.rs index 593c6bba2..c5511d562 100644 --- a/src-tauri/src/ai_processing.rs +++ b/src-tauri/src/ai_processing.rs @@ -509,7 +509,7 @@ pub fn run_u2netp_model(image: &DynamicImage, u2netp_session: &Mutex) - Ok(final_mask) } -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, specta::Type)] #[serde(rename_all = "camelCase")] pub struct AiSubjectMaskParameters { pub start_x: f64, @@ -528,7 +528,7 @@ pub struct AiSubjectMaskParameters { pub orientation_steps: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, specta::Type)] #[serde(rename_all = "camelCase")] pub struct AiSkyMaskParameters { #[serde(default)] @@ -543,7 +543,7 @@ pub struct AiSkyMaskParameters { pub orientation_steps: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, specta::Type)] #[serde(rename_all = "camelCase")] pub struct AiForegroundMaskParameters { #[serde(default)] @@ -556,4 +556,4 @@ pub struct AiForegroundMaskParameters { pub flip_vertical: Option, #[serde(default)] pub orientation_steps: Option, -} \ No newline at end of file +} diff --git a/src-tauri/src/culling.rs b/src-tauri/src/culling.rs index 2556305ab..ed5b03b2b 100644 --- a/src-tauri/src/culling.rs +++ b/src-tauri/src/culling.rs @@ -10,7 +10,7 @@ use tauri::{AppHandle, Emitter}; use crate::image_loader; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct CullingSettings { pub similarity_threshold: u32, @@ -19,7 +19,7 @@ pub struct CullingSettings { pub filter_blurry: bool, } -#[derive(Serialize, Debug, Clone)] +#[derive(Serialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ImageAnalysisResult { pub path: String, @@ -31,14 +31,14 @@ pub struct ImageAnalysisResult { pub height: u32, } -#[derive(Serialize, Debug, Clone)] +#[derive(Serialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct CullGroup { pub representative: ImageAnalysisResult, pub duplicates: Vec, } -#[derive(Serialize, Debug, Clone, Default)] +#[derive(Serialize, Debug, Clone, Default, specta::Type)] #[serde(rename_all = "camelCase")] pub struct CullingSuggestions { pub similar_groups: Vec, @@ -181,6 +181,7 @@ fn analyze_image( } #[tauri::command] +#[specta::specta] pub async fn cull_images( paths: Vec, settings: CullingSettings, diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 3e4461968..ecebb3f78 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -60,7 +60,7 @@ fn emit_thumbnail_cache_setup_error(app_handle: &AppHandle, path: &str, reason: ); } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] pub struct Preset { pub id: String, pub name: String, @@ -73,14 +73,14 @@ struct ExportPresetFile<'a> { presets: &'a [PresetItem], } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] pub struct PresetFolder { pub id: String, pub name: String, pub children: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub enum PresetItem { Preset(Preset), @@ -92,14 +92,14 @@ pub struct PresetFile { pub presets: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct SortCriteria { pub key: String, pub order: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct FilterCriteria { pub rating: u8, @@ -139,20 +139,20 @@ impl fmt::Display for ReadFileError { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct LastFolderState { pub current_folder_path: String, pub expanded_folders: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, specta::Type)] pub struct MyLens { pub maker: String, pub model: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, specta::Type)] #[serde(rename_all = "camelCase")] pub enum PasteMode { Merge, @@ -174,7 +174,7 @@ fn default_included_adjustments() -> HashSet { .collect() } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct CopyPasteSettings { pub mode: PasteMode, @@ -194,7 +194,7 @@ impl Default for CopyPasteSettings { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ExportPreset { pub id: String, @@ -280,7 +280,7 @@ fn default_tagging_shortcuts_option() -> Option> { "event".to_string(), ]) } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct AppSettings { pub last_root_path: Option, @@ -405,7 +405,7 @@ impl Default for AppSettings { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] pub struct ImageFile { path: String, modified: u64, @@ -415,7 +415,7 @@ pub struct ImageFile { is_virtual_copy: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ImportSettings { pub filename_template: String, @@ -458,6 +458,7 @@ pub fn parse_virtual_path(virtual_path: &str) -> (PathBuf, PathBuf) { } #[tauri::command] +#[specta::specta] pub async fn read_exif_for_paths( paths: Vec, ) -> Result>, String> { @@ -481,6 +482,7 @@ pub async fn read_exif_for_paths( } #[tauri::command] +#[specta::specta] pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result, String> { let settings = load_settings(app_handle).unwrap_or_default(); let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); @@ -580,6 +582,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result Result, String> { let settings = load_settings(app_handle).unwrap_or_default(); let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); @@ -686,7 +689,7 @@ pub fn list_images_recursive(path: String, app_handle: AppHandle) -> Result Result { } #[tauri::command] +#[specta::specta] pub async fn get_folder_tree(path: String) -> Result { match tauri::async_runtime::spawn_blocking(move || get_folder_tree_sync(path)).await { Ok(Ok(folder_node)) => Ok(folder_node), @@ -782,6 +786,7 @@ pub async fn get_folder_tree(path: String) -> Result { } #[tauri::command] +#[specta::specta] pub async fn get_pinned_folder_trees(paths: Vec) -> Result, String> { let result = tauri::async_runtime::spawn_blocking(move || { let results: Vec> = paths @@ -1147,6 +1152,7 @@ fn generate_single_thumbnail_and_cache( } #[tauri::command] +#[specta::specta] pub async fn generate_thumbnails( paths: Vec, app_handle: tauri::AppHandle, @@ -1187,6 +1193,7 @@ pub async fn generate_thumbnails( } #[tauri::command] +#[specta::specta] pub fn generate_thumbnails_progressive( paths: Vec, app_handle: tauri::AppHandle, @@ -1267,6 +1274,7 @@ pub fn generate_thumbnails_progressive( } #[tauri::command] +#[specta::specta] pub fn create_folder(path: String) -> Result<(), String> { let path_obj = Path::new(&path); if let (Some(parent), Some(new_folder_name_os)) = (path_obj.parent(), path_obj.file_name()) { @@ -1288,6 +1296,7 @@ pub fn create_folder(path: String) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn rename_folder(path: String, new_name: String) -> Result<(), String> { let p = Path::new(&path); if !p.is_dir() { @@ -1311,6 +1320,7 @@ pub fn rename_folder(path: String, new_name: String) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn delete_folder(path: String) -> Result<(), String> { if let Err(trash_error) = trash::delete(&path) { log::warn!("Failed to move folder to trash: {}. Falling back to permanent delete.", trash_error); @@ -1321,6 +1331,7 @@ pub fn delete_folder(path: String) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn duplicate_file(path: String) -> Result<(), String> { let (source_path, source_sidecar_path) = parse_virtual_path(&path); if !source_path.is_file() { @@ -1396,6 +1407,7 @@ fn find_all_associated_files(source_image_path: &Path) -> Result, S } #[tauri::command] +#[specta::specta] pub fn copy_files(source_paths: Vec, destination_folder: String) -> Result<(), String> { let dest_path = Path::new(&destination_folder); if !dest_path.is_dir() { @@ -1447,6 +1459,7 @@ pub fn copy_files(source_paths: Vec, destination_folder: String) -> Resu } #[tauri::command] +#[specta::specta] pub fn move_files(source_paths: Vec, destination_folder: String) -> Result<(), String> { let dest_path = Path::new(&destination_folder); if !dest_path.is_dir() { @@ -1501,6 +1514,7 @@ pub fn move_files(source_paths: Vec, destination_folder: String) -> Resu } #[tauri::command] +#[specta::specta] pub fn save_metadata_and_update_thumbnail( path: String, adjustments: Value, @@ -1599,6 +1613,7 @@ pub fn save_metadata_and_update_thumbnail( } #[tauri::command] +#[specta::specta] pub fn apply_adjustments_to_paths( paths: Vec, adjustments: Value, @@ -1695,6 +1710,7 @@ pub fn apply_adjustments_to_paths( } #[tauri::command] +#[specta::specta] pub fn reset_adjustments_for_paths( paths: Vec, app_handle: AppHandle, @@ -1780,6 +1796,7 @@ pub fn reset_adjustments_for_paths( } #[tauri::command] +#[specta::specta] pub fn apply_auto_adjustments_to_paths( paths: Vec, app_handle: AppHandle, @@ -1912,6 +1929,7 @@ pub fn apply_auto_adjustments_to_paths( } #[tauri::command] +#[specta::specta] pub fn set_color_label_for_paths(paths: Vec, color: Option, app_handle: AppHandle) -> Result<(), String> { let settings = load_settings(app_handle.clone()).unwrap_or_default(); let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); @@ -1958,6 +1976,7 @@ pub fn set_color_label_for_paths(paths: Vec, color: Option, app_ } #[tauri::command] +#[specta::specta] pub fn load_metadata(path: String, app_handle: AppHandle) -> Result { let settings = load_settings(app_handle).unwrap_or_default(); let enable_xmp_sync = settings.enable_xmp_sync.unwrap_or(false); @@ -1996,6 +2015,7 @@ fn get_presets_path(app_handle: &AppHandle) -> Result Result, String> { let path = get_presets_path(&app_handle)?; if !path.exists() { @@ -2006,6 +2026,7 @@ pub fn load_presets(app_handle: AppHandle) -> Result, String> { } #[tauri::command] +#[specta::specta] pub fn save_presets(presets: Vec, app_handle: AppHandle) -> Result<(), String> { let path = get_presets_path(&app_handle)?; let json_string = serde_json::to_string_pretty(&presets).map_err(|e| e.to_string())?; @@ -2026,6 +2047,7 @@ fn get_settings_path(app_handle: &AppHandle) -> Result Result { let path = get_settings_path(&app_handle)?; @@ -2068,6 +2090,7 @@ pub fn load_settings(app_handle: AppHandle) -> Result { } #[tauri::command] +#[specta::specta] pub fn save_settings(settings: AppSettings, app_handle: AppHandle) -> Result<(), String> { let path = get_settings_path(&app_handle)?; let json_string = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; @@ -2075,6 +2098,7 @@ pub fn save_settings(settings: AppSettings, app_handle: AppHandle) -> Result<(), } #[tauri::command] +#[specta::specta] pub fn handle_import_presets_from_file( file_path: String, app_handle: AppHandle, @@ -2130,6 +2154,7 @@ pub fn handle_import_presets_from_file( } #[tauri::command] +#[specta::specta] pub fn handle_import_legacy_presets_from_file( file_path: String, app_handle: AppHandle, @@ -2183,6 +2208,7 @@ pub fn handle_import_legacy_presets_from_file( } #[tauri::command] +#[specta::specta] pub fn handle_export_presets_to_file( presets_to_export: Vec, file_path: String, @@ -2198,6 +2224,7 @@ pub fn handle_export_presets_to_file( } #[tauri::command] +#[specta::specta] pub fn save_community_preset( name: String, adjustments: Value, @@ -2247,6 +2274,7 @@ pub fn save_community_preset( } #[tauri::command] +#[specta::specta] pub fn clear_all_sidecars(root_path: String) -> Result { if !Path::new(&root_path).exists() { return Err(format!("Root path does not exist: {}", root_path)); @@ -2274,6 +2302,7 @@ pub fn clear_all_sidecars(root_path: String) -> Result { } #[tauri::command] +#[specta::specta] pub fn clear_thumbnail_cache(app_handle: AppHandle) -> Result<(), String> { let cache_dir = app_handle .path() @@ -2293,6 +2322,7 @@ pub fn clear_thumbnail_cache(app_handle: AppHandle) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn show_in_finder(path: String) -> Result<(), String> { let (source_path, _) = parse_virtual_path(&path); let source_path_str = source_path.to_string_lossy().to_string(); @@ -2329,6 +2359,7 @@ pub fn show_in_finder(path: String) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn delete_files_from_disk(paths: Vec) -> Result<(), String> { let mut files_to_trash = HashSet::new(); @@ -2374,6 +2405,7 @@ pub fn delete_files_from_disk(paths: Vec) -> Result<(), String> { } #[tauri::command] +#[specta::specta] pub fn delete_files_with_associated(paths: Vec) -> Result<(), String> { if paths.is_empty() { return Ok(()); @@ -2513,6 +2545,7 @@ pub fn get_cached_or_generate_thumbnail_image( } #[tauri::command] +#[specta::specta] pub async fn import_files( source_paths: Vec, destination_folder: String, @@ -2644,6 +2677,7 @@ pub fn generate_filename_from_template( } #[tauri::command] +#[specta::specta] pub fn rename_files(paths: Vec, name_template: String) -> Result, String> { if paths.is_empty() { return Ok(Vec::new()); @@ -2719,6 +2753,7 @@ pub fn rename_files(paths: Vec, name_template: String) -> Result Result { let (source_path, source_sidecar_path) = parse_virtual_path(&source_virtual_path); @@ -2949,4 +2984,4 @@ pub fn sync_metadata_to_xmp(source_path: &Path, metadata: &ImageMetadata, create let _ = fs::write(&xmp_file, content); } } -} \ No newline at end of file +} diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 6b8968fe9..47f488af7 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -14,7 +14,7 @@ use std::sync::Arc; pub use crate::gpu_processing::{get_or_init_gpu_context, process_and_get_dynamic_image}; use crate::{load_settings, mask_generation::MaskDefinition, AppState}; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] pub struct ImageMetadata { pub version: u32, pub rating: u8, @@ -34,7 +34,7 @@ impl Default for ImageMetadata { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, specta::Type)] pub struct Crop { pub x: f64, pub y: f64, @@ -42,7 +42,7 @@ pub struct Crop { pub height: f64, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, specta::Type)] pub struct GeometryParams { pub distortion: f32, pub vertical: f32, @@ -1887,7 +1887,7 @@ fn apply_gentle_detail_enhance( }); } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, specta::Type)] pub struct HistogramData { red: Vec, green: Vec, @@ -1896,6 +1896,7 @@ pub struct HistogramData { } #[tauri::command] +#[specta::specta] pub fn generate_histogram( state: tauri::State, app_handle: tauri::AppHandle, @@ -2029,7 +2030,7 @@ fn normalize_histogram_range(histogram: &mut Vec, percentile_clip: f32) { } } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, specta::Type)] pub struct WaveformData { red: Vec, green: Vec, @@ -2040,6 +2041,7 @@ pub struct WaveformData { } #[tauri::command] +#[specta::specta] pub fn generate_waveform( state: tauri::State, app_handle: tauri::AppHandle, @@ -2316,6 +2318,7 @@ pub fn auto_results_to_json(results: &AutoAdjustmentResults) -> serde_json::Valu } #[tauri::command] +#[specta::specta] pub fn calculate_auto_adjustments( state: tauri::State, ) -> Result { @@ -2331,4 +2334,4 @@ pub fn calculate_auto_adjustments( let results = perform_auto_analysis(&original_image); Ok(auto_results_to_json(&results)) -} \ No newline at end of file +} diff --git a/src-tauri/src/lens_correction.rs b/src-tauri/src/lens_correction.rs index 731264e9d..2ec26dedc 100644 --- a/src-tauri/src/lens_correction.rs +++ b/src-tauri/src/lens_correction.rs @@ -145,7 +145,7 @@ pub struct MultiName { value: String, } -#[derive(Serialize)] +#[derive(Serialize, specta::Type)] pub struct LensDistortionParams { k1: f64, k2: f64, @@ -509,6 +509,7 @@ pub fn load_lensfun_db(app_handle: &tauri::AppHandle) -> LensDatabase { } #[tauri::command] +#[specta::specta] pub fn get_lensfun_makers(state: State) -> Result, String> { let db_guard = state.lens_db.lock().map_err(|e| format!("Lock poisoned: {}", e))?; if let Some(db) = &*db_guard { @@ -526,6 +527,7 @@ pub fn get_lensfun_makers(state: State) -> Result, String> } #[tauri::command] +#[specta::specta] pub fn get_lensfun_lenses_for_maker(maker: String, state: State) -> Result, String> { let db_guard = state.lens_db.lock().map_err(|e| format!("Lock poisoned: {}", e))?; if let Some(db) = &*db_guard { @@ -544,6 +546,7 @@ pub fn get_lensfun_lenses_for_maker(maker: String, state: State) -> Re } #[tauri::command] +#[specta::specta] pub fn autodetect_lens(maker: String, model: String, state: State) -> Result, String> { let clean_maker = maker.trim().trim_matches('"').to_string(); let clean_model = model.trim().trim_matches('"').to_string(); @@ -628,6 +631,7 @@ pub fn autodetect_lens(maker: String, model: String, state: State) -> } #[tauri::command] +#[specta::specta] pub fn get_lens_distortion_params( maker: String, model: String, @@ -647,4 +651,4 @@ pub fn get_lens_distortion_params( } } Ok(None) -} \ No newline at end of file +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c0ec7f8dc..2d01a514b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,7 +56,9 @@ use rayon::prelude::*; use reqwest; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tauri::{Emitter, Manager, ipc::Response}; +use specta_typescript::{BigIntExportBehavior, Typescript}; +use tauri::{Emitter, Manager}; +use tauri_specta::{Builder as SpectaBuilder, ErrorHandlingMode, Event, collect_commands, collect_events}; use tempfile::NamedTempFile; use tokio::sync::Mutex as TokioMutex; use tokio::task::JoinHandle; @@ -135,6 +137,12 @@ struct PreviewUpdatePayload { data: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +struct AiConnectorStatusUpdate { + connected: bool, +} + pub struct AppState { window_setup_complete: AtomicBool, original_image: Mutex>, @@ -162,7 +170,7 @@ pub struct AppState { pub load_image_generation: Arc, } -#[derive(serde::Serialize)] +#[derive(serde::Serialize, specta::Type)] struct LoadImageResult { width: u32, height: u32, @@ -171,7 +179,7 @@ struct LoadImageResult { is_raw: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] enum ResizeMode { LongEdge, @@ -180,7 +188,7 @@ enum ResizeMode { Height, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] struct ResizeOptions { mode: ResizeMode, @@ -188,7 +196,7 @@ struct ResizeOptions { dont_enlarge: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] struct ExportSettings { jpeg_quality: u8, @@ -201,19 +209,19 @@ struct ExportSettings { export_masks: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] pub struct CommunityPreset { pub name: String, pub creator: String, pub adjustments: Value, } -#[derive(Serialize)] +#[derive(Serialize, specta::Type)] struct LutParseResult { size: u32, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub enum WatermarkAnchor { TopLeft, @@ -227,7 +235,7 @@ pub enum WatermarkAnchor { BottomRight, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct WatermarkSettings { path: String, @@ -237,7 +245,7 @@ pub struct WatermarkSettings { opacity: f32, } -#[derive(serde::Serialize)] +#[derive(serde::Serialize, specta::Type)] struct ImageDimensions { width: u32, height: u32, @@ -512,6 +520,7 @@ fn get_or_load_lut(state: &tauri::State, path: &str) -> Result, @@ -638,6 +647,7 @@ async fn load_image( } #[tauri::command] +#[specta::specta] fn get_image_dimensions(path: String) -> Result { let (source_path, _) = parse_virtual_path(&path); image::image_dimensions(&source_path) @@ -646,6 +656,7 @@ fn get_image_dimensions(path: String) -> Result { } #[tauri::command] +#[specta::specta] fn cancel_thumbnail_generation(state: tauri::State) -> Result<(), String> { state .thumbnail_cancellation_token @@ -954,6 +965,7 @@ fn start_preview_worker(app_handle: tauri::AppHandle) { } #[tauri::command] +#[specta::specta] fn apply_adjustments( js_adjustments: serde_json::Value, is_interactive: bool, @@ -971,6 +983,7 @@ fn apply_adjustments( } #[tauri::command] +#[specta::specta] fn generate_uncropped_preview( js_adjustments: serde_json::Value, state: tauri::State, @@ -1084,11 +1097,12 @@ fn generate_uncropped_preview( } #[tauri::command] +#[specta::specta] fn generate_original_transformed_preview( js_adjustments: serde_json::Value, state: tauri::State, app_handle: tauri::AppHandle, -) -> Result { +) -> Result, String> { let loaded_image = state .original_image .lock() @@ -1125,10 +1139,11 @@ fn generate_original_transformed_preview( .encode_rgb(&rgb_pixels, width as u32, height as u32) .map_err(|e| format!("Failed to encode with mozjpeg-rs: {}", e))?; - Ok(Response::new(bytes)) + Ok(bytes) } #[tauri::command] +#[specta::specta] async fn preview_geometry_transform( params: GeometryParams, js_adjustments: serde_json::Value, @@ -1329,6 +1344,7 @@ fn get_full_image_for_processing( } #[tauri::command] +#[specta::specta] async fn generate_fullscreen_preview( js_adjustments: serde_json::Value, app_handle: tauri::AppHandle, @@ -1690,6 +1706,7 @@ fn export_masks_for_image( } #[tauri::command] +#[specta::specta] async fn export_image( original_path: String, output_path: String, @@ -1769,6 +1786,7 @@ async fn export_image( } #[tauri::command] +#[specta::specta] async fn batch_export_images( output_folder: String, paths: Vec, @@ -1973,11 +1991,13 @@ async fn batch_export_images( } #[tauri::command] -fn cancel_export(state: tauri::State) -> Result<(), String> { +#[specta::specta] +fn cancel_export(state: tauri::State, app_handle: tauri::AppHandle) -> Result<(), String> { match state.export_task_handle.lock().unwrap().take() { Some(handle) => { handle.abort(); println!("Export task cancellation requested."); + let _ = app_handle.emit("export-cancelled", ()); } _ => { return Err("No export task is currently running.".to_string()); @@ -1987,6 +2007,7 @@ fn cancel_export(state: tauri::State) -> Result<(), String> { } #[tauri::command] +#[specta::specta] async fn estimate_export_size( js_adjustments: Value, export_settings: ExportSettings, @@ -2091,6 +2112,7 @@ async fn estimate_export_size( } #[tauri::command] +#[specta::specta] async fn estimate_batch_export_size( paths: Vec, export_settings: ExportSettings, @@ -2252,6 +2274,7 @@ async fn estimate_batch_export_size( } #[tauri::command] +#[specta::specta] fn generate_mask_overlay( mask_def: MaskDefinition, width: u32, @@ -2286,6 +2309,7 @@ fn generate_mask_overlay( } #[tauri::command] +#[specta::specta] async fn generate_ai_foreground_mask( js_adjustments: serde_json::Value, rotation: f32, @@ -2320,6 +2344,7 @@ async fn generate_ai_foreground_mask( } #[tauri::command] +#[specta::specta] async fn generate_ai_sky_mask( js_adjustments: serde_json::Value, rotation: f32, @@ -2353,6 +2378,7 @@ async fn generate_ai_sky_mask( } #[tauri::command] +#[specta::specta] async fn generate_ai_subject_mask( js_adjustments: serde_json::Value, path: String, @@ -2509,10 +2535,11 @@ async fn generate_ai_subject_mask( } #[tauri::command] +#[specta::specta] fn generate_preset_preview( js_adjustments: serde_json::Value, state: tauri::State, -) -> Result { +) -> Result, String> { let context = get_or_init_gpu_context(&state)?; let loaded_image = state @@ -2564,15 +2591,17 @@ fn generate_preset_preview( .write_with_encoder(JpegEncoder::new_with_quality(&mut buf, 50)) .map_err(|e| e.to_string())?; - Ok(Response::new(buf.into_inner())) + Ok(buf.into_inner()) } #[tauri::command] +#[specta::specta] fn update_window_effect(theme: String, window: tauri::Window) { apply_window_effect(theme, window); } #[tauri::command] +#[specta::specta] async fn check_ai_connector_status(app_handle: tauri::AppHandle) { let settings = load_settings(app_handle.clone()).unwrap_or_default(); let is_connected = if let Some(address) = settings.ai_connector_address { @@ -2580,13 +2609,14 @@ async fn check_ai_connector_status(app_handle: tauri::AppHandle) { } else { false }; - let _ = app_handle.emit( - "ai-connector-status-update", - serde_json::json!({ "connected": is_connected }), - ); + let _ = AiConnectorStatusUpdate { + connected: is_connected, + } + .emit(&app_handle); } #[tauri::command] +#[specta::specta] async fn test_ai_connector_connection(address: String) -> Result<(), String> { match ai_connector::check_status(&address).await { Ok(true) => Ok(()), @@ -2606,6 +2636,7 @@ fn calculate_dynamic_patch_radius(width: u32, height: u32) -> u32 { } #[tauri::command] +#[specta::specta] async fn invoke_generative_replace_with_mask_def( path: String, patch_definition: AiPatchDefinition, @@ -2771,6 +2802,7 @@ async fn invoke_generative_replace_with_mask_def( } #[tauri::command] +#[specta::specta] fn get_supported_file_types() -> Result { let raw_extensions: Vec<&str> = crate::formats::RAW_EXTENSIONS .iter() @@ -2785,6 +2817,7 @@ fn get_supported_file_types() -> Result { } #[tauri::command] +#[specta::specta] async fn fetch_community_presets() -> Result, String> { let client = reqwest::Client::new(); let url = "https://raw.githubusercontent.com/CyberTimon/RapidRAW-Presets/main/manifest.json"; @@ -2809,6 +2842,7 @@ async fn fetch_community_presets() -> Result, String> { } #[tauri::command] +#[specta::specta] async fn generate_all_community_previews( image_paths: Vec, presets: Vec, @@ -2941,6 +2975,7 @@ async fn generate_all_community_previews( } #[tauri::command] +#[specta::specta] async fn save_temp_file(bytes: Vec) -> Result { let mut temp_file = NamedTempFile::new().map_err(|e| e.to_string())?; temp_file.write_all(&bytes).map_err(|e| e.to_string())?; @@ -2949,6 +2984,7 @@ async fn save_temp_file(bytes: Vec) -> Result { } #[tauri::command] +#[specta::specta] async fn stitch_panorama( paths: Vec, app_handle: tauri::AppHandle, @@ -3021,6 +3057,7 @@ async fn stitch_panorama( } #[tauri::command] +#[specta::specta] async fn save_panorama( first_path_str: String, state: tauri::State<'_, AppState>, @@ -3062,13 +3099,20 @@ async fn save_panorama( } #[tauri::command] +#[specta::specta] async fn merge_hdr( paths: Vec, app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result<(), String> { + let emit_hdr_error = |message: &str| { + let _ = app_handle.emit("hdr-error", message.to_string()); + }; + if paths.len() < 2 { - return Err("Please select at least two images to merge.".to_string()); + let msg = "Please select at least two images to merge.".to_string(); + emit_hdr_error(&msg); + return Err(msg); } let hdr_result_handle = state.hdr_result.clone(); @@ -3114,20 +3158,26 @@ async fn merge_hdr( Ok((path.clone(), dynamic_image, exposure, gains)) }) - .collect::, String>>()?; + .collect::, String>>() + .map_err(|e| { + emit_hdr_error(&e); + e + })?; if let Some((first_path, first_img, _, _)) = loaded_items.first() { let (width, height) = (first_img.width(), first_img.height()); for (path, img, _, _) in loaded_items.iter().skip(1) { if img.width() != width || img.height() != height { - return Err(format!( + let msg = format!( "Dimension mismatch detected.\n\nBase image ({}): {}x{}\nTarget image ({}): {}x{}\n\nHDR merge requires all images to be exactly the same size.", Path::new(first_path).file_name().unwrap_or_default().to_string_lossy(), width, height, Path::new(path).file_name().unwrap_or_default().to_string_lossy(), img.width(), img.height() - )); + ); + emit_hdr_error(&msg); + return Err(msg); } } } @@ -3138,15 +3188,25 @@ async fn merge_hdr( HDRInput::with_image(img, *exposure, *gains) .map_err(|e| format!("Failed to prepare HDR input for {}: {}", path, e)) }) - .collect::, String>>()?; + .collect::, String>>() + .map_err(|e| { + emit_hdr_error(&e); + e + })?; log::info!("Starting HDR merge of {} images", images.len()); - let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| e.to_string())?; + let hdr_merged = hdr_merge_images(&mut images.into()).map_err(|e| { + let msg = e.to_string(); + emit_hdr_error(&msg); + msg + })?; log::info!("HDR merge completed"); let mut buf = Cursor::new(Vec::new()); if let Err(e) = hdr_merged.to_rgb8().write_to(&mut buf, ImageFormat::Png) { - return Err(format!("Failed to encode hdr preview: {}", e)); + let msg = format!("Failed to encode hdr preview: {}", e); + emit_hdr_error(&msg); + return Err(msg); } let base64_str = general_purpose::STANDARD.encode(buf.get_ref()); @@ -3166,6 +3226,7 @@ async fn merge_hdr( } #[tauri::command] +#[specta::specta] async fn save_hdr( first_path_str: String, state: tauri::State<'_, AppState>, @@ -3208,6 +3269,7 @@ async fn save_hdr( } #[tauri::command] +#[specta::specta] async fn apply_denoising( path: String, intensity: f32, @@ -3234,6 +3296,7 @@ async fn apply_denoising( } #[tauri::command] +#[specta::specta] async fn save_denoised_image( original_path_str: String, state: tauri::State<'_, AppState>, @@ -3277,6 +3340,7 @@ async fn save_denoised_image( } #[tauri::command] +#[specta::specta] async fn save_collage(base64_data: String, first_path_str: String) -> Result { let data_url_prefix = "data:image/png;base64,"; if !base64_data.starts_with(data_url_prefix) { @@ -3307,12 +3371,13 @@ async fn save_collage(base64_data: String, first_path_str: String) -> Result, app_handle: tauri::AppHandle, -) -> Result { +) -> Result, String> { let context = get_or_init_gpu_context(&state)?; let (source_path, _) = parse_virtual_path(&path); let source_path_str = source_path.to_string_lossy().to_string(); @@ -3385,10 +3450,11 @@ fn generate_preview_for_path( .encode_rgb(&rgb_pixels, width as u32, height as u32) .map_err(|e| format!("Failed to encode with mozjpeg-rs: {}", e))?; - Ok(Response::new(bytes)) + Ok(bytes) } #[tauri::command] +#[specta::specta] async fn load_and_parse_lut( path: String, state: tauri::State<'_, AppState>, @@ -3518,6 +3584,7 @@ fn setup_logging(app_handle: &tauri::AppHandle) { } #[tauri::command] +#[specta::specta] fn get_log_file_path(app_handle: tauri::AppHandle) -> Result { let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; let log_file_path = log_dir.join("app.log"); @@ -3525,6 +3592,7 @@ fn get_log_file_path(app_handle: tauri::AppHandle) -> Result { } #[tauri::command] +#[specta::specta] fn frontend_log(level: String, message: String) -> Result<(), String> { let trimmed = message.trim(); if trimmed.is_empty() { @@ -3555,6 +3623,7 @@ fn handle_file_open(app_handle: &tauri::AppHandle, path: PathBuf) { } #[tauri::command] +#[specta::specta] fn frontend_ready( app_handle: tauri::AppHandle, window: tauri::Window, @@ -3620,6 +3689,123 @@ fn frontend_ready( } fn main() { + let specta_builder = SpectaBuilder::::new() + .commands(collect_commands![ + load_image, + apply_adjustments, + export_image, + batch_export_images, + cancel_export, + estimate_export_size, + estimate_batch_export_size, + generate_fullscreen_preview, + generate_preview_for_path, + generate_original_transformed_preview, + generate_preset_preview, + generate_uncropped_preview, + preview_geometry_transform, + generate_mask_overlay, + generate_ai_subject_mask, + generate_ai_foreground_mask, + generate_ai_sky_mask, + update_window_effect, + check_ai_connector_status, + test_ai_connector_connection, + invoke_generative_replace_with_mask_def, + get_supported_file_types, + get_log_file_path, + frontend_log, + save_collage, + stitch_panorama, + save_panorama, + merge_hdr, + save_hdr, + apply_denoising, + save_denoised_image, + load_and_parse_lut, + fetch_community_presets, + generate_all_community_previews, + save_temp_file, + get_image_dimensions, + frontend_ready, + cancel_thumbnail_generation, + image_processing::generate_histogram, + image_processing::generate_waveform, + image_processing::calculate_auto_adjustments, + file_management::read_exif_for_paths, + file_management::list_images_in_dir, + file_management::list_images_recursive, + file_management::get_folder_tree, + file_management::get_pinned_folder_trees, + file_management::generate_thumbnails, + file_management::generate_thumbnails_progressive, + file_management::create_folder, + file_management::delete_folder, + file_management::copy_files, + file_management::move_files, + file_management::rename_folder, + file_management::rename_files, + file_management::duplicate_file, + file_management::show_in_finder, + file_management::delete_files_from_disk, + file_management::delete_files_with_associated, + file_management::save_metadata_and_update_thumbnail, + file_management::apply_adjustments_to_paths, + file_management::load_metadata, + file_management::load_presets, + file_management::save_presets, + file_management::load_settings, + file_management::save_settings, + file_management::reset_adjustments_for_paths, + file_management::apply_auto_adjustments_to_paths, + file_management::handle_import_presets_from_file, + file_management::handle_import_legacy_presets_from_file, + file_management::handle_export_presets_to_file, + file_management::save_community_preset, + file_management::clear_all_sidecars, + file_management::clear_thumbnail_cache, + file_management::set_color_label_for_paths, + file_management::import_files, + file_management::create_virtual_copy, + tagging::start_background_indexing, + tagging::clear_ai_tags, + tagging::clear_all_tags, + tagging::add_tag_for_paths, + tagging::remove_tag_for_paths, + culling::cull_images, + lens_correction::get_lensfun_makers, + lens_correction::get_lensfun_lenses_for_maker, + lens_correction::autodetect_lens, + lens_correction::get_lens_distortion_params, + negative_conversion::preview_negative_conversion, + negative_conversion::convert_negative_full, + negative_conversion::save_converted_negative, + ]) + .events(collect_events![AiConnectorStatusUpdate]) + .error_handling(ErrorHandlingMode::Throw); + + #[cfg(debug_assertions)] + { + specta_builder + .export( + Typescript::default().bigint(BigIntExportBehavior::Number), + "../src/bindings.ts", + ) + .expect("Failed to export TypeScript bindings"); + } + + if std::env::var("RAPIDRAW_EXPORT_BINDINGS_ONLY").as_deref() == Ok("1") { + specta_builder + .export( + Typescript::default().bigint(BigIntExportBehavior::Number), + "../src/bindings.ts", + ) + .expect("Failed to export TypeScript bindings"); + return; + } + + let specta_invoke_handler = specta_builder.invoke_handler(); + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { log::info!("New instance launched with args: {:?}. Focusing main window.", argv); @@ -3644,7 +3830,10 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_shell::init()) - .setup(|app| { + .invoke_handler(specta_invoke_handler) + .setup(move |app| { + specta_builder.mount_events(app); + #[cfg(any(windows, target_os = "linux"))] { if let Some(arg) = std::env::args().nth(1) { @@ -3863,97 +4052,6 @@ fn main() { lens_db: Mutex::new(None), load_image_generation: Arc::new(AtomicUsize::new(0)), }) - .invoke_handler(tauri::generate_handler![ - load_image, - apply_adjustments, - export_image, - batch_export_images, - cancel_export, - estimate_export_size, - estimate_batch_export_size, - generate_fullscreen_preview, - generate_preview_for_path, - generate_original_transformed_preview, - generate_preset_preview, - generate_uncropped_preview, - preview_geometry_transform, - generate_mask_overlay, - generate_ai_subject_mask, - generate_ai_foreground_mask, - generate_ai_sky_mask, - update_window_effect, - check_ai_connector_status, - test_ai_connector_connection, - invoke_generative_replace_with_mask_def, - get_supported_file_types, - get_log_file_path, - frontend_log, - save_collage, - stitch_panorama, - save_panorama, - merge_hdr, - save_hdr, - apply_denoising, - save_denoised_image, - load_and_parse_lut, - fetch_community_presets, - generate_all_community_previews, - save_temp_file, - get_image_dimensions, - frontend_ready, - cancel_thumbnail_generation, - image_processing::generate_histogram, - image_processing::generate_waveform, - image_processing::calculate_auto_adjustments, - file_management::read_exif_for_paths, - file_management::list_images_in_dir, - file_management::list_images_recursive, - file_management::get_folder_tree, - file_management::get_pinned_folder_trees, - file_management::generate_thumbnails, - file_management::generate_thumbnails_progressive, - file_management::create_folder, - file_management::delete_folder, - file_management::copy_files, - file_management::move_files, - file_management::rename_folder, - file_management::rename_files, - file_management::duplicate_file, - file_management::show_in_finder, - file_management::delete_files_from_disk, - file_management::delete_files_with_associated, - file_management::save_metadata_and_update_thumbnail, - file_management::apply_adjustments_to_paths, - file_management::load_metadata, - file_management::load_presets, - file_management::save_presets, - file_management::load_settings, - file_management::save_settings, - file_management::reset_adjustments_for_paths, - file_management::apply_auto_adjustments_to_paths, - file_management::handle_import_presets_from_file, - file_management::handle_import_legacy_presets_from_file, - file_management::handle_export_presets_to_file, - file_management::save_community_preset, - file_management::clear_all_sidecars, - file_management::clear_thumbnail_cache, - file_management::set_color_label_for_paths, - file_management::import_files, - file_management::create_virtual_copy, - tagging::start_background_indexing, - tagging::clear_ai_tags, - tagging::clear_all_tags, - tagging::add_tag_for_paths, - tagging::remove_tag_for_paths, - culling::cull_images, - lens_correction::get_lensfun_makers, - lens_correction::get_lensfun_lenses_for_maker, - lens_correction::autodetect_lens, - lens_correction::get_lens_distortion_params, - negative_conversion::preview_negative_conversion, - negative_conversion::convert_negative_full, - negative_conversion::save_converted_negative, - ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(#[allow(unused_variables)] |app_handle, event| { @@ -3976,4 +4074,4 @@ fn main() { _ => {} } }); -} \ No newline at end of file +} diff --git a/src-tauri/src/mask_generation.rs b/src-tauri/src/mask_generation.rs index c20d734fa..b2e140176 100644 --- a/src-tauri/src/mask_generation.rs +++ b/src-tauri/src/mask_generation.rs @@ -9,14 +9,14 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::f32::consts::PI; -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, specta::Type)] #[serde(rename_all = "camelCase")] pub enum SubMaskMode { Additive, Subtractive, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct SubMask { pub id: String, @@ -35,7 +35,7 @@ fn default_opacity() -> f32 { 100.0 } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct MaskDefinition { pub id: String, @@ -48,14 +48,14 @@ pub struct MaskDefinition { pub sub_masks: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct PatchData { pub color: String, pub mask: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, specta::Type)] #[serde(rename_all = "camelCase")] pub struct AiPatchDefinition { pub id: String, @@ -749,4 +749,4 @@ pub fn generate_mask_bitmap( } Some(final_mask) -} \ No newline at end of file +} diff --git a/src-tauri/src/negative_conversion.rs b/src-tauri/src/negative_conversion.rs index 2189d3ea8..bcd38a3d8 100644 --- a/src-tauri/src/negative_conversion.rs +++ b/src-tauri/src/negative_conversion.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use crate::image_processing::downscale_f32_image; use crate::AppState; -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, specta::Type)] pub struct NegativeConversionParams { pub red_weight: f32, pub green_weight: f32, @@ -168,6 +168,7 @@ fn run_pipeline( } #[tauri::command] +#[specta::specta] pub async fn preview_negative_conversion( path: String, params: NegativeConversionParams, @@ -246,6 +247,7 @@ pub async fn preview_negative_conversion( } #[tauri::command] +#[specta::specta] pub async fn convert_negative_full( path: String, params: NegativeConversionParams, @@ -311,6 +313,7 @@ pub async fn convert_negative_full( } #[tauri::command] +#[specta::specta] pub async fn save_converted_negative( original_path_str: String, state: tauri::State<'_, AppState>, @@ -332,4 +335,4 @@ pub async fn save_converted_negative( processed_image.to_rgb16().save(&out_path).map_err(|e| e.to_string())?; Ok(out_path.to_string_lossy().to_string()) -} \ No newline at end of file +} diff --git a/src-tauri/src/tagging.rs b/src-tauri/src/tagging.rs index d691643a4..852a95cee 100644 --- a/src-tauri/src/tagging.rs +++ b/src-tauri/src/tagging.rs @@ -251,6 +251,7 @@ pub fn generate_tags_with_clip( } #[tauri::command] +#[specta::specta] pub async fn start_background_indexing( folder_path: String, app_handle: AppHandle, @@ -459,6 +460,7 @@ fn modify_tags_for_path(path_str: &str, modify_fn: impl Fn(&mut Vec)) -> } #[tauri::command] +#[specta::specta] pub fn add_tag_for_paths(paths: Vec, tag: String) -> Result<(), String> { paths.par_iter().for_each(|path| { let tag_clone = tag.clone(); @@ -474,6 +476,7 @@ pub fn add_tag_for_paths(paths: Vec, tag: String) -> Result<(), String> } #[tauri::command] +#[specta::specta] pub fn remove_tag_for_paths(paths: Vec, tag: String) -> Result<(), String> { paths.par_iter().for_each(|path| { let tag_clone = tag.clone(); @@ -487,6 +490,7 @@ pub fn remove_tag_for_paths(paths: Vec, tag: String) -> Result<(), Strin } #[tauri::command] +#[specta::specta] pub fn clear_ai_tags(root_path: String) -> Result { if !Path::new(&root_path).exists() { return Err(format!("Root path does not exist: {}", root_path)); @@ -526,6 +530,7 @@ pub fn clear_ai_tags(root_path: String) -> Result { } #[tauri::command] +#[specta::specta] pub fn clear_all_tags(root_path: String) -> Result { if !Path::new(&root_path).exists() { return Err(format!("Root path does not exist: {}", root_path)); diff --git a/src/App.tsx b/src/App.tsx index 44692d37f..2e6a90d8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { homeDir } from '@tauri-apps/api/path'; @@ -95,7 +94,6 @@ import { AppSettings, BrushSettings, FilterCriteria, - Invokes, ImageFile, Option, OPTION_SEPARATOR, @@ -118,9 +116,66 @@ import { } from './components/ui/AppProperties'; import { ChannelConfig } from './components/adjustments/Curves'; import HdrModal from './components/modals/HdrModal'; +import { commands, events } from './bindings'; const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key +type InvokeArgs = Record; + +function invoke(command: string, args: InvokeArgs = {}): Promise { + switch (command) { + case 'apply_adjustments': return commands.applyAdjustments(args.jsAdjustments, args.isInteractive) as Promise; + case 'apply_adjustments_to_paths': return commands.applyAdjustmentsToPaths(args.paths, args.adjustments) as Promise; + case 'apply_auto_adjustments_to_paths': return commands.applyAutoAdjustmentsToPaths(args.paths) as Promise; + case 'apply_denoising': return commands.applyDenoising(args.path, args.intensity) as Promise; + case 'calculate_auto_adjustments': return commands.calculateAutoAdjustments() as Promise; + case 'cancel_thumbnail_generation': return commands.cancelThumbnailGeneration() as Promise; + case 'copy_files': return commands.copyFiles(args.sourcePaths, args.destinationFolder) as Promise; + case 'create_folder': return commands.createFolder(args.path) as Promise; + case 'create_virtual_copy': return commands.createVirtualCopy(args.sourceVirtualPath) as Promise; + case 'delete_files_from_disk': return commands.deleteFilesFromDisk(args.paths) as Promise; + case 'delete_files_with_associated': return commands.deleteFilesWithAssociated(args.paths) as Promise; + case 'delete_folder': return commands.deleteFolder(args.path) as Promise; + case 'duplicate_file': return commands.duplicateFile(args.path) as Promise; + case 'frontend_ready': return commands.frontendReady() as Promise; + case 'generate_ai_foreground_mask': return commands.generateAiForegroundMask(args.jsAdjustments, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; + case 'generate_ai_sky_mask': return commands.generateAiSkyMask(args.jsAdjustments, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; + case 'generate_ai_subject_mask': return commands.generateAiSubjectMask(args.jsAdjustments, args.path, args.startPoint, args.endPoint, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; + case 'generate_fullscreen_preview': return commands.generateFullscreenPreview(args.jsAdjustments) as Promise; + case 'generate_original_transformed_preview': return commands.generateOriginalTransformedPreview(args.jsAdjustments) as Promise; + case 'generate_uncropped_preview': return commands.generateUncroppedPreview(args.jsAdjustments) as Promise; + case 'generate_waveform': return commands.generateWaveform() as Promise; + case 'get_folder_tree': return commands.getFolderTree(args.path) as Promise; + case 'get_pinned_folder_trees': return commands.getPinnedFolderTrees(args.paths) as Promise; + case 'get_supported_file_types': return commands.getSupportedFileTypes() as Promise; + case 'import_files': return commands.importFiles(args.sourcePaths, args.destinationFolder, args.settings) as Promise; + case 'invoke_generative_replace_with_mask_def': return commands.invokeGenerativeReplaceWithMaskDef(args.path, args.patchDefinition, args.currentAdjustments, args.useFastInpaint, args.token) as Promise; + case 'list_images_in_dir': return commands.listImagesInDir(args.path) as Promise; + case 'list_images_recursive': return commands.listImagesRecursive(args.path) as Promise; + case 'load_and_parse_lut': return commands.loadAndParseLut(args.path) as Promise; + case 'load_image': return commands.loadImage(args.path) as Promise; + case 'load_metadata': return commands.loadMetadata(args.path) as Promise; + case 'load_settings': return commands.loadSettings() as Promise; + case 'merge_hdr': return commands.mergeHdr(args.paths) as Promise; + case 'move_files': return commands.moveFiles(args.sourcePaths, args.destinationFolder) as Promise; + case 'read_exif_for_paths': return commands.readExifForPaths(args.paths) as Promise; + case 'rename_files': return commands.renameFiles(args.paths, args.nameTemplate) as Promise; + case 'rename_folder': return commands.renameFolder(args.path, args.newName) as Promise; + case 'reset_adjustments_for_paths': return commands.resetAdjustmentsForPaths(args.paths) as Promise; + case 'save_collage': return commands.saveCollage(args.base64Data, args.firstPathStr) as Promise; + case 'save_hdr': return commands.saveHdr(args.firstPathStr) as Promise; + case 'save_metadata_and_update_thumbnail': return commands.saveMetadataAndUpdateThumbnail(args.path, args.adjustments) as Promise; + case 'save_panorama': return commands.savePanorama(args.firstPathStr) as Promise; + case 'save_settings': return commands.saveSettings(args.settings) as Promise; + case 'set_color_label_for_paths': return commands.setColorLabelForPaths(args.paths, args.color) as Promise; + case 'show_in_finder': return commands.showInFinder(args.path) as Promise; + case 'start_background_indexing': return commands.startBackgroundIndexing(args.folderPath) as Promise; + case 'stitch_panorama': return commands.stitchPanorama(args.paths) as Promise; + case 'update_window_effect': return commands.updateWindowEffect(args.theme) as Promise; + default: return Promise.reject(new Error(`Unsupported invoke command: ${command}`)); + } +} + interface CollapsibleSectionsState { basic: boolean; color: boolean; @@ -682,14 +737,14 @@ function App() { }, [libraryViewMode]); useEffect(() => { - const unlisten = listen('ai-connector-status-update', (event: any) => { + const unlisten = events.aiConnectorStatusUpdate.listen((event) => { setisAIConnectorConnected(event.payload.connected); }); - invoke(Invokes.CheckAIConnectorStatus); - const interval = setInterval(() => invoke(Invokes.CheckAIConnectorStatus), 10000); + void commands.checkAiConnectorStatus(); + const interval = setInterval(() => void commands.checkAiConnectorStatus(), 10000); return () => { clearInterval(interval); - unlisten.then((f) => f()); + unlisten.then((f) => void f()); }; }, []); @@ -729,7 +784,7 @@ function App() { setIsGeneratingAi(true); try { - const newPatchDataJson: any = await invoke(Invokes.InvokeGenerativeReplaseWithMaskDef, { + const newPatchDataJson: any = await invoke('invoke_generative_replace_with_mask_def', { currentAdjustments: adjustments, patchDefinition: patchDefinition, path: selectedImage.path, @@ -815,7 +870,7 @@ function App() { lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newMaskParams: any = await invoke(Invokes.GenerateAiSubjectMask, { + const newMaskParams: any = await invoke('generate_ai_subject_mask', { jsAdjustments: transformAdjustments, endPoint: [endPoint.x, endPoint.y], flipHorizontal: adjustments.flipHorizontal, @@ -845,7 +900,7 @@ function App() { }; const patchDefinitionForBackend = updatedAdjustmentsForBackend.aiPatches.find((p: AiPatch) => p.id === patchId); - const newPatchDataJson: any = await invoke(Invokes.InvokeGenerativeReplaseWithMaskDef, { + const newPatchDataJson: any = await invoke('invoke_generative_replace_with_mask_def', { currentAdjustments: updatedAdjustmentsForBackend, patchDefinition: { ...patchDefinitionForBackend, prompt: '' }, path: selectedImage.path, @@ -960,7 +1015,7 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke(Invokes.GenerateAiSubjectMask, { + const newParameters = await invoke('generate_ai_subject_mask', { jsAdjustments: transformAdjustments, endPoint: [endPoint.x, endPoint.y], flipHorizontal: adjustments.flipHorizontal, @@ -1012,7 +1067,7 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke(Invokes.GenerateAiForegroundMask, { + const newParameters = await invoke('generate_ai_foreground_mask', { jsAdjustments: transformAdjustments, flipHorizontal: adjustments.flipHorizontal, flipVertical: adjustments.flipVertical, @@ -1061,7 +1116,7 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke(Invokes.GenerateAiSkyMask, { + const newParameters = await invoke('generate_ai_sky_mask', { jsAdjustments: transformAdjustments, flipHorizontal: adjustments.flipHorizontal, flipVertical: adjustments.flipVertical, @@ -1342,7 +1397,7 @@ function App() { } try { - await invoke(Invokes.ApplyAdjustments, { + await invoke('apply_adjustments', { jsAdjustments: payload, isInteractive: dragging }); @@ -1365,7 +1420,7 @@ function App() { if (!selectedImage?.isReady) { return; } - invoke(Invokes.GenerateUncroppedPreview, { jsAdjustments: currentAdjustments }).catch((err) => + invoke('generate_uncropped_preview', { jsAdjustments: currentAdjustments }).catch((err) => console.error('Failed to generate uncropped preview:', err), ); }, 50), @@ -1374,7 +1429,7 @@ function App() { const debouncedSave = useCallback( debounce((path, adjustmentsToSave) => { - invoke(Invokes.SaveMetadataAndUpdateThumbnail, { path, adjustments: adjustmentsToSave }).catch((err) => { + invoke('save_metadata_and_update_thumbnail', { path, adjustments: adjustmentsToSave }).catch((err) => { console.error('Auto-save failed:', err); setError(`Failed to save changes: ${err}`); }); @@ -1474,7 +1529,7 @@ function App() { const { searchCriteria: _searchCriteria, ...settingsToSave } = newSettings as any; setAppSettings(newSettings); - invoke(Invokes.SaveSettings, { settings: settingsToSave }).catch((err) => { + invoke('save_settings', { settings: settingsToSave }).catch((err) => { console.error('Failed to save settings:', err); }); }, @@ -1482,7 +1537,7 @@ function App() { ); useEffect(() => { - invoke(Invokes.LoadSettings) + invoke('load_settings') .then(async (settings: any) => { if ( !settings.copyPasteSettings || @@ -1524,7 +1579,7 @@ function App() { } if (settings?.pinnedFolders && settings.pinnedFolders.length > 0) { try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: settings.pinnedFolders }); + const trees = await invoke('get_pinned_folder_trees', { paths: settings.pinnedFolders }); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to load pinned folder trees:', err); @@ -1537,13 +1592,13 @@ function App() { const command = settings.libraryViewMode === LibraryViewMode.Recursive - ? Invokes.ListImagesRecursive - : Invokes.ListImagesInDir; + ? 'list_images_recursive' + : 'list_images_in_dir'; preloadedDataRef.current = { rootPath: root, currentPath: currentPath, - tree: invoke(Invokes.GetFolderTree, { path: root }), + tree: invoke('get_folder_tree', { path: root }), images: invoke(command, { path: currentPath }) }; } @@ -1600,7 +1655,7 @@ function App() { }, [libraryViewMode, appSettings, handleSettingsChange]); useEffect(() => { - invoke(Invokes.GetSupportedFileTypes) + invoke('get_supported_file_types') .then((types: any) => setSupportedTypes(types)) .catch((err) => console.error('Failed to load supported file types:', err)); }, []); @@ -1659,7 +1714,7 @@ function App() { }); const isLight = [Theme.Light, Theme.Snow, Theme.Arctic].includes(effectThemeForWindow); - invoke(Invokes.UpdateWindowEffect, { theme: isLight ? Theme.Light : Theme.Dark }); + invoke('update_window_effect', { theme: isLight ? Theme.Light : Theme.Dark }); }, [theme, adaptivePalette]); useEffect(() => { @@ -1677,7 +1732,7 @@ function App() { const refreshAllFolderTrees = useCallback(async () => { if (rootPath) { try { - const treeData = await invoke(Invokes.GetFolderTree, { path: rootPath }); + const treeData = await invoke('get_folder_tree', { path: rootPath }); setFolderTree(treeData); } catch (err) { console.error('Failed to refresh main folder tree:', err); @@ -1688,7 +1743,7 @@ function App() { const currentPins = appSettings?.pinnedFolders || []; if (currentPins.length > 0) { try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: currentPins }); + const trees = await invoke('get_pinned_folder_trees', { paths: currentPins }); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to refresh pinned folder trees:', err); @@ -1713,7 +1768,7 @@ function App() { handleSettingsChange({ ...appSettings, pinnedFolders: newPins }); try { - const trees = await invoke(Invokes.GetPinnedFolderTrees, { paths: newPins }); + const trees = await invoke('get_pinned_folder_trees', { paths: newPins }); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to refresh pinned folders:', err); @@ -1774,7 +1829,7 @@ function App() { setIsTreeLoading(true); handleSettingsChange({ ...appSettings, lastRootPath: path } as AppSettings); try { - const treeData = await invoke(Invokes.GetFolderTree, { path }); + const treeData = await invoke('get_folder_tree', { path }); setFolderTree(treeData); } catch (err) { console.error('Failed to load folder tree:', err); @@ -1796,7 +1851,7 @@ function App() { } const command = - libraryViewMode === LibraryViewMode.Recursive ? Invokes.ListImagesRecursive : Invokes.ListImagesInDir; + libraryViewMode === LibraryViewMode.Recursive ? 'list_images_recursive' : 'list_images_in_dir'; let files: ImageFile[]; if (preloadedImages) { @@ -1813,7 +1868,7 @@ function App() { const paths = files.map((f: ImageFile) => f.path); if (isExifSortActive) { - const exifDataMap: Record = await invoke(Invokes.ReadExifForPaths, { paths }); + const exifDataMap: Record = await invoke('read_exif_for_paths', { paths }); const finalImageList = files.map((image) => ({ ...image, exif: exifDataMap[image.path] || image.exif || null, @@ -1821,7 +1876,7 @@ function App() { setImageList(finalImageList); } else { setImageList(files); - invoke(Invokes.ReadExifForPaths, { paths }) + invoke('read_exif_for_paths', { paths }) .then((exifDataMap: any) => { setImageList((currentImageList) => currentImageList.map((image) => ({ @@ -1838,7 +1893,7 @@ function App() { setImageList(files); } - invoke(Invokes.StartBackgroundIndexing, { folderPath: path }).catch((err) => { + invoke('start_background_indexing', { folderPath: path }).catch((err) => { console.error('Failed to start background indexing:', err); }); } catch (err) { @@ -1868,7 +1923,7 @@ function App() { if (!currentFolderPath) return; try { const command = - libraryViewMode === LibraryViewMode.Recursive ? Invokes.ListImagesRecursive : Invokes.ListImagesInDir; + libraryViewMode === LibraryViewMode.Recursive ? 'list_images_recursive' : 'list_images_in_dir'; const files: ImageFile[] = await invoke(command, { path: currentFolderPath }); const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; @@ -1879,7 +1934,7 @@ function App() { if (shouldReadExif && files.length > 0 && isExifSortActive) { const paths = files.map((f: ImageFile) => f.path); - freshExifData = await invoke(Invokes.ReadExifForPaths, { paths }); + freshExifData = await invoke('read_exif_for_paths', { paths }); } setImageList((prevList) => { @@ -1901,7 +1956,7 @@ function App() { if (shouldReadExif && files.length > 0 && !isExifSortActive) { const paths = files.map((f: ImageFile) => f.path); - invoke(Invokes.ReadExifForPaths, { paths }) + invoke('read_exif_for_paths', { paths }) .then((exifDataMap: any) => { setImageList((currentImageList) => currentImageList.map((image) => { @@ -2219,7 +2274,7 @@ function App() { setAdjustments(newAdjustments); } - invoke(Invokes.ApplyAdjustmentsToPaths, { paths: pathsToUpdate, adjustments: adjustmentsToApply }).catch( + invoke('apply_adjustments_to_paths', { paths: pathsToUpdate, adjustments: adjustmentsToApply }).catch( (err) => { console.error('Failed to paste adjustments to multiple images:', err); setError(`Failed to paste adjustments: ${err}`); @@ -2235,7 +2290,7 @@ function App() { return; } try { - const autoAdjustments: Adjustments = await invoke(Invokes.CalculateAutoAdjustments); + const autoAdjustments: Adjustments = await invoke('calculate_auto_adjustments'); setAdjustments((prev: Adjustments) => { const newAdjustments = { ...prev, ...autoAdjustments }; newAdjustments.sectionVisibility = { @@ -2284,7 +2339,7 @@ function App() { setLibraryActiveAdjustments((prev) => ({ ...prev, rating: finalRating })); } - invoke(Invokes.ApplyAdjustmentsToPaths, { paths: pathsToRate, adjustments: { rating: finalRating } }).catch( + invoke('apply_adjustments_to_paths', { paths: pathsToRate, adjustments: { rating: finalRating } }).catch( (err) => { console.error('Failed to apply rating to paths:', err); setError(`Failed to apply rating: ${err}`); @@ -2319,7 +2374,7 @@ function App() { } const finalColor = color !== null && color === currentColor ? null : color; try { - await invoke(Invokes.SetColorLabelForPaths, { paths: pathsToUpdate, color: finalColor }); + await invoke('set_color_label_for_paths', { paths: pathsToUpdate, color: finalColor }); setImageList((prevList: Array) => prevList.map((image: ImageFile) => { @@ -2386,9 +2441,9 @@ function App() { } try { if (mode === 'copy') - await invoke(Invokes.CopyFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); else { - await invoke(Invokes.MoveFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); setCopiedFilePaths([]); } await refreshImageList(); @@ -2410,7 +2465,7 @@ function App() { const request = { cancelled: false }; fullResRequestRef.current = request; - invoke(Invokes.GenerateFullscreenPreview, { + invoke('generate_fullscreen_preview', { jsAdjustments: currentAdjustments, }) .then(() => { @@ -2681,6 +2736,13 @@ function App() { setIndexingProgress(event.payload); } }), + listen('indexing-error', (event: any) => { + if (isEffectActive) { + setIsIndexing(false); + setIndexingProgress({ current: 0, total: 0 }); + setError(typeof event.payload === 'string' ? event.payload : 'Indexing error'); + } + }), listen('indexing-finished', () => { if (isEffectActive) { setIsIndexing(false); @@ -2688,7 +2750,7 @@ function App() { if (currentFolderPathRef.current) { const refreshImageList = async () => { try { - const list: ImageFile[] = await invoke(Invokes.ListImagesInDir, { path: currentFolderPathRef.current }); + const list: ImageFile[] = await invoke('list_images_in_dir', { path: currentFolderPathRef.current }); if (Array.isArray(list)) { setImageList(list); } @@ -2710,6 +2772,17 @@ function App() { setExportState((prev: ExportState) => ({ ...prev, status: Status.Success })); } }), + listen('export-complete-with-errors', (event: any) => { + if (isEffectActive) { + const errors = event.payload?.errors ?? 0; + const total = event.payload?.total ?? 0; + setExportState((prev: ExportState) => ({ + ...prev, + status: Status.Error, + errorMessage: `Export completed with errors (${errors}/${total}).`, + })); + } + }), listen('export-error', (event) => { if (isEffectActive) { setExportState((prev: ExportState) => ({ @@ -2761,6 +2834,14 @@ function App() { })); } }), + listen('thumbnail-generation-error', (event: any) => { + if (isEffectActive) { + const payload = event.payload; + if (payload?.reason) { + setError(`Thumbnail generation error: ${payload.reason}`); + } + } + }), listen('denoise-progress', (event: any) => { if (isEffectActive) { setDenoiseModalState((prev) => ({ ...prev, progressMessage: event.payload as string })); @@ -2820,7 +2901,7 @@ function App() { useEffect(() => { if (libraryActivePath) { - invoke(Invokes.LoadMetadata, { path: libraryActivePath }) + invoke('load_metadata', { path: libraryActivePath }) .then((metadata: any) => { if (metadata.adjustments && !metadata.adjustments.is_null) { const normalized: Adjustments = normalizeLoadedAdjustments(metadata.adjustments); @@ -2876,11 +2957,22 @@ function App() { } }); + const unlistenWarning = listen('panorama-warning', (event: any) => { + if (isEffectActive) { + const warning = String(event.payload); + setPanoramaModalState((prev: PanoramaModalState) => ({ + ...prev, + progressMessage: warning, + })); + } + }); + return () => { isEffectActive = false; unlistenProgress.then((f: any) => f()); unlistenComplete.then((f: any) => f()); unlistenError.then((f: any) => f()); + unlistenWarning.then((f: any) => f()); }; }, []); @@ -2956,18 +3048,11 @@ function App() { } }); - const unlistenError = listen('culling-error', (event: any) => { - if (isEffectActive) { - setCullingModalState((prev) => ({ ...prev, progress: null, error: String(event.payload) })); - } - }); - return () => { isEffectActive = false; unlistenStart.then((f) => f()); unlistenProgress.then((f) => f()); unlistenComplete.then((f) => f()); - unlistenError.then((f) => f()); }; }, []); @@ -2979,7 +3064,7 @@ function App() { } try { - const savedPath: string = await invoke(Invokes.SavePanorama, { + const savedPath: string = await invoke('save_panorama', { firstPathStr: panoramaModalState.stitchingSourcePaths[0], }); await refreshImageList(); @@ -2999,7 +3084,7 @@ function App() { } try { - const savedPath: string = await invoke(Invokes.SaveHdr, { + const savedPath: string = await invoke('save_hdr', { firstPathStr: hdrModalState.stitchingSourcePaths[0], }); await refreshImageList(); @@ -3022,7 +3107,7 @@ function App() { })); try { - await invoke(Invokes.ApplyDenoising, { + await invoke('apply_denoising', { path: denoiseModalState.targetPath, intensity: intensity }); @@ -3037,7 +3122,7 @@ function App() { const handleSaveDenoisedImage = async (): Promise => { if (!denoiseModalState.targetPath) throw new Error("No target path"); - const savedPath = await invoke(Invokes.SaveDenoisedImage, { + const savedPath = await invoke('save_denoised_image', { originalPathStr: denoiseModalState.targetPath }); await refreshImageList(); @@ -3046,7 +3131,7 @@ function App() { const handleSaveCollage = async (base64Data: string, firstPath: string): Promise => { try { - const savedPath: string = await invoke(Invokes.SaveCollage, { + const savedPath: string = await invoke('save_collage', { base64Data, firstPathStr: firstPath, }); @@ -3168,7 +3253,7 @@ function App() { treeData = await preloadedDataRef.current.tree; console.log('Preload cache hit for folder tree.'); } else { - treeData = await invoke(Invokes.GetFolderTree, { path: root }); + treeData = await invoke('get_folder_tree', { path: root }); } setFolderTree(treeData); } catch (err) { @@ -3303,7 +3388,7 @@ function App() { useEffect(() => { const invokeWaveForm = async () => { - const waveForm: any = await invoke(Invokes.GenerateWaveform).catch((err) => + const waveForm: any = await invoke('generate_waveform').catch((err) => console.error('Failed to generate waveform:', err), ); if (waveForm) { @@ -3322,7 +3407,7 @@ function App() { const loadMetadataEarly = async () => { try { - const metadata: any = await invoke(Invokes.LoadMetadata, { path: selectedImage.path }); + const metadata: any = await invoke('load_metadata', { path: selectedImage.path }); if (!isEffectActive) return; let initialAdjusts; @@ -3343,7 +3428,7 @@ function App() { const loadFullImageData = async () => { try { - const loadImageResult: any = await invoke(Invokes.LoadImage, { path: selectedImage.path }); + const loadImageResult: any = await invoke('load_image', { path: selectedImage.path }); if (!isEffectActive) { return; } @@ -3434,7 +3519,7 @@ function App() { async (nameTemplate: string) => { if (renameTargetPaths.length > 0 && nameTemplate) { try { - const newPaths: Array = await invoke(Invokes.RenameFiles, { + const newPaths: Array = await invoke('rename_files', { nameTemplate, paths: renameTargetPaths, }); @@ -3474,7 +3559,7 @@ function App() { const handleStartImport = async (settings: AppSettings) => { if (importSourcePaths.length > 0 && importTargetFolder) { - invoke(Invokes.ImportFiles, { + invoke('import_files', { destinationFolder: importTargetFolder, settings: settings, sourcePaths: importSourcePaths, @@ -3494,7 +3579,7 @@ function App() { debouncedSetHistory.cancel(); - invoke(Invokes.ResetAdjustmentsForPaths, { paths: pathsToReset }) + invoke('reset_adjustments_for_paths', { paths: pathsToReset }) .then(() => { if (libraryActivePath && pathsToReset.includes(libraryActivePath)) { setLibraryActiveAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, rating: prev.rating })); @@ -3577,7 +3662,7 @@ function App() { const handleCreateVirtualCopy = async (sourcePath: string) => { try { - await invoke(Invokes.CreateVirtualCopy, { sourceVirtualPath: sourcePath }); + await invoke('create_virtual_copy', { sourceVirtualPath: sourcePath }); await refreshImageList(); } catch (err) { console.error('Failed to create virtual copy:', err); @@ -3832,7 +3917,7 @@ function App() { const handleCreateVirtualCopy = async (sourcePath: string) => { try { - await invoke(Invokes.CreateVirtualCopy, { sourceVirtualPath: sourcePath }); + await invoke('create_virtual_copy', { sourceVirtualPath: sourcePath }); await refreshImageList(); } catch (err) { console.error('Failed to create virtual copy:', err); @@ -3843,10 +3928,10 @@ function App() { const handleApplyAutoAdjustmentsToSelection = () => { if (finalSelection.length === 0) return; - invoke(Invokes.ApplyAutoAdjustmentsToPaths, { paths: finalSelection }) + invoke('apply_auto_adjustments_to_paths', { paths: finalSelection }) .then(async () => { if (selectedImage && finalSelection.includes(selectedImage.path)) { - const metadata: Metadata = await invoke(Invokes.LoadMetadata, { + const metadata: Metadata = await invoke('load_metadata', { path: selectedImage.path, }); if (metadata.adjustments && !metadata.adjustments.is_null) { @@ -3856,7 +3941,7 @@ function App() { } } if (libraryActivePath && finalSelection.includes(libraryActivePath)) { - const metadata: Metadata = await invoke(Invokes.LoadMetadata, { + const metadata: Metadata = await invoke('load_metadata', { path: libraryActivePath, }); if (metadata.adjustments && !metadata.adjustments.is_null) { @@ -3913,7 +3998,7 @@ function App() { label: 'Copy Adjustments', onClick: async () => { try { - const metadata: any = await invoke(Invokes.LoadMetadata, { path: finalSelection[0] }); + const metadata: any = await invoke('load_metadata', { path: finalSelection[0] }); const sourceAdjustments = metadata.adjustments && !metadata.adjustments.is_null ? { ...INITIAL_ADJUSTMENTS, ...metadata.adjustments } @@ -3991,7 +4076,7 @@ function App() { progressMessage: 'Starting panorama process...', stitchingSourcePaths: finalSelection, }); - invoke(Invokes.StitchPanorama, { paths: finalSelection }).catch((err) => { + invoke('stitch_panorama', { paths: finalSelection }).catch((err) => { setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, error: String(err), @@ -4013,7 +4098,7 @@ function App() { progressMessage: 'Starting hdr process...', stitchingSourcePaths: finalSelection, }); - invoke(Invokes.MergeHdr, { paths: finalSelection }).catch((err) => { + invoke('merge_hdr', { paths: finalSelection }).catch((err) => { setHdrModalState((prev: HdrModalState) => ({ ...prev, error: String(err), @@ -4067,7 +4152,7 @@ function App() { label: 'Duplicate Image', onClick: async () => { try { - await invoke(Invokes.DuplicateFile, { path: finalSelection[0] }); + await invoke('duplicate_file', { path: finalSelection[0] }); await refreshImageList(); } catch (err) { console.error('Failed to duplicate file:', err); @@ -4118,7 +4203,7 @@ function App() { icon: Folder, label: 'Show in File Explorer', onClick: () => { - invoke(Invokes.ShowInFinder, { path: finalSelection[0] }).catch((err) => + invoke('show_in_finder', { path: finalSelection[0] }).catch((err) => setError(`Could not show file in explorer: ${err}`), ); }, @@ -4132,7 +4217,7 @@ function App() { const handleCreateFolder = async (folderName: string) => { if (folderName && folderName.trim() !== '' && folderActionTarget) { try { - await invoke(Invokes.CreateFolder, { path: `${folderActionTarget}/${folderName.trim()}` }); + await invoke('create_folder', { path: `${folderActionTarget}/${folderName.trim()}` }); refreshAllFolderTrees(); } catch (err) { setError(`Failed to create folder: ${err}`); @@ -4146,7 +4231,7 @@ function App() { const oldPath = folderActionTarget; const trimmedNewName = newName.trim(); - await invoke(Invokes.RenameFolder, { path: oldPath, newName: trimmedNewName }); + await invoke('rename_folder', { path: oldPath, newName: trimmedNewName }); const parentDir = getParentDir(oldPath); const separator = oldPath.includes('/') ? '/' : '\\'; @@ -4238,7 +4323,7 @@ function App() { label: copyPastedLabel, onClick: async () => { try { - await invoke(Invokes.CopyFiles, { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); + await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); if (targetPath === currentFolderPath) handleLibraryRefresh(); } catch (err) { setError(`Failed to copy files: ${err}`); @@ -4249,7 +4334,7 @@ function App() { label: movePastedLabel, onClick: async () => { try { - await invoke(Invokes.MoveFiles, { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); + await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); setCopiedFilePaths([]); setMultiSelectedPaths([]); refreshAllFolderTrees(); @@ -4267,7 +4352,7 @@ function App() { icon: Folder, label: 'Show in File Explorer', onClick: () => - invoke(Invokes.ShowInFinder, { path: targetPath }).catch((err) => setError(`Could not show folder: ${err}`)), + invoke('show_in_finder', { path: targetPath }).catch((err) => setError(`Could not show folder: ${err}`)), }, ...(path ? [ @@ -4284,7 +4369,7 @@ function App() { isDestructive: true, onClick: async () => { try { - await invoke(Invokes.DeleteFolder, { path: targetPath }); + await invoke('delete_folder', { path: targetPath }); if (currentFolderPath?.startsWith(targetPath)) await handleSelectSubfolder(rootPath); refreshAllFolderTrees(); } catch (err) { @@ -4317,7 +4402,7 @@ function App() { label: copyPastedLabel, onClick: async () => { try { - await invoke(Invokes.CopyFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); handleLibraryRefresh(); } catch (err) { setError(`Failed to copy files: ${err}`); @@ -4328,7 +4413,7 @@ function App() { label: movePastedLabel, onClick: async () => { try { - await invoke(Invokes.MoveFiles, { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); setCopiedFilePaths([]); setMultiSelectedPaths([]); refreshAllFolderTrees(); diff --git a/src/bindings.ts b/src/bindings.ts new file mode 100644 index 000000000..0db5ae79d --- /dev/null +++ b/src/bindings.ts @@ -0,0 +1,394 @@ + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + + +export const commands = { +async loadImage(path: string) : Promise { + return await TAURI_INVOKE("load_image", { path }); +}, +async applyAdjustments(jsAdjustments: JsonValue, isInteractive: boolean) : Promise { + return await TAURI_INVOKE("apply_adjustments", { jsAdjustments, isInteractive }); +}, +async exportImage(originalPath: string, outputPath: string, jsAdjustments: JsonValue, exportSettings: ExportSettings) : Promise { + return await TAURI_INVOKE("export_image", { originalPath, outputPath, jsAdjustments, exportSettings }); +}, +async batchExportImages(outputFolder: string, paths: string[], exportSettings: ExportSettings, outputFormat: string) : Promise { + return await TAURI_INVOKE("batch_export_images", { outputFolder, paths, exportSettings, outputFormat }); +}, +async cancelExport() : Promise { + return await TAURI_INVOKE("cancel_export"); +}, +async estimateExportSize(jsAdjustments: JsonValue, exportSettings: ExportSettings, outputFormat: string) : Promise { + return await TAURI_INVOKE("estimate_export_size", { jsAdjustments, exportSettings, outputFormat }); +}, +async estimateBatchExportSize(paths: string[], exportSettings: ExportSettings, outputFormat: string) : Promise { + return await TAURI_INVOKE("estimate_batch_export_size", { paths, exportSettings, outputFormat }); +}, +async generateFullscreenPreview(jsAdjustments: JsonValue) : Promise { + return await TAURI_INVOKE("generate_fullscreen_preview", { jsAdjustments }); +}, +async generatePreviewForPath(path: string, jsAdjustments: JsonValue) : Promise { + return await TAURI_INVOKE("generate_preview_for_path", { path, jsAdjustments }); +}, +async generateOriginalTransformedPreview(jsAdjustments: JsonValue) : Promise { + return await TAURI_INVOKE("generate_original_transformed_preview", { jsAdjustments }); +}, +async generatePresetPreview(jsAdjustments: JsonValue) : Promise { + return await TAURI_INVOKE("generate_preset_preview", { jsAdjustments }); +}, +async generateUncroppedPreview(jsAdjustments: JsonValue) : Promise { + return await TAURI_INVOKE("generate_uncropped_preview", { jsAdjustments }); +}, +async previewGeometryTransform(params: GeometryParams, jsAdjustments: JsonValue, showLines: boolean) : Promise { + return await TAURI_INVOKE("preview_geometry_transform", { params, jsAdjustments, showLines }); +}, +async generateMaskOverlay(maskDef: MaskDefinition, width: number, height: number, scale: number, cropOffset: [number, number]) : Promise { + return await TAURI_INVOKE("generate_mask_overlay", { maskDef, width, height, scale, cropOffset }); +}, +async generateAiSubjectMask(jsAdjustments: JsonValue, path: string, startPoint: [number, number], endPoint: [number, number], rotation: number, flipHorizontal: boolean, flipVertical: boolean, orientationSteps: number) : Promise { + return await TAURI_INVOKE("generate_ai_subject_mask", { jsAdjustments, path, startPoint, endPoint, rotation, flipHorizontal, flipVertical, orientationSteps }); +}, +async generateAiForegroundMask(jsAdjustments: JsonValue, rotation: number, flipHorizontal: boolean, flipVertical: boolean, orientationSteps: number) : Promise { + return await TAURI_INVOKE("generate_ai_foreground_mask", { jsAdjustments, rotation, flipHorizontal, flipVertical, orientationSteps }); +}, +async generateAiSkyMask(jsAdjustments: JsonValue, rotation: number, flipHorizontal: boolean, flipVertical: boolean, orientationSteps: number) : Promise { + return await TAURI_INVOKE("generate_ai_sky_mask", { jsAdjustments, rotation, flipHorizontal, flipVertical, orientationSteps }); +}, +async updateWindowEffect(theme: string) : Promise { + await TAURI_INVOKE("update_window_effect", { theme }); +}, +async checkAiConnectorStatus() : Promise { + await TAURI_INVOKE("check_ai_connector_status"); +}, +async testAiConnectorConnection(address: string) : Promise { + return await TAURI_INVOKE("test_ai_connector_connection", { address }); +}, +async invokeGenerativeReplaceWithMaskDef(path: string, patchDefinition: AiPatchDefinition, currentAdjustments: JsonValue, useFastInpaint: boolean, token: string | null) : Promise { + return await TAURI_INVOKE("invoke_generative_replace_with_mask_def", { path, patchDefinition, currentAdjustments, useFastInpaint, token }); +}, +async getSupportedFileTypes() : Promise { + return await TAURI_INVOKE("get_supported_file_types"); +}, +async getLogFilePath() : Promise { + return await TAURI_INVOKE("get_log_file_path"); +}, +async frontendLog(level: string, message: string) : Promise { + return await TAURI_INVOKE("frontend_log", { level, message }); +}, +async saveCollage(base64Data: string, firstPathStr: string) : Promise { + return await TAURI_INVOKE("save_collage", { base64Data, firstPathStr }); +}, +async stitchPanorama(paths: string[]) : Promise { + return await TAURI_INVOKE("stitch_panorama", { paths }); +}, +async savePanorama(firstPathStr: string) : Promise { + return await TAURI_INVOKE("save_panorama", { firstPathStr }); +}, +async mergeHdr(paths: string[]) : Promise { + return await TAURI_INVOKE("merge_hdr", { paths }); +}, +async saveHdr(firstPathStr: string) : Promise { + return await TAURI_INVOKE("save_hdr", { firstPathStr }); +}, +async applyDenoising(path: string, intensity: number) : Promise { + return await TAURI_INVOKE("apply_denoising", { path, intensity }); +}, +async saveDenoisedImage(originalPathStr: string) : Promise { + return await TAURI_INVOKE("save_denoised_image", { originalPathStr }); +}, +async loadAndParseLut(path: string) : Promise { + return await TAURI_INVOKE("load_and_parse_lut", { path }); +}, +async fetchCommunityPresets() : Promise { + return await TAURI_INVOKE("fetch_community_presets"); +}, +async generateAllCommunityPreviews(imagePaths: string[], presets: CommunityPreset[]) : Promise> { + return await TAURI_INVOKE("generate_all_community_previews", { imagePaths, presets }); +}, +async saveTempFile(bytes: number[]) : Promise { + return await TAURI_INVOKE("save_temp_file", { bytes }); +}, +async getImageDimensions(path: string) : Promise { + return await TAURI_INVOKE("get_image_dimensions", { path }); +}, +async frontendReady() : Promise { + return await TAURI_INVOKE("frontend_ready"); +}, +async cancelThumbnailGeneration() : Promise { + return await TAURI_INVOKE("cancel_thumbnail_generation"); +}, +async generateHistogram() : Promise { + return await TAURI_INVOKE("generate_histogram"); +}, +async generateWaveform() : Promise { + return await TAURI_INVOKE("generate_waveform"); +}, +async calculateAutoAdjustments() : Promise { + return await TAURI_INVOKE("calculate_auto_adjustments"); +}, +async readExifForPaths(paths: string[]) : Promise }>> { + return await TAURI_INVOKE("read_exif_for_paths", { paths }); +}, +async listImagesInDir(path: string) : Promise { + return await TAURI_INVOKE("list_images_in_dir", { path }); +}, +async listImagesRecursive(path: string) : Promise { + return await TAURI_INVOKE("list_images_recursive", { path }); +}, +async getFolderTree(path: string) : Promise { + return await TAURI_INVOKE("get_folder_tree", { path }); +}, +async getPinnedFolderTrees(paths: string[]) : Promise { + return await TAURI_INVOKE("get_pinned_folder_trees", { paths }); +}, +async generateThumbnails(paths: string[]) : Promise> { + return await TAURI_INVOKE("generate_thumbnails", { paths }); +}, +async generateThumbnailsProgressive(paths: string[]) : Promise { + return await TAURI_INVOKE("generate_thumbnails_progressive", { paths }); +}, +async createFolder(path: string) : Promise { + return await TAURI_INVOKE("create_folder", { path }); +}, +async deleteFolder(path: string) : Promise { + return await TAURI_INVOKE("delete_folder", { path }); +}, +async copyFiles(sourcePaths: string[], destinationFolder: string) : Promise { + return await TAURI_INVOKE("copy_files", { sourcePaths, destinationFolder }); +}, +async moveFiles(sourcePaths: string[], destinationFolder: string) : Promise { + return await TAURI_INVOKE("move_files", { sourcePaths, destinationFolder }); +}, +async renameFolder(path: string, newName: string) : Promise { + return await TAURI_INVOKE("rename_folder", { path, newName }); +}, +async renameFiles(paths: string[], nameTemplate: string) : Promise { + return await TAURI_INVOKE("rename_files", { paths, nameTemplate }); +}, +async duplicateFile(path: string) : Promise { + return await TAURI_INVOKE("duplicate_file", { path }); +}, +async showInFinder(path: string) : Promise { + return await TAURI_INVOKE("show_in_finder", { path }); +}, +async deleteFilesFromDisk(paths: string[]) : Promise { + return await TAURI_INVOKE("delete_files_from_disk", { paths }); +}, +async deleteFilesWithAssociated(paths: string[]) : Promise { + return await TAURI_INVOKE("delete_files_with_associated", { paths }); +}, +async saveMetadataAndUpdateThumbnail(path: string, adjustments: JsonValue) : Promise { + return await TAURI_INVOKE("save_metadata_and_update_thumbnail", { path, adjustments }); +}, +async applyAdjustmentsToPaths(paths: string[], adjustments: JsonValue) : Promise { + return await TAURI_INVOKE("apply_adjustments_to_paths", { paths, adjustments }); +}, +async loadMetadata(path: string) : Promise { + return await TAURI_INVOKE("load_metadata", { path }); +}, +async loadPresets() : Promise { + return await TAURI_INVOKE("load_presets"); +}, +async savePresets(presets: PresetItem[]) : Promise { + return await TAURI_INVOKE("save_presets", { presets }); +}, +async loadSettings() : Promise { + return await TAURI_INVOKE("load_settings"); +}, +async saveSettings(settings: AppSettings) : Promise { + return await TAURI_INVOKE("save_settings", { settings }); +}, +async resetAdjustmentsForPaths(paths: string[]) : Promise { + return await TAURI_INVOKE("reset_adjustments_for_paths", { paths }); +}, +async applyAutoAdjustmentsToPaths(paths: string[]) : Promise { + return await TAURI_INVOKE("apply_auto_adjustments_to_paths", { paths }); +}, +async handleImportPresetsFromFile(filePath: string) : Promise { + return await TAURI_INVOKE("handle_import_presets_from_file", { filePath }); +}, +async handleImportLegacyPresetsFromFile(filePath: string) : Promise { + return await TAURI_INVOKE("handle_import_legacy_presets_from_file", { filePath }); +}, +async handleExportPresetsToFile(presetsToExport: PresetItem[], filePath: string) : Promise { + return await TAURI_INVOKE("handle_export_presets_to_file", { presetsToExport, filePath }); +}, +async saveCommunityPreset(name: string, adjustments: JsonValue) : Promise { + return await TAURI_INVOKE("save_community_preset", { name, adjustments }); +}, +async clearAllSidecars(rootPath: string) : Promise { + return await TAURI_INVOKE("clear_all_sidecars", { rootPath }); +}, +async clearThumbnailCache() : Promise { + return await TAURI_INVOKE("clear_thumbnail_cache"); +}, +async setColorLabelForPaths(paths: string[], color: string | null) : Promise { + return await TAURI_INVOKE("set_color_label_for_paths", { paths, color }); +}, +async importFiles(sourcePaths: string[], destinationFolder: string, settings: ImportSettings) : Promise { + return await TAURI_INVOKE("import_files", { sourcePaths, destinationFolder, settings }); +}, +async createVirtualCopy(sourceVirtualPath: string) : Promise { + return await TAURI_INVOKE("create_virtual_copy", { sourceVirtualPath }); +}, +async startBackgroundIndexing(folderPath: string) : Promise { + return await TAURI_INVOKE("start_background_indexing", { folderPath }); +}, +async clearAiTags(rootPath: string) : Promise { + return await TAURI_INVOKE("clear_ai_tags", { rootPath }); +}, +async clearAllTags(rootPath: string) : Promise { + return await TAURI_INVOKE("clear_all_tags", { rootPath }); +}, +async addTagForPaths(paths: string[], tag: string) : Promise { + return await TAURI_INVOKE("add_tag_for_paths", { paths, tag }); +}, +async removeTagForPaths(paths: string[], tag: string) : Promise { + return await TAURI_INVOKE("remove_tag_for_paths", { paths, tag }); +}, +async cullImages(paths: string[], settings: CullingSettings) : Promise { + return await TAURI_INVOKE("cull_images", { paths, settings }); +}, +async getLensfunMakers() : Promise { + return await TAURI_INVOKE("get_lensfun_makers"); +}, +async getLensfunLensesForMaker(maker: string) : Promise { + return await TAURI_INVOKE("get_lensfun_lenses_for_maker", { maker }); +}, +async autodetectLens(maker: string, model: string) : Promise<[string, string] | null> { + return await TAURI_INVOKE("autodetect_lens", { maker, model }); +}, +async getLensDistortionParams(maker: string, model: string, focalLength: number, aperture: number | null, distance: number | null) : Promise { + return await TAURI_INVOKE("get_lens_distortion_params", { maker, model, focalLength, aperture, distance }); +}, +async previewNegativeConversion(path: string, params: NegativeConversionParams) : Promise { + return await TAURI_INVOKE("preview_negative_conversion", { path, params }); +}, +async convertNegativeFull(path: string, params: NegativeConversionParams) : Promise { + return await TAURI_INVOKE("convert_negative_full", { path, params }); +}, +async saveConvertedNegative(originalPathStr: string) : Promise { + return await TAURI_INVOKE("save_converted_negative", { originalPathStr }); +} +} + +/** user-defined events **/ + + +export const events = __makeEvents__<{ +aiConnectorStatusUpdate: AiConnectorStatusUpdate +}>({ +aiConnectorStatusUpdate: "ai-connector-status-update" +}) + +/** user-defined constants **/ + + + +/** user-defined types **/ + +export type AiConnectorStatusUpdate = { connected: boolean } +export type AiForegroundMaskParameters = { maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } +export type AiPatchDefinition = { id: string; name: string; visible: boolean; invert: boolean; prompt: string; patchData?: PatchData | null; opacity?: number; subMasks: SubMask[] } +export type AiSkyMaskParameters = { maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } +export type AiSubjectMaskParameters = { startX: number; startY: number; endX: number; endY: number; maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } +export type AppSettings = { lastRootPath: string | null; pinnedFolders?: string[]; editorPreviewResolution: number | null; enableZoomHifi?: boolean | null; enableLivePreviews?: boolean | null; enableHighQualityLivePreviews?: boolean | null; sortCriteria: SortCriteria | null; filterCriteria: FilterCriteria | null; theme: string | null; transparent: boolean | null; decorations: boolean | null; aiConnectorAddress: string | null; lastFolderState: LastFolderState | null; adaptiveEditorTheme: boolean | null; uiVisibility: JsonValue | null; enableAiTagging: boolean | null; taggingThreadCount: number | null; taggingShortcuts?: string[] | null; customAiTags?: string[] | null; aiTagCount?: number | null; thumbnailSize: string | null; thumbnailAspectRatio: string | null; aiProvider: string | null; adjustmentVisibility?: Partial<{ [key in string]: boolean }>; enableExifReading: boolean | null; activeTreeSection?: string | null; copyPasteSettings?: CopyPasteSettings; rawHighlightCompression?: number | null; processingBackend?: string | null; linuxGpuOptimization?: boolean | null; libraryViewMode?: string | null; exportPresets?: ExportPreset[]; myLenses?: MyLens[] | null; enableFolderImageCounts?: boolean | null; linearRawMode?: string; enableXmpSync?: boolean | null; createXmpIfMissing?: boolean | null } +export type CommunityPreset = { name: string; creator: string; adjustments: JsonValue } +export type CopyPasteSettings = { mode: PasteMode; includedAdjustments?: string[]; knownAdjustments?: string[] } +export type CullGroup = { representative: ImageAnalysisResult; duplicates: ImageAnalysisResult[] } +export type CullingSettings = { similarityThreshold: number; blurThreshold: number; groupSimilar: boolean; filterBlurry: boolean } +export type CullingSuggestions = { similarGroups: CullGroup[]; blurryImages: ImageAnalysisResult[]; failedPaths: string[] } +export type ExportPreset = { id: string; name: string; fileFormat: string; jpegQuality: number; enableResize: boolean; resizeMode: string; resizeValue: number; dontEnlarge: boolean; keepMetadata: boolean; stripGps: boolean; filenameTemplate: string; enableWatermark: boolean; watermarkPath: string | null; watermarkAnchor: string | null; watermarkScale: number; watermarkSpacing: number; watermarkOpacity: number; exportMasks?: boolean | null } +export type ExportSettings = { jpegQuality: number; resize: ResizeOptions | null; keepMetadata: boolean; stripGps: boolean; filenameTemplate: string | null; watermark: WatermarkSettings | null; exportMasks?: boolean } +export type FilterCriteria = { rating: number; rawStatus: string; colors?: string[] } +export type FolderNode = { name: string; path: string; children: FolderNode[]; isDir: boolean; imageCount: number } +export type GeometryParams = { distortion: number; vertical: number; horizontal: number; rotate: number; aspect: number; scale: number; x_offset: number; y_offset: number; lens_distortion_amount: number; lens_vignette_amount: number; lens_tca_amount: number; lens_distortion_enabled: boolean; lens_tca_enabled: boolean; lens_vignette_enabled: boolean; lens_auto_crop: boolean; lens_dist_k1: number; lens_dist_k2: number; lens_dist_k3: number; lens_model: number; tca_vr: number; tca_vb: number; vig_k1: number; vig_k2: number; vig_k3: number } +export type HistogramData = { red: number[]; green: number[]; blue: number[]; luma: number[] } +export type ImageAnalysisResult = { path: string; qualityScore: number; sharpnessMetric: number; centerFocusMetric: number; exposureMetric: number; width: number; height: number } +export type ImageDimensions = { width: number; height: number } +export type ImageFile = { path: string; modified: number; is_edited: boolean; tags: string[] | null; exif: Partial<{ [key in string]: string }> | null; is_virtual_copy: boolean } +export type ImageMetadata = { version: number; rating: number; adjustments: JsonValue; tags?: string[] | null } +export type ImportSettings = { filenameTemplate: string; organizeByDate: boolean; dateFolderFormat: string; deleteAfterImport: boolean } +export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> +export type LastFolderState = { currentFolderPath: string; expandedFolders: string[] } +export type LensDistortionParams = { k1: number; k2: number; k3: number; model: number; tca_vr: number; tca_vb: number; vig_k1: number; vig_k2: number; vig_k3: number } +export type LoadImageResult = { width: number; height: number; metadata: ImageMetadata; exif: Partial<{ [key in string]: string }>; is_raw: boolean } +export type LutParseResult = { size: number } +export type MaskDefinition = { id: string; name: string; visible: boolean; invert: boolean; opacity?: number; adjustments: JsonValue; subMasks: SubMask[] } +export type MyLens = { maker: string; model: string } +export type NegativeConversionParams = { red_weight: number; green_weight: number; blue_weight: number; exposure: number; contrast: number } +export type PasteMode = "merge" | "replace" +export type PatchData = { color: string; mask: string } +export type Preset = { id: string; name: string; adjustments: JsonValue } +export type PresetFolder = { id: string; name: string; children: Preset[] } +export type PresetItem = { preset: Preset } | { folder: PresetFolder } +export type ResizeMode = "longEdge" | "shortEdge" | "width" | "height" +export type ResizeOptions = { mode: ResizeMode; value: number; dontEnlarge: boolean } +export type SortCriteria = { key: string; order: string } +export type SubMask = { id: string; type: string; visible: boolean; invert?: boolean; opacity?: number; mode: SubMaskMode; parameters: JsonValue } +export type SubMaskMode = "additive" | "subtractive" +export type WatermarkAnchor = "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" +export type WatermarkSettings = { path: string; anchor: WatermarkAnchor; scale: number; spacing: number; opacity: number } +export type WaveformData = { red: number[]; green: number[]; blue: number[]; luma: number[]; width: number; height: number } + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +} diff --git a/src/components/modals/CollageModal.tsx b/src/components/modals/CollageModal.tsx index 7dedbb181..682c585c5 100644 --- a/src/components/modals/CollageModal.tsx +++ b/src/components/modals/CollageModal.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { AnimatePresence, motion } from 'framer-motion'; import { CheckCircle, XCircle, Loader2, Save, Crop, Proportions, LayoutTemplate, Shuffle, RectangleHorizontal, RectangleVertical, Palette } from 'lucide-react'; -import { ImageFile, Invokes } from '../ui/AppProperties'; +import { commands } from '../../bindings'; +import { ImageFile } from '../ui/AppProperties'; import Button from '../ui/Button'; import Slider from '../ui/Slider'; import Switch from '../ui/Switch'; @@ -122,10 +122,10 @@ export default function CollageModal({ isOpen, onClose, onSave, sourceImages }: setError(null); try { const imagePromises = sourceImages.map(async (imageFile) => { - const metadata: any = await invoke(Invokes.LoadMetadata, { path: imageFile.path }); + const metadata: any = await commands.loadMetadata(imageFile.path); const adjustments = metadata.adjustments && !metadata.adjustments.is_null ? metadata.adjustments : {}; - const imageData: Uint8Array = await invoke(Invokes.GeneratePreviewForPath, { path: imageFile.path, jsAdjustments: adjustments }); + const imageData: Uint8Array = await commands.generatePreviewForPath(imageFile.path, adjustments); const blob = new Blob([imageData], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); diff --git a/src/components/modals/CullingModal.tsx b/src/components/modals/CullingModal.tsx index fd5d5b6a1..ed3d1e888 100644 --- a/src/components/modals/CullingModal.tsx +++ b/src/components/modals/CullingModal.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { CheckCircle, XCircle, Loader2, Users, Trash2, Star, Tag } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; -import { CullingSettings, CullingSuggestions, Invokes, Progress } from '../ui/AppProperties'; +import { commands } from '../../bindings'; +import { CullingSettings, CullingSuggestions, Progress } from '../ui/AppProperties'; import Button from '../ui/Button'; import Switch from '../ui/Switch'; import Slider from '../ui/Slider'; @@ -124,7 +124,7 @@ export default function CullingModal({ const handleStartCulling = useCallback(async () => { try { - await invoke(Invokes.CullImages, { paths: imagePaths, settings }); + await commands.cullImages(imagePaths, settings); } catch (err) { console.error('Culling failed to start:', err); onError(String(err)); diff --git a/src/components/modals/LensCorrectionModal.tsx b/src/components/modals/LensCorrectionModal.tsx index 8eb52650a..b605584d6 100644 --- a/src/components/modals/LensCorrectionModal.tsx +++ b/src/components/modals/LensCorrectionModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { RotateCcw, Search, @@ -205,13 +205,7 @@ export default function LensCorrectionModal({ const fetchDistortionParams = async (maker: string, model: string) => { try { - const distParams: any = await invoke('get_lens_distortion_params', { - maker, - model, - focalLength: focalLength, - aperture: aperture, - distance: distance - }); + const distParams: any = await commands.getLensDistortionParams(maker, model, focalLength ?? 50, aperture, distance); return distParams; } catch (error) { console.error('Failed to fetch lens params', error); @@ -252,11 +246,7 @@ export default function LensCorrectionModal({ vig_k3: currentParams.lensDistortionParams?.vig_k3 ?? 0, }; - const result: string = await invoke('preview_geometry_transform', { - params: fullParams, - jsAdjustments: currentAdjustments, - showLines: false, - }); + const result: string = await commands.previewGeometryTransform(fullParams, currentAdjustments, false); setPreviewUrl(result); } catch (e) { console.error('Lens correction preview failed', e); @@ -270,7 +260,7 @@ export default function LensCorrectionModal({ setIsMounted(true); const timer = setTimeout(() => setShow(true), 10); - invoke('load_settings').then((settings: any) => { + commands.loadSettings().then((settings: any) => { if (settings?.myLenses) { setMyLenses(settings.myLenses); } @@ -294,12 +284,12 @@ export default function LensCorrectionModal({ handleResetZoom(); updatePreview(initParams); - invoke('get_lensfun_makers') + commands.getLensfunMakers() .then((m: any) => setMakers(m)) .catch(console.error); if (initParams.lensMaker) { - invoke('get_lensfun_lenses_for_maker', { maker: initParams.lensMaker }) + commands.getLensfunLensesForMaker(initParams.lensMaker) .then((l: any) => setLenses(l)) .catch(console.error); } @@ -327,7 +317,7 @@ export default function LensCorrectionModal({ setLenses([]); setDetectionStatus('idle'); - invoke('get_lensfun_lenses_for_maker', { maker }) + commands.getLensfunLensesForMaker(maker) .then((l: any) => setLenses(l)) .catch(console.error); @@ -357,7 +347,7 @@ export default function LensCorrectionModal({ setParams(tempParams); setDetectionStatus('idle'); - invoke('get_lensfun_lenses_for_maker', { maker: selected.maker }) + commands.getLensfunLensesForMaker(selected.maker) .then((l: any) => setLenses(l)) .catch(console.error); @@ -395,13 +385,13 @@ export default function LensCorrectionModal({ setDetectionStatus('detecting'); try { - const result: [string, string] | null = await invoke('autodetect_lens', { maker: exifMaker, model: exifModel }); + const result: [string, string] | null = await commands.autodetectLens(exifMaker, exifModel); if (result) { const [detectedMaker, detectedModel] = result; if (detectedMaker !== params.lensMaker) { - await invoke('get_lensfun_lenses_for_maker', { maker: detectedMaker }).then((l: any) => setLenses(l)); + await commands.getLensfunLensesForMaker(detectedMaker).then((l: any) => setLenses(l)); } const distortionParams = await fetchDistortionParams(detectedMaker, detectedModel); @@ -482,11 +472,7 @@ export default function LensCorrectionModal({ vig_k3: currentAdjustments.lensDistortionParams?.vig_k3 ?? 0, }; - invoke('preview_geometry_transform', { - params: fullParams, - jsAdjustments: currentAdjustments, - showLines: false, - }).then((result: any) => setPreviewUrl(result)); + commands.previewGeometryTransform(fullParams, currentAdjustments, false).then((result: any) => setPreviewUrl(result)); } else { updatePreview(params); } diff --git a/src/components/modals/NegativeConversionModal.tsx b/src/components/modals/NegativeConversionModal.tsx index ccebe1205..d5b08548e 100644 --- a/src/components/modals/NegativeConversionModal.tsx +++ b/src/components/modals/NegativeConversionModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { RotateCcw, ZoomIn, @@ -109,10 +109,7 @@ export default function NegativeConversionModal({ throttle(async (currentParams: NegativeParams, isInitialLoad: boolean = false) => { if (!selectedImagePath) return; try { - const result: string = await invoke('preview_negative_conversion', { - path: selectedImagePath, - params: currentParams - }); + const result: string = await commands.previewNegativeConversion(selectedImagePath, currentParams); setPreviewUrl(result); if (isInitialLoad) { setIsLoading(false); @@ -133,10 +130,7 @@ export default function NegativeConversionModal({ setIsLoading(true); setTimeout(() => setShow(true), 10); updatePreview(DEFAULT_PARAMS, true); - invoke('generate_preview_for_path', { - path: selectedImagePath, - jsAdjustments: {} - }).then((res: any) => { + commands.generatePreviewForPath(selectedImagePath || '', {}).then((res: any) => { const blob = new Blob([new Uint8Array(res)], { type: 'image/jpeg' }); setOriginalUrl(URL.createObjectURL(blob)); }).catch(console.error); @@ -164,10 +158,8 @@ export default function NegativeConversionModal({ if (!selectedImagePath) return; setIsSaving(true); try { - await invoke('convert_negative_full', { path: selectedImagePath, params }); - const savedPath: string = await invoke('save_converted_negative', { - originalPathStr: selectedImagePath - }); + await commands.convertNegativeFull(selectedImagePath, params); + const savedPath: string = await commands.saveConvertedNegative(selectedImagePath); onSave(savedPath); onClose(); } catch (e) { diff --git a/src/components/modals/TransformModal.tsx b/src/components/modals/TransformModal.tsx index f6551ff2e..1ee93ffc1 100644 --- a/src/components/modals/TransformModal.tsx +++ b/src/components/modals/TransformModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { Check, RotateCcw, @@ -238,11 +238,7 @@ export default function TransformModal({ isOpen, onClose, onApply, currentAdjust lens_auto_crop: currentAdjustments.lensAutoCropEnabled ?? true, }; - const result: string = await invoke('preview_geometry_transform', { - params: fullParams, - jsAdjustments: currentAdjustments, - showLines: linesEnabled, - }); + const result: string = await commands.previewGeometryTransform(fullParams, currentAdjustments, linesEnabled); setPreviewUrl(result); } catch (e) { console.error('Preview transform failed', e); @@ -331,11 +327,7 @@ export default function TransformModal({ isOpen, onClose, onApply, currentAdjust lens_vignette_enabled: currentAdjustments.lensVignetteEnabled ?? true, lens_auto_crop: currentAdjustments.lensAutoCropEnabled ?? true, }; - const result: string = await invoke('preview_geometry_transform', { - params: fullParams, - jsAdjustments: currentAdjustments, - showLines: false, - }); + const result: string = await commands.previewGeometryTransform(fullParams, currentAdjustments, false); setPreviewUrl(result); } else { updatePreview(params, showLines); @@ -630,4 +622,4 @@ export default function TransformModal({ isOpen, onClose, onApply, currentAdjust ); -} \ No newline at end of file +} diff --git a/src/components/panel/CommunityPage.tsx b/src/components/panel/CommunityPage.tsx index 515db565c..60f572067 100644 --- a/src/components/panel/CommunityPage.tsx +++ b/src/components/panel/CommunityPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { ArrowLeft, CheckCircle2, @@ -12,7 +12,7 @@ import { import { motion, AnimatePresence } from 'framer-motion'; import Button from '../ui/Button'; import Input from '../ui/Input'; -import { Invokes, SupportedTypes, ImageFile } from '../ui/AppProperties'; +import { SupportedTypes, ImageFile } from '../ui/AppProperties'; import { INITIAL_ADJUSTMENTS } from '../../utils/adjustments'; const DEFAULT_PREVIEW_IMAGE_URL = 'https://raw.githubusercontent.com/CyberTimon/RapidRAW-Presets/main/sample-image.jpg'; @@ -75,7 +75,7 @@ const CommunityPage = ({ onBackToLibrary, imageList, currentFolderPath }: Commun try { const response = await fetch(DEFAULT_PREVIEW_IMAGE_URL); const blob = await response.blob(); - const tempPath: string = await invoke(Invokes.SaveTempFile, { bytes: Array.from(new Uint8Array(await blob.arrayBuffer())) }); + const tempPath: string = await commands.saveTempFile(Array.from(new Uint8Array(await blob.arrayBuffer()))); return tempPath; } catch (error) { console.error("Failed to fetch default preview image:", error); @@ -87,7 +87,7 @@ const CommunityPage = ({ onBackToLibrary, imageList, currentFolderPath }: Commun const fetchPresets = async () => { setIsLoading(true); try { - const communityPresets: CommunityPreset[] = await invoke(Invokes.FetchCommunityPresets); + const communityPresets: CommunityPreset[] = await commands.fetchCommunityPresets(); setPresets(communityPresets); } catch (error) { console.error("Failed to fetch community presets:", error); @@ -141,13 +141,13 @@ const CommunityPage = ({ onBackToLibrary, imageList, currentFolderPath }: Commun const generateAllPreviews = async () => { setAllPreviewsLoaded(false); try { - const previewDataMap: Record = await invoke(Invokes.GenerateAllCommunityPreviews, { - imagePaths: previewImagePaths, - presets: presets.map(p => ({ + const previewDataMap: Record = await commands.generateAllCommunityPreviews( + previewImagePaths, + presets.map((p) => ({ ...p, adjustments: { ...INITIAL_ADJUSTMENTS, ...p.adjustments } })), - }); + ); const newPreviews: Record = {}; for (const [presetName, imageData] of Object.entries(previewDataMap)) { @@ -178,10 +178,7 @@ const CommunityPage = ({ onBackToLibrary, imageList, currentFolderPath }: Commun throw new Error("Preset adjustments are missing."); } - await invoke(Invokes.SaveCommunityPreset, { - name: preset.name, - adjustments: preset.adjustments, - }); + await commands.saveCommunityPreset(preset.name, preset.adjustments); setDownloadStatus(prev => ({ ...prev, [preset.name]: 'success' })); } catch (error) { console.error(`Failed to download preset ${preset.name}:`, error); @@ -331,4 +328,4 @@ const CommunityPage = ({ onBackToLibrary, imageList, currentFolderPath }: Commun ); }; -export default CommunityPage; \ No newline at end of file +export default CommunityPage; diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 69f075514..6f417d729 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -3,7 +3,7 @@ import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; import { Crop, PercentCrop } from 'react-image-crop'; import { Loader2 } from 'lucide-react'; import clsx from 'clsx'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import debounce from 'lodash.debounce'; import { AnimatePresence } from 'framer-motion'; import { ImageDimensions, useImageRenderSize } from '../../hooks/useImageRenderSize'; @@ -12,7 +12,7 @@ import EditorToolbar from './editor/EditorToolbar'; import ImageCanvas from './editor/ImageCanvas'; import Waveform from './editor/Waveform'; import { Mask, SubMask } from './right/Masks'; -import { BrushSettings, Invokes, Panel, SelectedImage, TransformState, WaveformData } from '../ui/AppProperties'; +import { BrushSettings, Panel, SelectedImage, TransformState, WaveformData } from '../ui/AppProperties'; import type { OverlayMode } from './right/CropPanel'; interface EditorProps { @@ -311,13 +311,13 @@ export default function Editor({ } try { const cropOffset = [adjustments.crop?.x || 0, adjustments.crop?.y || 0]; - const dataUrl: string = await invoke(Invokes.GenerateMaskOverlay, { - cropOffset, - height: Math.round(renderSize.height), + const dataUrl: string = await commands.generateMaskOverlay( maskDef, - scale: renderSize.scale, - width: Math.round(renderSize.width), - }); + Math.round(renderSize.width), + Math.round(renderSize.height), + renderSize.scale, + cropOffset, + ); if (dataUrl) { setMaskOverlayUrl(dataUrl); } else { diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index 273344457..2b1722660 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, forwardRef, useMemo, useCallback } from 'react'; import { getVersion } from '@tauri-apps/api/app'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { open } from '@tauri-apps/plugin-shell'; import { AlertTriangle, @@ -29,7 +29,6 @@ import { AppSettings, FilterCriteria, ImageFile, - Invokes, LibraryViewMode, Progress, RawStatus, @@ -1348,7 +1347,7 @@ export default function MainLibrary({ }, []); useEffect(() => { - invoke(Invokes.GetSupportedFileTypes) + commands.getSupportedFileTypes() .then((types: any) => setSupportedTypes(types)) .catch((err) => console.error('Failed to load supported file types:', err)); }, []); diff --git a/src/components/panel/SettingsPanel.tsx b/src/components/panel/SettingsPanel.tsx index 45d957c09..13df77416 100644 --- a/src/components/panel/SettingsPanel.tsx +++ b/src/components/panel/SettingsPanel.tsx @@ -15,7 +15,7 @@ import { Keyboard, Bookmark, } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../bindings'; import { relaunch } from '@tauri-apps/plugin-process'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; @@ -27,7 +27,7 @@ import Switch from '../ui/Switch'; import Input from '../ui/Input'; import Slider from '../ui/Slider'; import { ThemeProps, THEMES, DEFAULT_THEME_ID } from '../../utils/themes'; -import { Invokes } from '../ui/AppProperties'; + interface ConfirmModalState { confirmText: string; @@ -279,7 +279,7 @@ export default function SettingsPanel({ useEffect(() => { const fetchLogPath = async () => { try { - const path: string = await invoke(Invokes.GetLogFilePath); + const path: string = await commands.getLogFilePath(); setLogPath(path); } catch (error) { console.error('Failed to get log file path:', error); @@ -288,7 +288,7 @@ export default function SettingsPanel({ }; fetchLogPath(); - invoke('get_lensfun_makers') + commands.getLensfunMakers() .then((m: any) => setLensMakers(m)) .catch(console.error); }, []); @@ -321,7 +321,7 @@ export default function SettingsPanel({ setTempLensModel(''); setLensModels([]); if (maker) { - invoke('get_lensfun_lenses_for_maker', { maker }) + commands.getLensfunLensesForMaker(maker) .then((l: any) => setLensModels(l)) .catch(console.error); } @@ -363,7 +363,7 @@ export default function SettingsPanel({ setIsClearing(true); setClearMessage('Deleting sidecar files, please wait...'); try { - const count: number = await invoke(Invokes.ClearAllSidecars, { rootPath: effectiveRootPath }); + const count: number = await commands.clearAllSidecars(effectiveRootPath); setClearMessage(`${count} sidecar files deleted successfully.`); onLibraryRefresh(); } catch (err: any) { @@ -393,7 +393,7 @@ export default function SettingsPanel({ setIsClearingAiTags(true); setAiTagsClearMessage('Clearing AI tags from all sidecar files...'); try { - const count: number = await invoke(Invokes.ClearAiTags, { rootPath: effectiveRootPath }); + const count: number = await commands.clearAiTags(effectiveRootPath); setAiTagsClearMessage(`${count} files updated. AI tags removed.`); onLibraryRefresh(); } catch (err: any) { @@ -423,7 +423,7 @@ export default function SettingsPanel({ setIsClearingTags(true); setTagsClearMessage('Clearing all tags from sidecar files...'); try { - const count: number = await invoke(Invokes.ClearAllTags, { rootPath: effectiveRootPath }); + const count: number = await commands.clearAllTags(effectiveRootPath); setTagsClearMessage(`${count} files updated. All non-color tags removed.`); onLibraryRefresh(); } catch (err: any) { @@ -476,7 +476,7 @@ export default function SettingsPanel({ setIsClearingCache(true); setCacheClearMessage('Clearing thumbnail cache...'); try { - await invoke(Invokes.ClearThumbnailCache); + await commands.clearThumbnailCache(); setCacheClearMessage('Thumbnail cache cleared successfully.'); onLibraryRefresh(); } catch (err: any) { @@ -508,7 +508,7 @@ export default function SettingsPanel({ } setTestStatus({ testing: true, message: 'Testing...', success: null }); try { - await invoke(Invokes.TestAIConnectorConnection, { address: aiConnectorAddress }); + await commands.testAiConnectorConnection(aiConnectorAddress); setTestStatus({ testing: false, message: 'Connection successful!', success: true }); } catch (err) { setTestStatus({ testing: false, message: `Connection failed.`, success: false }); @@ -1352,7 +1352,7 @@ export default function SettingsPanel({ { if (logPath && !logPath.startsWith('Could not')) { - await invoke(Invokes.ShowInFinder, { path: logPath }); + await commands.showInFinder(logPath); } }} buttonText="Open Log File" diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 36fbd7bd6..b1ecef2bd 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { save, open } from '@tauri-apps/plugin-dialog'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../../bindings'; import { Save, CheckCircle, XCircle, Loader, Ban } from 'lucide-react'; import debounce from 'lodash.debounce'; import Switch from '../../ui/Switch'; @@ -18,7 +18,7 @@ import { FileFormats, WatermarkAnchor, } from '../../ui/ExportImportProperties'; -import { Invokes, SelectedImage, AppSettings } from '../../ui/AppProperties'; +import { SelectedImage, AppSettings } from '../../ui/AppProperties'; import ExportPresetsList from '../../ui/ExportPresetsList'; import { useExportSettings } from '../../../hooks/useExportSettings'; @@ -236,9 +236,7 @@ export default function ExportPanel({ const fetchWatermarkDimensions = async () => { if (watermarkPath) { try { - const dimensions: { width: number; height: number } = await invoke('get_image_dimensions', { - path: watermarkPath, - }); + const dimensions: { width: number; height: number } = await commands.getImageDimensions(watermarkPath); if (dimensions.height > 0) { setWatermarkImageAspectRatio(dimensions.width / dimensions.height); } else { @@ -276,11 +274,7 @@ export default function ExportPanel({ } setIsEstimating(true); try { - const size: number = await invoke(Invokes.EstimateExportSize, { - jsAdjustments: currentAdjustments, - exportSettings, - outputFormat: format, - }); + const size: number = await commands.estimateExportSize(currentAdjustments, exportSettings, format); setEstimatedSize(size); } catch (err) { console.error('Failed to estimate export size:', err); @@ -392,12 +386,12 @@ export default function ExportPanel({ if (isBatchMode || !isEditorContext) { const outputFolder = await open({ title: `Select Folder to Export ${numImages} Image(s)`, directory: true }); if (outputFolder) { - await invoke(Invokes.BatchExportImages, { - exportSettings, + await commands.batchExportImages( outputFolder, - outputFormat: FILE_FORMATS.find((f: FileFormat) => f.id === fileFormat)?.extensions[0], - paths: pathsToExport, - }); + pathsToExport, + exportSettings, + FILE_FORMATS.find((f: FileFormat) => f.id === fileFormat)?.extensions[0] || 'jpeg', + ); } else { setExportState((prev: ExportState) => ({ ...prev, status: Status.Idle })); } @@ -413,12 +407,7 @@ export default function ExportPanel({ filters: FILE_FORMATS.map((f: FileFormat) => ({ name: f.name, extensions: f.extensions })), }); if (filePath) { - await invoke(Invokes.ExportImage, { - exportSettings, - jsAdjustments: adjustments, - originalPath: selectedImage.path, - outputPath: filePath, - }); + await commands.exportImage(selectedImage.path, filePath, adjustments, exportSettings); } else { setExportState((prev: ExportState) => ({ ...prev, status: Status.Idle })); } @@ -435,7 +424,7 @@ export default function ExportPanel({ const handleCancel = async () => { try { - await invoke(Invokes.CancelExport); + await commands.cancelExport(); } catch (error) { console.error('Failed to send cancel request:', error); } diff --git a/src/components/panel/right/LibraryExportPanel.tsx b/src/components/panel/right/LibraryExportPanel.tsx index 3ebc2867e..416f0088f 100644 --- a/src/components/panel/right/LibraryExportPanel.tsx +++ b/src/components/panel/right/LibraryExportPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../../bindings'; import { Save, CheckCircle, XCircle, Loader, X, Ban } from 'lucide-react'; import debounce from 'lodash.debounce'; import Switch from '../../ui/Switch'; @@ -17,7 +17,7 @@ import { FileFormats, WatermarkAnchor, } from '../../ui/ExportImportProperties'; -import { Invokes, ImageFile, AppSettings } from '../../ui/AppProperties'; +import { ImageFile, AppSettings } from '../../ui/AppProperties'; import ExportPresetsList from '../../ui/ExportPresetsList'; import { useExportSettings } from '../../../hooks/useExportSettings'; @@ -223,9 +223,7 @@ export default function LibraryExportPanel({ if (multiSelectedPaths.length > 0) { try { const firstPath = multiSelectedPaths[0]; - const dimensions: { width: number; height: number } = await invoke('get_image_dimensions', { - path: firstPath, - }); + const dimensions: { width: number; height: number } = await commands.getImageDimensions(firstPath); if (dimensions.width > 0 && dimensions.height > 0) { setImageAspectRatio(dimensions.width / dimensions.height); } else { @@ -249,9 +247,7 @@ export default function LibraryExportPanel({ const fetchWatermarkDimensions = async () => { if (watermarkPath) { try { - const dimensions: { width: number; height: number } = await invoke('get_image_dimensions', { - path: watermarkPath, - }); + const dimensions: { width: number; height: number } = await commands.getImageDimensions(watermarkPath); if (dimensions.height > 0) { setWatermarkImageAspectRatio(dimensions.width / dimensions.height); } else { @@ -285,11 +281,7 @@ export default function LibraryExportPanel({ debounce(async (paths, exportSettings, format) => { setIsEstimating(true); try { - const size: number = await invoke(Invokes.EstimateBatchExportSize, { - paths, - exportSettings, - outputFormat: format, - }); + const size: number = await commands.estimateBatchExportSize(paths, exportSettings, format); setEstimatedSize(size); } catch (err) { console.error('Failed to estimate batch export size:', err); @@ -411,12 +403,12 @@ export default function LibraryExportPanel({ }); if (outputFolder) { - await invoke(Invokes.BatchExportImages, { - exportSettings, + await commands.batchExportImages( outputFolder, - outputFormat: FILE_FORMATS.find((f: FileFormat) => f.id === fileFormat)?.extensions[0], - paths: multiSelectedPaths, - }); + multiSelectedPaths, + exportSettings, + FILE_FORMATS.find((f: FileFormat) => f.id === fileFormat)?.extensions[0] || 'jpeg', + ); } else { setExportState((prev: ExportState) => ({ ...prev, status: Status.Idle })); } @@ -432,7 +424,7 @@ export default function LibraryExportPanel({ const handleCancel = async () => { try { - await invoke(Invokes.CancelExport); + await commands.cancelExport(); } catch (error) { console.error('Failed to send cancel request:', error); } diff --git a/src/components/panel/right/MetadataPanel.tsx b/src/components/panel/right/MetadataPanel.tsx index 09b68fff4..f4ce4a091 100644 --- a/src/components/panel/right/MetadataPanel.tsx +++ b/src/components/panel/right/MetadataPanel.tsx @@ -1,9 +1,9 @@ import { useState, useMemo } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { Check, ChevronDown, ChevronRight, Plus, Star, Tag, X } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import clsx from 'clsx'; -import { SelectedImage, AppSettings, Invokes } from '../../ui/AppProperties'; +import { commands } from '../../../bindings'; +import { SelectedImage, AppSettings } from '../../ui/AppProperties'; import { COLOR_LABELS, Color } from '../../../utils/adjustments'; interface CameraSetting { @@ -176,7 +176,7 @@ export default function MetadataPanel({ if (newTagValue && !currentTags.some((t) => t.tag === newTagValue)) { try { const prefixedTag = `${USER_TAG_PREFIX}${newTagValue}`; - await invoke(Invokes.AddTagForPaths, { paths: [selectedImage.path], tag: prefixedTag }); + await commands.addTagForPaths([selectedImage.path], prefixedTag); const newTags = [...currentTags, { tag: newTagValue, isUser: true }]; onTagsChanged([selectedImage.path], newTags); @@ -190,7 +190,7 @@ export default function MetadataPanel({ const handleRemoveTag = async (tagToRemove: { tag: string; isUser: boolean }) => { try { const prefixedTag = tagToRemove.isUser ? `${USER_TAG_PREFIX}${tagToRemove.tag}` : tagToRemove.tag; - await invoke(Invokes.RemoveTagForPaths, { paths: [selectedImage.path], tag: prefixedTag }); + await commands.removeTagForPaths([selectedImage.path], prefixedTag); const newTags = currentTags.filter((t) => t.tag !== tagToRemove.tag); onTagsChanged([selectedImage.path], newTags); diff --git a/src/components/panel/right/PresetsPanel.tsx b/src/components/panel/right/PresetsPanel.tsx index b1421fdf6..28fa066a4 100644 --- a/src/components/panel/right/PresetsPanel.tsx +++ b/src/components/panel/right/PresetsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { commands } from '../../../bindings'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import { DndContext, @@ -34,7 +34,7 @@ import CreateFolderModal from '../../modals/CreateFolderModal'; import RenameFolderModal from '../../modals/RenameFolderModal'; import Button from '../../ui/Button'; import { Adjustments, INITIAL_ADJUSTMENTS } from '../../../utils/adjustments'; -import { Invokes, OPTION_SEPARATOR, Panel, Preset, SelectedImage } from '../../ui/AppProperties'; +import { OPTION_SEPARATOR, Panel, Preset, SelectedImage } from '../../ui/AppProperties'; interface DroppableFolderItemProps { children: any; @@ -393,9 +393,7 @@ export default function PresetsPanel({ try { const fullPresetAdjustments = { ...INITIAL_ADJUSTMENTS, ...preset.adjustments }; - const imageData: Uint8Array = await invoke(Invokes.GeneratePresetPreview, { - jsAdjustments: fullPresetAdjustments, - }); + const imageData: Uint8Array = await commands.generatePresetPreview(fullPresetAdjustments); const blob = new Blob([imageData], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); setPreviews((prev: Record) => { @@ -453,9 +451,7 @@ export default function PresetsPanel({ setIsGeneratingPreviews(true); try { const fullPresetAdjustments: any = { ...INITIAL_ADJUSTMENTS, ...preset.adjustments }; - const imageData: Uint8Array = await invoke(Invokes.GeneratePresetPreview, { - jsAdjustments: fullPresetAdjustments, - }); + const imageData: Uint8Array = await commands.generatePresetPreview(fullPresetAdjustments); const blob = new Blob([imageData], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 9e9c171bc..cade6133a 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -5,83 +5,6 @@ import { ToolType } from '../panel/right/Masks'; export const GLOBAL_KEYS = [' ', 'ArrowUp', 'ArrowDown', 'f', 'b', 'w']; export const OPTION_SEPARATOR = 'separator'; -export enum Invokes { - AddTagForPaths = 'add_tag_for_paths', - ApplyAdjustments = 'apply_adjustments', - ApplyAdjustmentsToPaths = 'apply_adjustments_to_paths', - ApplyAutoAdjustmentsToPaths = 'apply_auto_adjustments_to_paths', - ApplyDenoising = 'apply_denoising', - BatchExportImages = 'batch_export_images', - CalculateAutoAdjustments = 'calculate_auto_adjustments', - CancelExport = 'cancel_export', - CheckAIConnectorStatus = 'check_ai_connector_status', - ClearAllSidecars = 'clear_all_sidecars', - ClearAiTags = 'clear_ai_tags', - ClearAllTags = 'clear_all_tags', - ClearThumbnailCache = 'clear_thumbnail_cache', - CopyFiles = 'copy_files', - CreateFolder = 'create_folder', - CreateVirtualCopy = 'create_virtual_copy', - CullImages = 'cull_images', - DeleteFolder = 'delete_folder', - DuplicateFile = 'duplicate_file', - EstimateBatchExportSize = 'estimate_batch_export_size', - EstimateExportSize = 'estimate_export_size', - ExportImage = 'export_image', - FrontendLog = 'frontend_log', - GenerateAiForegroundMask = 'generate_ai_foreground_mask', - GenerateAiSkyMask = 'generate_ai_sky_mask', - GenerateAiSubjectMask = 'generate_ai_subject_mask', - GenerateFullscreenPreview = 'generate_fullscreen_preview', - GeneratePreviewForPath = 'generate_preview_for_path', - GenerateHistogram = 'generate_histogram', - GenerateMaskOverlay = 'generate_mask_overlay', - GeneratePresetPreview = 'generate_preset_preview', - GenerateThumbnailsProgressive = 'generate_thumbnails_progressive', - GenerateUncroppedPreview = 'generate_uncropped_preview', - GenerateWaveform = 'image_processing::generate_waveform', - GetFolderTree = 'get_folder_tree', - GetLogFilePath = 'get_log_file_path', - GetPinnedFolderTrees = 'get_pinned_folder_trees', - GetSupportedFileTypes = 'get_supported_file_types', - HandleExportPresetsToFile = 'handle_export_presets_to_file', - HandleImportPresetsFromFile = 'handle_import_presets_from_file', - HandleImportLegacyPresetsFromFile = 'handle_import_legacy_presets_from_file', - ImportFiles = 'import_files', - InvokeGenerativeReplace = 'invoke_generative_replace', - InvokeGenerativeReplaseWithMaskDef = 'invoke_generative_replace_with_mask_def', - ListImagesInDir = 'list_images_in_dir', - ListImagesRecursive = 'list_images_recursive', - LoadImage = 'load_image', - LoadMetadata = 'load_metadata', - LoadPresets = 'load_presets', - LoadSettings = 'load_settings', - MoveFiles = 'move_files', - ReadExifForPaths = 'read_exif_for_paths', - RemoveTagForPaths = 'remove_tag_for_paths', - RenameFiles = 'rename_files', - RenameFolder = 'rename_folder', - ResetAdjustmentsForPaths = 'reset_adjustments_for_paths', - SaveMetadataAndUpdateThumbnail = 'save_metadata_and_update_thumbnail', - SaveCollage = 'save_collage', - SaveDenoisedImage = 'save_denoised_image', - SavePanorama = 'save_panorama', - SaveHdr = 'save_hdr', - SavePresets = 'save_presets', - SaveSettings = 'save_settings', - SetColorLabelForPaths = 'set_color_label_for_paths', - ShowInFinder = 'show_in_finder', - StartBackgroundIndexing = 'start_background_indexing', - StitchPanorama = 'stitch_panorama', - MergeHdr = 'merge_hdr', - TestAIConnectorConnection = 'test_ai_connector_connection', - UpdateWindowEffect = 'update_window_effect', - FetchCommunityPresets = 'fetch_community_presets', - GenerateAllCommunityPreviews = 'generate_all_community_previews', - SaveCommunityPreset = 'save_community_preset', - SaveTempFile = 'save_temp_file', -} - export enum Panel { Adjustments = 'adjustments', Ai = 'ai', diff --git a/src/context/TaggingSubMenu.tsx b/src/context/TaggingSubMenu.tsx index 3af7c3741..869d2f273 100644 --- a/src/context/TaggingSubMenu.tsx +++ b/src/context/TaggingSubMenu.tsx @@ -1,8 +1,7 @@ import { useState, useEffect, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { X, Plus } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Invokes } from '../components/ui/AppProperties'; +import { commands } from '../bindings'; interface TaggingSubMenuProps { paths: string[]; @@ -43,7 +42,7 @@ export default function TaggingSubMenu({ if (newTagValue && !tags.some((t) => t.tag === newTagValue)) { try { const prefixedTag = `${USER_TAG_PREFIX}${newTagValue}`; - await invoke(Invokes.AddTagForPaths, { paths, tag: prefixedTag }); + await commands.addTagForPaths(paths, prefixedTag); const newTags = [...tags, { tag: newTagValue, isUser: true }].sort((a, b) => a.tag.localeCompare(b.tag)); setTags(newTags); onTagsChanged(paths, newTags); @@ -57,7 +56,7 @@ export default function TaggingSubMenu({ const handleRemoveTag = async (tagToRemove: { tag: string; isUser: boolean }) => { try { const prefixedTag = tagToRemove.isUser ? `${USER_TAG_PREFIX}${tagToRemove.tag}` : tagToRemove.tag; - await invoke(Invokes.RemoveTagForPaths, { paths, tag: prefixedTag }); + await commands.removeTagForPaths(paths, prefixedTag); const newTags = tags.filter((t) => t.tag !== tagToRemove.tag); setTags(newTags); onTagsChanged(paths, newTags); diff --git a/src/hooks/usePresets.ts b/src/hooks/usePresets.ts index 1b334eecc..17531e968 100644 --- a/src/hooks/usePresets.ts +++ b/src/hooks/usePresets.ts @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import debounce from 'lodash.debounce'; +import { commands } from '../bindings'; import { Adjustments, COPYABLE_ADJUSTMENT_KEYS } from '../utils/adjustments'; -import { Folder, Invokes, Preset } from '../components/ui/AppProperties'; +import { Folder, Preset } from '../components/ui/AppProperties'; export enum PresetListType { Folder = 'folder', @@ -30,7 +30,7 @@ export function usePresets(currentAdjustments: Adjustments) { const loadPresets = useCallback(async () => { setIsLoading(true); try { - const loadedPresets: Array = await invoke(Invokes.LoadPresets); + const loadedPresets: Array = await commands.loadPresets(); console.log(loadedPresets); setPresets(loadedPresets); } catch (error) { @@ -44,7 +44,7 @@ export function usePresets(currentAdjustments: Adjustments) { const savePresetsToBackend = useCallback( debounce((presetsToSave: Array) => { console.log(presetsToSave); - invoke(Invokes.SavePresets, { presets: presetsToSave }).catch((err) => + commands.savePresets(presetsToSave).catch((err) => console.error('Failed to save presets:', err), ); }, 500), @@ -392,7 +392,7 @@ export function usePresets(currentAdjustments: Adjustments) { async (filePath: string) => { setIsLoading(true); try { - const updatedPresetList: Array = await invoke(Invokes.HandleImportPresetsFromFile, { filePath }); + const updatedPresetList: Array = await commands.handleImportPresetsFromFile(filePath); setPresets(updatedPresetList); } catch (error) { console.error('Failed to import presets from file:', error); @@ -408,9 +408,7 @@ export function usePresets(currentAdjustments: Adjustments) { async (filePath: string) => { setIsLoading(true); try { - const updatedPresetList: Array = await invoke(Invokes.HandleImportLegacyPresetsFromFile, { - filePath, - }); + const updatedPresetList: Array = await commands.handleImportLegacyPresetsFromFile(filePath); setPresets(updatedPresetList); } catch (error) { console.error('Failed to import legacy presets from file:', error); @@ -424,7 +422,7 @@ export function usePresets(currentAdjustments: Adjustments) { const exportPresetsToFile = useCallback(async (presetsToExport: Array, filePath: string) => { try { - await invoke(Invokes.HandleExportPresetsToFile, { presetsToExport, filePath }); + await commands.handleExportPresetsToFile(presetsToExport, filePath); } catch (error) { console.error('Failed to export presets to file:', error); throw error; diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index 37674b6d4..6abaa9220 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { ImageFile, Invokes, Progress } from '../components/ui/AppProperties'; +import { commands } from '../bindings'; +import { ImageFile, Progress } from '../components/ui/AppProperties'; export function useThumbnails(imageList: Array, setThumbnails: any) { const [loading, setLoading] = useState(false); @@ -61,7 +61,7 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { }); try { - await invoke(Invokes.GenerateThumbnailsProgressive, { paths: imagePaths }); + await commands.generateThumbnailsProgressive(imagePaths); } catch (error) { console.error('Failed to invoke thumbnail generation:', error); setLoading(false); @@ -81,4 +81,4 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { }, [imageList, setThumbnails]); return { loading, progress }; -} \ No newline at end of file +} diff --git a/src/utils/frontendLogBridge.ts b/src/utils/frontendLogBridge.ts index 03d067851..36735f7ea 100644 --- a/src/utils/frontendLogBridge.ts +++ b/src/utils/frontendLogBridge.ts @@ -1,5 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; -import { Invokes } from '../components/ui/AppProperties'; +import { commands } from '../bindings'; type FrontendLogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -242,10 +241,10 @@ function sendToBackend(level: FrontendLogLevel, args: unknown[]): void { return; } - void invoke(Invokes.FrontendLog, { + void commands.frontendLog( level, message, - }).catch(() => { + ).catch(() => { // Prevent recursion if backend logging channel is unavailable. }); } From efc674df6a8d603dd72ce3015969ac18c2d3834e Mon Sep 17 00:00:00 2001 From: Yann Birba Date: Mon, 2 Mar 2026 23:13:53 +0100 Subject: [PATCH 2/3] Complete specta IPC migration for App events and commands --- scripts/check-ipc-contract.sh | 38 +++- src-tauri/src/main.rs | 153 ++++++++++++- src/App.tsx | 394 ++++++++++++++-------------------- src/bindings.ts | 115 +++++++++- src/hooks/useThumbnails.tsx | 7 +- 5 files changed, 459 insertions(+), 248 deletions(-) diff --git a/scripts/check-ipc-contract.sh b/scripts/check-ipc-contract.sh index bc7cef864..92597a0a4 100644 --- a/scripts/check-ipc-contract.sh +++ b/scripts/check-ipc-contract.sh @@ -10,6 +10,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT backend_cmds="$TMP_DIR/backend_cmds.txt" front_enum_cmds="$TMP_DIR/front_enum_cmds.txt" front_literal_cmds="$TMP_DIR/front_literal_cmds.txt" +front_event_methods="$TMP_DIR/front_event_methods.txt" backend_events="$TMP_DIR/backend_events.txt" front_events="$TMP_DIR/front_events.txt" @@ -27,17 +28,38 @@ else : > "$front_enum_cmds" fi -rg -n "invoke\\(\\s*['\"][^'\"]+['\"]" src --glob '!src-tauri/**' -o \ - | sed -E "s/.*invoke\\(\\s*['\"]([^'\"]+)['\"].*/\\1/" \ - | sort -u > "$front_literal_cmds" +if rg -q "invoke\\(\\s*['\"][^'\"]+['\"]" src --glob '!src-tauri/**'; then + rg -n "invoke\\(\\s*['\"][^'\"]+['\"]" src --glob '!src-tauri/**' -o \ + | sed -E "s/.*invoke\\(\\s*['\"]([^'\"]+)['\"].*/\\1/" \ + | sort -u > "$front_literal_cmds" +else + : > "$front_literal_cmds" +fi -rg --files src-tauri/src \ - | xargs perl -0777 -ne 'while (/\.emit(?:_to|_filter)?\(\s*"([^"]+)"/g) { print "$1\n"; }' \ - | sort -u > "$backend_events" +if [[ -f src/bindings.ts ]]; then + sed -n '/}>({/,/^})/p' src/bindings.ts \ + | sed -nE 's/^[[:space:]]*[A-Za-z0-9_]+:[[:space:]]*"([^"]+)".*/\1/p' \ + | sort -u > "$backend_events" +else + rg --files src-tauri/src \ + | xargs perl -0777 -ne 'while (/\.emit(?:_to|_filter)?\(\s*"([^"]+)"/g) { print "$1\n"; }' \ + | sort -u > "$backend_events" +fi rg --files src --glob '!src-tauri/**' \ - | xargs perl -ne 'while (/(?:listen|once)\(\s*["\x27]([^"\x27]+)["\x27]/g) { print "$1\n"; }' \ - | sort -u > "$front_events" + | xargs perl -ne 'while (/(?:listen|once)\(\s*["\x27]([^"\x27]+)["\x27]/g) { print "$1\n"; } while (/events\.([A-Za-z0-9_]+)\.(?:listen|once)\s*\(/g) { print "__method__:$1\n"; }' \ + | sort -u > "$front_event_methods" + +grep -v '^__method__:' "$front_event_methods" > "$front_events" || true + +grep '^__method__:' "$front_event_methods" | sed 's/^__method__://' | while read -r method; do + mapped="$(sed -nE "s/^[[:space:]]*${method}:[[:space:]]*\"([^\"]+)\".*/\\1/p" src/bindings.ts | head -n1)" + if [[ -n "$mapped" ]]; then + echo "$mapped" >> "$front_events" + fi +done + +sort -u "$front_events" -o "$front_events" if [[ -s "$front_enum_cmds" ]]; then echo "=== Commands in frontend enum but missing in backend ===" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d01a514b..864b76d2a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -143,6 +143,118 @@ struct AiConnectorStatusUpdate { connected: bool, } +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PreviewUpdateFinal(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PreviewUpdateUncropped(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct HistogramUpdate(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct OpenWithFile(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct WaveformUpdate(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ThumbnailGenerated(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct AiModelDownloadStart(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct AiModelDownloadFinish(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct IndexingStarted(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct IndexingProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct IndexingError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct IndexingFinished(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct BatchExportProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ExportComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ExportCompleteWithErrors(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ExportError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ExportCancelled(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ImportStart(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ImportProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ImportComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ImportError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ThumbnailGenerationError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ThumbnailProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct ThumbnailGenerationComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct DenoiseProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct DenoiseComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct DenoiseError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PanoramaProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PanoramaComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PanoramaError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct PanoramaWarning(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct HdrProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct HdrComplete(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct HdrError(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct CullingStart(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct CullingProgress(serde_json::Value); +#[derive(Serialize, Deserialize, Clone, Debug, specta::Type, tauri_specta::Event)] +#[serde(transparent)] +struct CullingComplete(serde_json::Value); + pub struct AppState { window_setup_complete: AtomicBool, original_image: Mutex>, @@ -3781,7 +3893,46 @@ fn main() { negative_conversion::convert_negative_full, negative_conversion::save_converted_negative, ]) - .events(collect_events![AiConnectorStatusUpdate]) + .events(collect_events![ + AiConnectorStatusUpdate, + PreviewUpdateFinal, + PreviewUpdateUncropped, + HistogramUpdate, + OpenWithFile, + WaveformUpdate, + ThumbnailGenerated, + AiModelDownloadStart, + AiModelDownloadFinish, + IndexingStarted, + IndexingProgress, + IndexingError, + IndexingFinished, + BatchExportProgress, + ExportComplete, + ExportCompleteWithErrors, + ExportError, + ExportCancelled, + ImportStart, + ImportProgress, + ImportComplete, + ImportError, + ThumbnailGenerationError, + ThumbnailProgress, + ThumbnailGenerationComplete, + DenoiseProgress, + DenoiseComplete, + DenoiseError, + PanoramaProgress, + PanoramaComplete, + PanoramaError, + PanoramaWarning, + HdrProgress, + HdrComplete, + HdrError, + CullingStart, + CullingProgress, + CullingComplete + ]) .error_handling(ErrorHandlingMode::Throw); #[cfg(debug_assertions)] diff --git a/src/App.tsx b/src/App.tsx index 2e6a90d8b..bbb579f6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { homeDir } from '@tauri-apps/api/path'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -120,62 +119,6 @@ import { commands, events } from './bindings'; const CLERK_PUBLISHABLE_KEY = 'pk_test_YnJpZWYtc2Vhc25haWwtMTIuY2xlcmsuYWNjb3VudHMuZGV2JA'; // local dev key -type InvokeArgs = Record; - -function invoke(command: string, args: InvokeArgs = {}): Promise { - switch (command) { - case 'apply_adjustments': return commands.applyAdjustments(args.jsAdjustments, args.isInteractive) as Promise; - case 'apply_adjustments_to_paths': return commands.applyAdjustmentsToPaths(args.paths, args.adjustments) as Promise; - case 'apply_auto_adjustments_to_paths': return commands.applyAutoAdjustmentsToPaths(args.paths) as Promise; - case 'apply_denoising': return commands.applyDenoising(args.path, args.intensity) as Promise; - case 'calculate_auto_adjustments': return commands.calculateAutoAdjustments() as Promise; - case 'cancel_thumbnail_generation': return commands.cancelThumbnailGeneration() as Promise; - case 'copy_files': return commands.copyFiles(args.sourcePaths, args.destinationFolder) as Promise; - case 'create_folder': return commands.createFolder(args.path) as Promise; - case 'create_virtual_copy': return commands.createVirtualCopy(args.sourceVirtualPath) as Promise; - case 'delete_files_from_disk': return commands.deleteFilesFromDisk(args.paths) as Promise; - case 'delete_files_with_associated': return commands.deleteFilesWithAssociated(args.paths) as Promise; - case 'delete_folder': return commands.deleteFolder(args.path) as Promise; - case 'duplicate_file': return commands.duplicateFile(args.path) as Promise; - case 'frontend_ready': return commands.frontendReady() as Promise; - case 'generate_ai_foreground_mask': return commands.generateAiForegroundMask(args.jsAdjustments, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; - case 'generate_ai_sky_mask': return commands.generateAiSkyMask(args.jsAdjustments, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; - case 'generate_ai_subject_mask': return commands.generateAiSubjectMask(args.jsAdjustments, args.path, args.startPoint, args.endPoint, args.rotation, args.flipHorizontal, args.flipVertical, args.orientationSteps) as Promise; - case 'generate_fullscreen_preview': return commands.generateFullscreenPreview(args.jsAdjustments) as Promise; - case 'generate_original_transformed_preview': return commands.generateOriginalTransformedPreview(args.jsAdjustments) as Promise; - case 'generate_uncropped_preview': return commands.generateUncroppedPreview(args.jsAdjustments) as Promise; - case 'generate_waveform': return commands.generateWaveform() as Promise; - case 'get_folder_tree': return commands.getFolderTree(args.path) as Promise; - case 'get_pinned_folder_trees': return commands.getPinnedFolderTrees(args.paths) as Promise; - case 'get_supported_file_types': return commands.getSupportedFileTypes() as Promise; - case 'import_files': return commands.importFiles(args.sourcePaths, args.destinationFolder, args.settings) as Promise; - case 'invoke_generative_replace_with_mask_def': return commands.invokeGenerativeReplaceWithMaskDef(args.path, args.patchDefinition, args.currentAdjustments, args.useFastInpaint, args.token) as Promise; - case 'list_images_in_dir': return commands.listImagesInDir(args.path) as Promise; - case 'list_images_recursive': return commands.listImagesRecursive(args.path) as Promise; - case 'load_and_parse_lut': return commands.loadAndParseLut(args.path) as Promise; - case 'load_image': return commands.loadImage(args.path) as Promise; - case 'load_metadata': return commands.loadMetadata(args.path) as Promise; - case 'load_settings': return commands.loadSettings() as Promise; - case 'merge_hdr': return commands.mergeHdr(args.paths) as Promise; - case 'move_files': return commands.moveFiles(args.sourcePaths, args.destinationFolder) as Promise; - case 'read_exif_for_paths': return commands.readExifForPaths(args.paths) as Promise; - case 'rename_files': return commands.renameFiles(args.paths, args.nameTemplate) as Promise; - case 'rename_folder': return commands.renameFolder(args.path, args.newName) as Promise; - case 'reset_adjustments_for_paths': return commands.resetAdjustmentsForPaths(args.paths) as Promise; - case 'save_collage': return commands.saveCollage(args.base64Data, args.firstPathStr) as Promise; - case 'save_hdr': return commands.saveHdr(args.firstPathStr) as Promise; - case 'save_metadata_and_update_thumbnail': return commands.saveMetadataAndUpdateThumbnail(args.path, args.adjustments) as Promise; - case 'save_panorama': return commands.savePanorama(args.firstPathStr) as Promise; - case 'save_settings': return commands.saveSettings(args.settings) as Promise; - case 'set_color_label_for_paths': return commands.setColorLabelForPaths(args.paths, args.color) as Promise; - case 'show_in_finder': return commands.showInFinder(args.path) as Promise; - case 'start_background_indexing': return commands.startBackgroundIndexing(args.folderPath) as Promise; - case 'stitch_panorama': return commands.stitchPanorama(args.paths) as Promise; - case 'update_window_effect': return commands.updateWindowEffect(args.theme) as Promise; - default: return Promise.reject(new Error(`Unsupported invoke command: ${command}`)); - } -} - interface CollapsibleSectionsState { basic: boolean; color: boolean; @@ -705,9 +648,7 @@ function App() { const generate = async () => { if (showOriginal && selectedImage?.path && !transformedOriginalUrl) { try { - const imageData: Uint8Array = await invoke('generate_original_transformed_preview', { - jsAdjustments: adjustments, - }); + const imageData: Uint8Array = await commands.generateOriginalTransformedPreview(adjustments,); if (isEffectActive) { const blob = new Blob([imageData], { type: 'image/jpeg' }); objectUrl = URL.createObjectURL(blob); @@ -784,12 +725,13 @@ function App() { setIsGeneratingAi(true); try { - const newPatchDataJson: any = await invoke('invoke_generative_replace_with_mask_def', { - currentAdjustments: adjustments, - patchDefinition: patchDefinition, - path: selectedImage.path, - useFastInpaint: useFastInpaint, - }); + const newPatchDataJson: any = await commands.invokeGenerativeReplaceWithMaskDef( + selectedImage.path, + patchDefinition, + adjustments, + useFastInpaint, + null, + ); const newPatchData = JSON.parse(newPatchDataJson); patchesSentToBackend.current.delete(patchId); @@ -870,16 +812,16 @@ function App() { lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newMaskParams: any = await invoke('generate_ai_subject_mask', { - jsAdjustments: transformAdjustments, - endPoint: [endPoint.x, endPoint.y], - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - path: selectedImage.path, - rotation: adjustments.rotation, - startPoint: [startPoint.x, startPoint.y], - }); + const newMaskParams: any = await commands.generateAiSubjectMask( + transformAdjustments, + selectedImage.path, + [startPoint.x, startPoint.y], + [endPoint.x, endPoint.y], + adjustments.rotation, + adjustments.flipHorizontal, + adjustments.flipVertical, + adjustments.orientationSteps, + ); const subMaskToUpdate = adjustments.aiPatches ?.find((p: AiPatch) => p.id === patchId) @@ -900,12 +842,13 @@ function App() { }; const patchDefinitionForBackend = updatedAdjustmentsForBackend.aiPatches.find((p: AiPatch) => p.id === patchId); - const newPatchDataJson: any = await invoke('invoke_generative_replace_with_mask_def', { - currentAdjustments: updatedAdjustmentsForBackend, - patchDefinition: { ...patchDefinitionForBackend, prompt: '' }, - path: selectedImage.path, - useFastInpaint: true, - }); + const newPatchDataJson: any = await commands.invokeGenerativeReplaceWithMaskDef( + selectedImage.path, + { ...patchDefinitionForBackend, prompt: '' }, + updatedAdjustmentsForBackend, + true, + null, + ); const newPatchData = JSON.parse(newPatchDataJson); if (!newPatchData?.color || !newPatchData?.mask) { @@ -1015,16 +958,16 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke('generate_ai_subject_mask', { - jsAdjustments: transformAdjustments, - endPoint: [endPoint.x, endPoint.y], - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - path: selectedImage.path, - rotation: adjustments.rotation, - startPoint: [startPoint.x, startPoint.y], - }); + const newParameters = await commands.generateAiSubjectMask( + transformAdjustments, + selectedImage.path, + [startPoint.x, startPoint.y], + [endPoint.x, endPoint.y], + adjustments.rotation, + adjustments.flipHorizontal, + adjustments.flipVertical, + adjustments.orientationSteps, + ); const subMask = adjustments.aiPatches ?.flatMap((p: AiPatch) => p.subMasks) @@ -1067,13 +1010,13 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke('generate_ai_foreground_mask', { - jsAdjustments: transformAdjustments, - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - rotation: adjustments.rotation, - }); + const newParameters = await commands.generateAiForegroundMask( + transformAdjustments, + adjustments.rotation, + adjustments.flipHorizontal, + adjustments.flipVertical, + adjustments.orientationSteps, + ); const subMask = adjustments.aiPatches ?.flatMap((p: AiPatch) => p.subMasks) @@ -1116,13 +1059,13 @@ function App() { lensTcaEnabled: adjustments.lensTcaEnabled, lensVignetteEnabled: adjustments.lensVignetteEnabled, }; - const newParameters = await invoke('generate_ai_sky_mask', { - jsAdjustments: transformAdjustments, - flipHorizontal: adjustments.flipHorizontal, - flipVertical: adjustments.flipVertical, - orientationSteps: adjustments.orientationSteps, - rotation: adjustments.rotation, - }); + const newParameters = await commands.generateAiSkyMask( + transformAdjustments, + adjustments.rotation, + adjustments.flipHorizontal, + adjustments.flipVertical, + adjustments.orientationSteps, + ); const subMask = adjustments.aiPatches ?.flatMap((p: AiPatch) => p.subMasks) @@ -1397,10 +1340,7 @@ function App() { } try { - await invoke('apply_adjustments', { - jsAdjustments: payload, - isInteractive: dragging - }); + await commands.applyAdjustments(payload, dragging); } catch (err) { console.error('Failed to invoke apply_adjustments:', err); } @@ -1420,7 +1360,7 @@ function App() { if (!selectedImage?.isReady) { return; } - invoke('generate_uncropped_preview', { jsAdjustments: currentAdjustments }).catch((err) => + commands.generateUncroppedPreview(currentAdjustments).catch((err) => console.error('Failed to generate uncropped preview:', err), ); }, 50), @@ -1429,7 +1369,7 @@ function App() { const debouncedSave = useCallback( debounce((path, adjustmentsToSave) => { - invoke('save_metadata_and_update_thumbnail', { path, adjustments: adjustmentsToSave }).catch((err) => { + commands.saveMetadataAndUpdateThumbnail(path, adjustmentsToSave).catch((err) => { console.error('Auto-save failed:', err); setError(`Failed to save changes: ${err}`); }); @@ -1479,7 +1419,7 @@ function App() { const handleLutSelect = useCallback( async (path: string) => { try { - const result: LutData = await invoke('load_and_parse_lut', { path }); + const result: LutData = await commands.loadAndParseLut(path); const name = path.split(/[\\/]/).pop() || 'LUT'; setAdjustments((prev: Partial) => ({ ...prev, @@ -1529,7 +1469,7 @@ function App() { const { searchCriteria: _searchCriteria, ...settingsToSave } = newSettings as any; setAppSettings(newSettings); - invoke('save_settings', { settings: settingsToSave }).catch((err) => { + commands.saveSettings(settingsToSave).catch((err) => { console.error('Failed to save settings:', err); }); }, @@ -1537,7 +1477,7 @@ function App() { ); useEffect(() => { - invoke('load_settings') + commands.loadSettings() .then(async (settings: any) => { if ( !settings.copyPasteSettings || @@ -1579,7 +1519,7 @@ function App() { } if (settings?.pinnedFolders && settings.pinnedFolders.length > 0) { try { - const trees = await invoke('get_pinned_folder_trees', { paths: settings.pinnedFolders }); + const trees = await commands.getPinnedFolderTrees(settings.pinnedFolders); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to load pinned folder trees:', err); @@ -1598,12 +1538,15 @@ function App() { preloadedDataRef.current = { rootPath: root, currentPath: currentPath, - tree: invoke('get_folder_tree', { path: root }), - images: invoke(command, { path: currentPath }) + tree: commands.getFolderTree(root), + images: + command === 'list_images_recursive' + ? commands.listImagesRecursive(currentPath) + : commands.listImagesInDir(currentPath), }; } - invoke('frontend_ready').catch(e => console.error("Failed to notify backend of readiness:", e)); + commands.frontendReady().catch(e => console.error("Failed to notify backend of readiness:", e)); }) .catch((err) => { console.error('Failed to load settings:', err); @@ -1655,7 +1598,7 @@ function App() { }, [libraryViewMode, appSettings, handleSettingsChange]); useEffect(() => { - invoke('get_supported_file_types') + commands.getSupportedFileTypes() .then((types: any) => setSupportedTypes(types)) .catch((err) => console.error('Failed to load supported file types:', err)); }, []); @@ -1714,7 +1657,7 @@ function App() { }); const isLight = [Theme.Light, Theme.Snow, Theme.Arctic].includes(effectThemeForWindow); - invoke('update_window_effect', { theme: isLight ? Theme.Light : Theme.Dark }); + commands.updateWindowEffect(isLight ? Theme.Light : Theme.Dark); }, [theme, adaptivePalette]); useEffect(() => { @@ -1732,7 +1675,7 @@ function App() { const refreshAllFolderTrees = useCallback(async () => { if (rootPath) { try { - const treeData = await invoke('get_folder_tree', { path: rootPath }); + const treeData = await commands.getFolderTree(rootPath); setFolderTree(treeData); } catch (err) { console.error('Failed to refresh main folder tree:', err); @@ -1743,7 +1686,7 @@ function App() { const currentPins = appSettings?.pinnedFolders || []; if (currentPins.length > 0) { try { - const trees = await invoke('get_pinned_folder_trees', { paths: currentPins }); + const trees = await commands.getPinnedFolderTrees(currentPins); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to refresh pinned folder trees:', err); @@ -1768,7 +1711,7 @@ function App() { handleSettingsChange({ ...appSettings, pinnedFolders: newPins }); try { - const trees = await invoke('get_pinned_folder_trees', { paths: newPins }); + const trees = await commands.getPinnedFolderTrees(newPins); setPinnedFolderTrees(trees); } catch (err) { console.error('Failed to refresh pinned folders:', err); @@ -1784,7 +1727,7 @@ function App() { const handleSelectSubfolder = useCallback( async (path: string | null, isNewRoot = false, preloadedImages?: ImageFile[]) => { - await invoke('cancel_thumbnail_generation'); + await commands.cancelThumbnailGeneration(); setIsViewLoading(true); setSearchCriteria({ tags: [], text: '', mode: 'OR' }); setLibraryScrollTop(0); @@ -1829,7 +1772,7 @@ function App() { setIsTreeLoading(true); handleSettingsChange({ ...appSettings, lastRootPath: path } as AppSettings); try { - const treeData = await invoke('get_folder_tree', { path }); + const treeData = await commands.getFolderTree(path); setFolderTree(treeData); } catch (err) { console.error('Failed to load folder tree:', err); @@ -1857,7 +1800,10 @@ function App() { if (preloadedImages) { files = preloadedImages; } else { - files = await invoke(command, { path }); + files = + command === 'list_images_recursive' + ? await commands.listImagesRecursive(path) + : await commands.listImagesInDir(path); } const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; @@ -1868,7 +1814,7 @@ function App() { const paths = files.map((f: ImageFile) => f.path); if (isExifSortActive) { - const exifDataMap: Record = await invoke('read_exif_for_paths', { paths }); + const exifDataMap: Record = await commands.readExifForPaths(paths); const finalImageList = files.map((image) => ({ ...image, exif: exifDataMap[image.path] || image.exif || null, @@ -1876,7 +1822,7 @@ function App() { setImageList(finalImageList); } else { setImageList(files); - invoke('read_exif_for_paths', { paths }) + commands.readExifForPaths(paths) .then((exifDataMap: any) => { setImageList((currentImageList) => currentImageList.map((image) => ({ @@ -1893,7 +1839,7 @@ function App() { setImageList(files); } - invoke('start_background_indexing', { folderPath: path }).catch((err) => { + commands.startBackgroundIndexing(path).catch((err) => { console.error('Failed to start background indexing:', err); }); } catch (err) { @@ -1925,7 +1871,10 @@ function App() { const command = libraryViewMode === LibraryViewMode.Recursive ? 'list_images_recursive' : 'list_images_in_dir'; - const files: ImageFile[] = await invoke(command, { path: currentFolderPath }); + const files: ImageFile[] = + command === 'list_images_recursive' + ? await commands.listImagesRecursive(currentFolderPath) + : await commands.listImagesInDir(currentFolderPath); const exifSortKeys = ['date_taken', 'iso', 'shutter_speed', 'aperture', 'focal_length']; const isExifSortActive = exifSortKeys.includes(sortCriteria.key); const shouldReadExif = appSettings?.enableExifReading ?? false; @@ -1934,7 +1883,7 @@ function App() { if (shouldReadExif && files.length > 0 && isExifSortActive) { const paths = files.map((f: ImageFile) => f.path); - freshExifData = await invoke('read_exif_for_paths', { paths }); + freshExifData = await commands.readExifForPaths(paths); } setImageList((prevList) => { @@ -1956,7 +1905,7 @@ function App() { if (shouldReadExif && files.length > 0 && !isExifSortActive) { const paths = files.map((f: ImageFile) => f.path); - invoke('read_exif_for_paths', { paths }) + commands.readExifForPaths(paths) .then((exifDataMap: any) => { setImageList((currentImageList) => currentImageList.map((image) => { @@ -2137,7 +2086,11 @@ function App() { try { const command = options.includeAssociated ? 'delete_files_with_associated' : 'delete_files_from_disk'; - await invoke(command, { paths: pathsToDelete }); + if (command === 'delete_files_with_associated') { + await commands.deleteFilesWithAssociated(pathsToDelete); + } else { + await commands.deleteFilesFromDisk(pathsToDelete); + } await refreshImageList(); @@ -2274,7 +2227,7 @@ function App() { setAdjustments(newAdjustments); } - invoke('apply_adjustments_to_paths', { paths: pathsToUpdate, adjustments: adjustmentsToApply }).catch( + commands.applyAdjustmentsToPaths(pathsToUpdate, adjustmentsToApply).catch( (err) => { console.error('Failed to paste adjustments to multiple images:', err); setError(`Failed to paste adjustments: ${err}`); @@ -2290,7 +2243,7 @@ function App() { return; } try { - const autoAdjustments: Adjustments = await invoke('calculate_auto_adjustments'); + const autoAdjustments: Adjustments = await commands.calculateAutoAdjustments(); setAdjustments((prev: Adjustments) => { const newAdjustments = { ...prev, ...autoAdjustments }; newAdjustments.sectionVisibility = { @@ -2339,7 +2292,7 @@ function App() { setLibraryActiveAdjustments((prev) => ({ ...prev, rating: finalRating })); } - invoke('apply_adjustments_to_paths', { paths: pathsToRate, adjustments: { rating: finalRating } }).catch( + commands.applyAdjustmentsToPaths(pathsToRate, { rating: finalRating }).catch( (err) => { console.error('Failed to apply rating to paths:', err); setError(`Failed to apply rating: ${err}`); @@ -2374,7 +2327,7 @@ function App() { } const finalColor = color !== null && color === currentColor ? null : color; try { - await invoke('set_color_label_for_paths', { paths: pathsToUpdate, color: finalColor }); + await commands.setColorLabelForPaths(pathsToUpdate, finalColor); setImageList((prevList: Array) => prevList.map((image: ImageFile) => { @@ -2441,9 +2394,9 @@ function App() { } try { if (mode === 'copy') - await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await commands.copyFiles(copiedFilePaths, currentFolderPath); else { - await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await commands.moveFiles(copiedFilePaths, currentFolderPath); setCopiedFilePaths([]); } await refreshImageList(); @@ -2465,9 +2418,7 @@ function App() { const request = { cancelled: false }; fullResRequestRef.current = request; - invoke('generate_fullscreen_preview', { - jsAdjustments: currentAdjustments, - }) + commands.generateFullscreenPreview(currentAdjustments,) .then(() => { fullResCacheKeyRef.current = key; if (!request.cancelled) { @@ -2671,7 +2622,7 @@ function App() { useEffect(() => { let isEffectActive = true; const listeners = [ - listen('preview-update-final', (event: any) => { + events.previewUpdateFinal.listen( (event: any) => { if (isEffectActive) { const { path, data } = event.payload; if (path !== selectedImagePathRef.current) return; @@ -2681,7 +2632,7 @@ function App() { setFinalPreviewUrl(url); } }), - listen('preview-update-uncropped', (event: any) => { + events.previewUpdateUncropped.listen( (event: any) => { if (isEffectActive) { const imageData = new Uint8Array(event.payload); const blob = new Blob([imageData], { type: 'image/jpeg' }); @@ -2689,22 +2640,22 @@ function App() { setUncroppedAdjustedPreviewUrl(url); } }), - listen('histogram-update', (event: any) => { + events.histogramUpdate.listen( (event: any) => { if (isEffectActive) { setHistogram(event.payload); } }), - listen('open-with-file', (event: any) => { + events.openWithFile.listen( (event: any) => { if (isEffectActive) { setInitialFileToOpen(event.payload as string); } }), - listen('waveform-update', (event: any) => { + events.waveformUpdate.listen( (event: any) => { if (isEffectActive) { setWaveform(event.payload); } }), - listen('thumbnail-generated', (event: any) => { + events.thumbnailGenerated.listen( (event: any) => { if (isEffectActive) { const { path, data, rating } = event.payload; if (data) { @@ -2715,42 +2666,42 @@ function App() { } } }), - listen('ai-model-download-start', (event: any) => { + events.aiModelDownloadStart.listen( (event: any) => { if (isEffectActive) { setAiModelDownloadStatus(event.payload); } }), - listen('ai-model-download-finish', () => { + events.aiModelDownloadFinish.listen( () => { if (isEffectActive) { setAiModelDownloadStatus(null); } }), - listen('indexing-started', () => { + events.indexingStarted.listen( () => { if (isEffectActive) { setIsIndexing(true); setIndexingProgress({ current: 0, total: 0 }); } }), - listen('indexing-progress', (event: any) => { + events.indexingProgress.listen( (event: any) => { if (isEffectActive) { setIndexingProgress(event.payload); } }), - listen('indexing-error', (event: any) => { + events.indexingError.listen( (event: any) => { if (isEffectActive) { setIsIndexing(false); setIndexingProgress({ current: 0, total: 0 }); setError(typeof event.payload === 'string' ? event.payload : 'Indexing error'); } }), - listen('indexing-finished', () => { + events.indexingFinished.listen( () => { if (isEffectActive) { setIsIndexing(false); setIndexingProgress({ current: 0, total: 0 }); if (currentFolderPathRef.current) { const refreshImageList = async () => { try { - const list: ImageFile[] = await invoke('list_images_in_dir', { path: currentFolderPathRef.current }); + const list: ImageFile[] = await commands.listImagesInDir(currentFolderPathRef.current); if (Array.isArray(list)) { setImageList(list); } @@ -2762,17 +2713,17 @@ function App() { } } }), - listen('batch-export-progress', (event: any) => { + events.batchExportProgress.listen( (event: any) => { if (isEffectActive) { setExportState((prev: ExportState) => ({ ...prev, progress: event.payload })); } }), - listen('export-complete', () => { + events.exportComplete.listen( () => { if (isEffectActive) { setExportState((prev: ExportState) => ({ ...prev, status: Status.Success })); } }), - listen('export-complete-with-errors', (event: any) => { + events.exportCompleteWithErrors.listen( (event: any) => { if (isEffectActive) { const errors = event.payload?.errors ?? 0; const total = event.payload?.total ?? 0; @@ -2783,7 +2734,7 @@ function App() { })); } }), - listen('export-error', (event) => { + events.exportError.listen( (event) => { if (isEffectActive) { setExportState((prev: ExportState) => ({ ...prev, @@ -2792,12 +2743,12 @@ function App() { })); } }), - listen('export-cancelled', () => { + events.exportCancelled.listen( () => { if (isEffectActive) { setExportState((prev: ExportState) => ({ ...prev, status: Status.Cancelled })); } }), - listen('import-start', (event: any) => { + events.importStart.listen( (event: any) => { if (isEffectActive) { setImportState({ errorMessage: '', @@ -2807,7 +2758,7 @@ function App() { }); } }), - listen('import-progress', (event: any) => { + events.importProgress.listen( (event: any) => { if (isEffectActive) { setImportState((prev: ImportState) => ({ ...prev, @@ -2816,7 +2767,7 @@ function App() { })); } }), - listen('import-complete', () => { + events.importComplete.listen( () => { if (isEffectActive) { setImportState((prev: ImportState) => ({ ...prev, status: Status.Success })); refreshAllFolderTrees(); @@ -2825,7 +2776,7 @@ function App() { } } }), - listen('import-error', (event) => { + events.importError.listen( (event) => { if (isEffectActive) { setImportState((prev: ImportState) => ({ ...prev, @@ -2834,7 +2785,7 @@ function App() { })); } }), - listen('thumbnail-generation-error', (event: any) => { + events.thumbnailGenerationError.listen( (event: any) => { if (isEffectActive) { const payload = event.payload; if (payload?.reason) { @@ -2842,12 +2793,12 @@ function App() { } } }), - listen('denoise-progress', (event: any) => { + events.denoiseProgress.listen( (event: any) => { if (isEffectActive) { setDenoiseModalState((prev) => ({ ...prev, progressMessage: event.payload as string })); } }), - listen('denoise-complete', (event: any) => { + events.denoiseComplete.listen( (event: any) => { if (isEffectActive) { const payload = event.payload; const isObject = typeof payload === 'object' && payload !== null; @@ -2861,7 +2812,7 @@ function App() { })); } }), - listen('denoise-error', (event: any) => { + events.denoiseError.listen( (event: any) => { if (isEffectActive) { setDenoiseModalState((prev) => ({ ...prev, @@ -2901,7 +2852,7 @@ function App() { useEffect(() => { if (libraryActivePath) { - invoke('load_metadata', { path: libraryActivePath }) + commands.loadMetadata(libraryActivePath) .then((metadata: any) => { if (metadata.adjustments && !metadata.adjustments.is_null) { const normalized: Adjustments = normalizeLoadedAdjustments(metadata.adjustments); @@ -2922,7 +2873,7 @@ function App() { useEffect(() => { let isEffectActive = true; - const unlistenProgress = listen('panorama-progress', (event: any) => { + const unlistenProgress = events.panoramaProgress.listen( (event: any) => { if (isEffectActive) { setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, @@ -2934,7 +2885,7 @@ function App() { } }); - const unlistenComplete = listen('panorama-complete', (event: any) => { + const unlistenComplete = events.panoramaComplete.listen( (event: any) => { if (isEffectActive) { const { base64 } = event.payload; setPanoramaModalState((prev: PanoramaModalState) => ({ @@ -2946,7 +2897,7 @@ function App() { } }); - const unlistenError = listen('panorama-error', (event: any) => { + const unlistenError = events.panoramaError.listen( (event: any) => { if (isEffectActive) { setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, @@ -2957,7 +2908,7 @@ function App() { } }); - const unlistenWarning = listen('panorama-warning', (event: any) => { + const unlistenWarning = events.panoramaWarning.listen( (event: any) => { if (isEffectActive) { const warning = String(event.payload); setPanoramaModalState((prev: PanoramaModalState) => ({ @@ -2979,7 +2930,7 @@ function App() { useEffect(() => { let isEffectActive = true; - const unlistenProgress = listen('hdr-progress', (event: any) => { + const unlistenProgress = events.hdrProgress.listen( (event: any) => { if (isEffectActive) { setHdrModalState((prev: HdrModalState) => ({ ...prev, @@ -2991,7 +2942,7 @@ function App() { } }); - const unlistenComplete = listen('hdr-complete', (event: any) => { + const unlistenComplete = events.hdrComplete.listen( (event: any) => { if (isEffectActive) { const { base64 } = event.payload; setHdrModalState((prev: HdrModalState) => ({ @@ -3003,7 +2954,7 @@ function App() { } }); - const unlistenError = listen('hdr-error', (event: any) => { + const unlistenError = events.hdrError.listen( (event: any) => { if (isEffectActive) { setHdrModalState((prev: HdrModalState) => ({ ...prev, @@ -3025,7 +2976,7 @@ function App() { useEffect(() => { let isEffectActive = true; - const unlistenStart = listen('culling-start', (event: any) => { + const unlistenStart = events.cullingStart.listen( (event: any) => { if (isEffectActive) { setCullingModalState({ isOpen: true, @@ -3036,13 +2987,13 @@ function App() { } }); - const unlistenProgress = listen('culling-progress', (event: any) => { + const unlistenProgress = events.cullingProgress.listen( (event: any) => { if (isEffectActive) { setCullingModalState((prev) => ({ ...prev, progress: event.payload })); } }); - const unlistenComplete = listen('culling-complete', (event: any) => { + const unlistenComplete = events.cullingComplete.listen( (event: any) => { if (isEffectActive) { setCullingModalState((prev) => ({ ...prev, progress: null, suggestions: event.payload })); } @@ -3064,9 +3015,7 @@ function App() { } try { - const savedPath: string = await invoke('save_panorama', { - firstPathStr: panoramaModalState.stitchingSourcePaths[0], - }); + const savedPath: string = await commands.savePanorama(panoramaModalState.stitchingSourcePaths[0],); await refreshImageList(); return savedPath; } catch (err) { @@ -3084,9 +3033,7 @@ function App() { } try { - const savedPath: string = await invoke('save_hdr', { - firstPathStr: hdrModalState.stitchingSourcePaths[0], - }); + const savedPath: string = await commands.saveHdr(hdrModalState.stitchingSourcePaths[0],); await refreshImageList(); return savedPath; } catch (err) { @@ -3107,10 +3054,7 @@ function App() { })); try { - await invoke('apply_denoising', { - path: denoiseModalState.targetPath, - intensity: intensity - }); + await commands.applyDenoising(denoiseModalState.targetPath, intensity); } catch (err) { setDenoiseModalState(prev => ({ ...prev, @@ -3122,19 +3066,14 @@ function App() { const handleSaveDenoisedImage = async (): Promise => { if (!denoiseModalState.targetPath) throw new Error("No target path"); - const savedPath = await invoke('save_denoised_image', { - originalPathStr: denoiseModalState.targetPath - }); + const savedPath = await commands.saveDenoisedImage(denoiseModalState.targetPath); await refreshImageList(); return savedPath; }; const handleSaveCollage = async (base64Data: string, firstPath: string): Promise => { try { - const savedPath: string = await invoke('save_collage', { - base64Data, - firstPathStr: firstPath, - }); + const savedPath: string = await commands.saveCollage(base64Data, firstPath); await refreshImageList(); return savedPath; } catch (err) { @@ -3253,7 +3192,7 @@ function App() { treeData = await preloadedDataRef.current.tree; console.log('Preload cache hit for folder tree.'); } else { - treeData = await invoke('get_folder_tree', { path: root }); + treeData = await commands.getFolderTree(root); } setFolderTree(treeData); } catch (err) { @@ -3388,7 +3327,7 @@ function App() { useEffect(() => { const invokeWaveForm = async () => { - const waveForm: any = await invoke('generate_waveform').catch((err) => + const waveForm: any = await commands.generateWaveform().catch((err) => console.error('Failed to generate waveform:', err), ); if (waveForm) { @@ -3407,7 +3346,7 @@ function App() { const loadMetadataEarly = async () => { try { - const metadata: any = await invoke('load_metadata', { path: selectedImage.path }); + const metadata: any = await commands.loadMetadata(selectedImage.path); if (!isEffectActive) return; let initialAdjusts; @@ -3428,7 +3367,7 @@ function App() { const loadFullImageData = async () => { try { - const loadImageResult: any = await invoke('load_image', { path: selectedImage.path }); + const loadImageResult: any = await commands.loadImage(selectedImage.path); if (!isEffectActive) { return; } @@ -3519,10 +3458,7 @@ function App() { async (nameTemplate: string) => { if (renameTargetPaths.length > 0 && nameTemplate) { try { - const newPaths: Array = await invoke('rename_files', { - nameTemplate, - paths: renameTargetPaths, - }); + const newPaths: Array = await commands.renameFiles(renameTargetPaths, nameTemplate); await refreshImageList(); @@ -3559,11 +3495,7 @@ function App() { const handleStartImport = async (settings: AppSettings) => { if (importSourcePaths.length > 0 && importTargetFolder) { - invoke('import_files', { - destinationFolder: importTargetFolder, - settings: settings, - sourcePaths: importSourcePaths, - }).catch((err) => { + commands.importFiles(importSourcePaths, importTargetFolder, settings).catch((err) => { console.error('Failed to start import:', err); setImportState({ status: Status.Error, errorMessage: `Failed to start import: ${err}` }); }); @@ -3579,7 +3511,7 @@ function App() { debouncedSetHistory.cancel(); - invoke('reset_adjustments_for_paths', { paths: pathsToReset }) + commands.resetAdjustmentsForPaths(pathsToReset) .then(() => { if (libraryActivePath && pathsToReset.includes(libraryActivePath)) { setLibraryActiveAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, rating: prev.rating })); @@ -3662,7 +3594,7 @@ function App() { const handleCreateVirtualCopy = async (sourcePath: string) => { try { - await invoke('create_virtual_copy', { sourceVirtualPath: sourcePath }); + await commands.createVirtualCopy(sourcePath); await refreshImageList(); } catch (err) { console.error('Failed to create virtual copy:', err); @@ -3917,7 +3849,7 @@ function App() { const handleCreateVirtualCopy = async (sourcePath: string) => { try { - await invoke('create_virtual_copy', { sourceVirtualPath: sourcePath }); + await commands.createVirtualCopy(sourcePath); await refreshImageList(); } catch (err) { console.error('Failed to create virtual copy:', err); @@ -3928,12 +3860,10 @@ function App() { const handleApplyAutoAdjustmentsToSelection = () => { if (finalSelection.length === 0) return; - invoke('apply_auto_adjustments_to_paths', { paths: finalSelection }) + commands.applyAutoAdjustmentsToPaths(finalSelection) .then(async () => { if (selectedImage && finalSelection.includes(selectedImage.path)) { - const metadata: Metadata = await invoke('load_metadata', { - path: selectedImage.path, - }); + const metadata: Metadata = await commands.loadMetadata(selectedImage.path,); if (metadata.adjustments && !metadata.adjustments.is_null) { const normalized = normalizeLoadedAdjustments(metadata.adjustments); setLiveAdjustments(normalized); @@ -3941,9 +3871,7 @@ function App() { } } if (libraryActivePath && finalSelection.includes(libraryActivePath)) { - const metadata: Metadata = await invoke('load_metadata', { - path: libraryActivePath, - }); + const metadata: Metadata = await commands.loadMetadata(libraryActivePath,); if (metadata.adjustments && !metadata.adjustments.is_null) { const normalized = normalizeLoadedAdjustments(metadata.adjustments); setLibraryActiveAdjustments(normalized); @@ -3998,7 +3926,7 @@ function App() { label: 'Copy Adjustments', onClick: async () => { try { - const metadata: any = await invoke('load_metadata', { path: finalSelection[0] }); + const metadata: any = await commands.loadMetadata(finalSelection[0]); const sourceAdjustments = metadata.adjustments && !metadata.adjustments.is_null ? { ...INITIAL_ADJUSTMENTS, ...metadata.adjustments } @@ -4076,7 +4004,7 @@ function App() { progressMessage: 'Starting panorama process...', stitchingSourcePaths: finalSelection, }); - invoke('stitch_panorama', { paths: finalSelection }).catch((err) => { + commands.stitchPanorama(finalSelection).catch((err) => { setPanoramaModalState((prev: PanoramaModalState) => ({ ...prev, error: String(err), @@ -4098,7 +4026,7 @@ function App() { progressMessage: 'Starting hdr process...', stitchingSourcePaths: finalSelection, }); - invoke('merge_hdr', { paths: finalSelection }).catch((err) => { + commands.mergeHdr(finalSelection).catch((err) => { setHdrModalState((prev: HdrModalState) => ({ ...prev, error: String(err), @@ -4152,7 +4080,7 @@ function App() { label: 'Duplicate Image', onClick: async () => { try { - await invoke('duplicate_file', { path: finalSelection[0] }); + await commands.duplicateFile(finalSelection[0]); await refreshImageList(); } catch (err) { console.error('Failed to duplicate file:', err); @@ -4203,7 +4131,7 @@ function App() { icon: Folder, label: 'Show in File Explorer', onClick: () => { - invoke('show_in_finder', { path: finalSelection[0] }).catch((err) => + commands.showInFinder(finalSelection[0]).catch((err) => setError(`Could not show file in explorer: ${err}`), ); }, @@ -4217,7 +4145,7 @@ function App() { const handleCreateFolder = async (folderName: string) => { if (folderName && folderName.trim() !== '' && folderActionTarget) { try { - await invoke('create_folder', { path: `${folderActionTarget}/${folderName.trim()}` }); + await commands.createFolder(`${folderActionTarget}/${folderName.trim()}`); refreshAllFolderTrees(); } catch (err) { setError(`Failed to create folder: ${err}`); @@ -4231,7 +4159,7 @@ function App() { const oldPath = folderActionTarget; const trimmedNewName = newName.trim(); - await invoke('rename_folder', { path: oldPath, newName: trimmedNewName }); + await commands.renameFolder(oldPath, trimmedNewName); const parentDir = getParentDir(oldPath); const separator = oldPath.includes('/') ? '/' : '\\'; @@ -4323,7 +4251,7 @@ function App() { label: copyPastedLabel, onClick: async () => { try { - await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); + await commands.copyFiles(copiedFilePaths, targetPath); if (targetPath === currentFolderPath) handleLibraryRefresh(); } catch (err) { setError(`Failed to copy files: ${err}`); @@ -4334,7 +4262,7 @@ function App() { label: movePastedLabel, onClick: async () => { try { - await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: targetPath }); + await commands.moveFiles(copiedFilePaths, targetPath); setCopiedFilePaths([]); setMultiSelectedPaths([]); refreshAllFolderTrees(); @@ -4352,7 +4280,7 @@ function App() { icon: Folder, label: 'Show in File Explorer', onClick: () => - invoke('show_in_finder', { path: targetPath }).catch((err) => setError(`Could not show folder: ${err}`)), + commands.showInFinder(targetPath).catch((err) => setError(`Could not show folder: ${err}`)), }, ...(path ? [ @@ -4369,7 +4297,7 @@ function App() { isDestructive: true, onClick: async () => { try { - await invoke('delete_folder', { path: targetPath }); + await commands.deleteFolder(targetPath); if (currentFolderPath?.startsWith(targetPath)) await handleSelectSubfolder(rootPath); refreshAllFolderTrees(); } catch (err) { @@ -4402,7 +4330,7 @@ function App() { label: copyPastedLabel, onClick: async () => { try { - await invoke('copy_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await commands.copyFiles(copiedFilePaths, currentFolderPath); handleLibraryRefresh(); } catch (err) { setError(`Failed to copy files: ${err}`); @@ -4413,7 +4341,7 @@ function App() { label: movePastedLabel, onClick: async () => { try { - await invoke('move_files', { sourcePaths: copiedFilePaths, destinationFolder: currentFolderPath }); + await commands.moveFiles(copiedFilePaths, currentFolderPath); setCopiedFilePaths([]); setMultiSelectedPaths([]); refreshAllFolderTrees(); diff --git a/src/bindings.ts b/src/bindings.ts index 0db5ae79d..1bb8209af 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -278,9 +278,83 @@ async saveConvertedNegative(originalPathStr: string) : Promise { export const events = __makeEvents__<{ -aiConnectorStatusUpdate: AiConnectorStatusUpdate +aiConnectorStatusUpdate: AiConnectorStatusUpdate, +aiModelDownloadFinish: AiModelDownloadFinish, +aiModelDownloadStart: AiModelDownloadStart, +batchExportProgress: BatchExportProgress, +cullingComplete: CullingComplete, +cullingProgress: CullingProgress, +cullingStart: CullingStart, +denoiseComplete: DenoiseComplete, +denoiseError: DenoiseError, +denoiseProgress: DenoiseProgress, +exportCancelled: ExportCancelled, +exportComplete: ExportComplete, +exportCompleteWithErrors: ExportCompleteWithErrors, +exportError: ExportError, +hdrComplete: HdrComplete, +hdrError: HdrError, +hdrProgress: HdrProgress, +histogramUpdate: HistogramUpdate, +importComplete: ImportComplete, +importError: ImportError, +importProgress: ImportProgress, +importStart: ImportStart, +indexingError: IndexingError, +indexingFinished: IndexingFinished, +indexingProgress: IndexingProgress, +indexingStarted: IndexingStarted, +openWithFile: OpenWithFile, +panoramaComplete: PanoramaComplete, +panoramaError: PanoramaError, +panoramaProgress: PanoramaProgress, +panoramaWarning: PanoramaWarning, +previewUpdateFinal: PreviewUpdateFinal, +previewUpdateUncropped: PreviewUpdateUncropped, +thumbnailGenerated: ThumbnailGenerated, +thumbnailGenerationComplete: ThumbnailGenerationComplete, +thumbnailGenerationError: ThumbnailGenerationError, +thumbnailProgress: ThumbnailProgress, +waveformUpdate: WaveformUpdate }>({ -aiConnectorStatusUpdate: "ai-connector-status-update" +aiConnectorStatusUpdate: "ai-connector-status-update", +aiModelDownloadFinish: "ai-model-download-finish", +aiModelDownloadStart: "ai-model-download-start", +batchExportProgress: "batch-export-progress", +cullingComplete: "culling-complete", +cullingProgress: "culling-progress", +cullingStart: "culling-start", +denoiseComplete: "denoise-complete", +denoiseError: "denoise-error", +denoiseProgress: "denoise-progress", +exportCancelled: "export-cancelled", +exportComplete: "export-complete", +exportCompleteWithErrors: "export-complete-with-errors", +exportError: "export-error", +hdrComplete: "hdr-complete", +hdrError: "hdr-error", +hdrProgress: "hdr-progress", +histogramUpdate: "histogram-update", +importComplete: "import-complete", +importError: "import-error", +importProgress: "import-progress", +importStart: "import-start", +indexingError: "indexing-error", +indexingFinished: "indexing-finished", +indexingProgress: "indexing-progress", +indexingStarted: "indexing-started", +openWithFile: "open-with-file", +panoramaComplete: "panorama-complete", +panoramaError: "panorama-error", +panoramaProgress: "panorama-progress", +panoramaWarning: "panorama-warning", +previewUpdateFinal: "preview-update-final", +previewUpdateUncropped: "preview-update-uncropped", +thumbnailGenerated: "thumbnail-generated", +thumbnailGenerationComplete: "thumbnail-generation-complete", +thumbnailGenerationError: "thumbnail-generation-error", +thumbnailProgress: "thumbnail-progress", +waveformUpdate: "waveform-update" }) /** user-defined constants **/ @@ -291,26 +365,51 @@ aiConnectorStatusUpdate: "ai-connector-status-update" export type AiConnectorStatusUpdate = { connected: boolean } export type AiForegroundMaskParameters = { maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } +export type AiModelDownloadFinish = JsonValue +export type AiModelDownloadStart = JsonValue export type AiPatchDefinition = { id: string; name: string; visible: boolean; invert: boolean; prompt: string; patchData?: PatchData | null; opacity?: number; subMasks: SubMask[] } export type AiSkyMaskParameters = { maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } export type AiSubjectMaskParameters = { startX: number; startY: number; endX: number; endY: number; maskDataBase64?: string | null; rotation?: number | null; flipHorizontal?: boolean | null; flipVertical?: boolean | null; orientationSteps?: number | null } export type AppSettings = { lastRootPath: string | null; pinnedFolders?: string[]; editorPreviewResolution: number | null; enableZoomHifi?: boolean | null; enableLivePreviews?: boolean | null; enableHighQualityLivePreviews?: boolean | null; sortCriteria: SortCriteria | null; filterCriteria: FilterCriteria | null; theme: string | null; transparent: boolean | null; decorations: boolean | null; aiConnectorAddress: string | null; lastFolderState: LastFolderState | null; adaptiveEditorTheme: boolean | null; uiVisibility: JsonValue | null; enableAiTagging: boolean | null; taggingThreadCount: number | null; taggingShortcuts?: string[] | null; customAiTags?: string[] | null; aiTagCount?: number | null; thumbnailSize: string | null; thumbnailAspectRatio: string | null; aiProvider: string | null; adjustmentVisibility?: Partial<{ [key in string]: boolean }>; enableExifReading: boolean | null; activeTreeSection?: string | null; copyPasteSettings?: CopyPasteSettings; rawHighlightCompression?: number | null; processingBackend?: string | null; linuxGpuOptimization?: boolean | null; libraryViewMode?: string | null; exportPresets?: ExportPreset[]; myLenses?: MyLens[] | null; enableFolderImageCounts?: boolean | null; linearRawMode?: string; enableXmpSync?: boolean | null; createXmpIfMissing?: boolean | null } +export type BatchExportProgress = JsonValue export type CommunityPreset = { name: string; creator: string; adjustments: JsonValue } export type CopyPasteSettings = { mode: PasteMode; includedAdjustments?: string[]; knownAdjustments?: string[] } export type CullGroup = { representative: ImageAnalysisResult; duplicates: ImageAnalysisResult[] } +export type CullingComplete = JsonValue +export type CullingProgress = JsonValue export type CullingSettings = { similarityThreshold: number; blurThreshold: number; groupSimilar: boolean; filterBlurry: boolean } +export type CullingStart = JsonValue export type CullingSuggestions = { similarGroups: CullGroup[]; blurryImages: ImageAnalysisResult[]; failedPaths: string[] } +export type DenoiseComplete = JsonValue +export type DenoiseError = JsonValue +export type DenoiseProgress = JsonValue +export type ExportCancelled = JsonValue +export type ExportComplete = JsonValue +export type ExportCompleteWithErrors = JsonValue +export type ExportError = JsonValue export type ExportPreset = { id: string; name: string; fileFormat: string; jpegQuality: number; enableResize: boolean; resizeMode: string; resizeValue: number; dontEnlarge: boolean; keepMetadata: boolean; stripGps: boolean; filenameTemplate: string; enableWatermark: boolean; watermarkPath: string | null; watermarkAnchor: string | null; watermarkScale: number; watermarkSpacing: number; watermarkOpacity: number; exportMasks?: boolean | null } export type ExportSettings = { jpegQuality: number; resize: ResizeOptions | null; keepMetadata: boolean; stripGps: boolean; filenameTemplate: string | null; watermark: WatermarkSettings | null; exportMasks?: boolean } export type FilterCriteria = { rating: number; rawStatus: string; colors?: string[] } export type FolderNode = { name: string; path: string; children: FolderNode[]; isDir: boolean; imageCount: number } export type GeometryParams = { distortion: number; vertical: number; horizontal: number; rotate: number; aspect: number; scale: number; x_offset: number; y_offset: number; lens_distortion_amount: number; lens_vignette_amount: number; lens_tca_amount: number; lens_distortion_enabled: boolean; lens_tca_enabled: boolean; lens_vignette_enabled: boolean; lens_auto_crop: boolean; lens_dist_k1: number; lens_dist_k2: number; lens_dist_k3: number; lens_model: number; tca_vr: number; tca_vb: number; vig_k1: number; vig_k2: number; vig_k3: number } +export type HdrComplete = JsonValue +export type HdrError = JsonValue +export type HdrProgress = JsonValue export type HistogramData = { red: number[]; green: number[]; blue: number[]; luma: number[] } +export type HistogramUpdate = JsonValue export type ImageAnalysisResult = { path: string; qualityScore: number; sharpnessMetric: number; centerFocusMetric: number; exposureMetric: number; width: number; height: number } export type ImageDimensions = { width: number; height: number } export type ImageFile = { path: string; modified: number; is_edited: boolean; tags: string[] | null; exif: Partial<{ [key in string]: string }> | null; is_virtual_copy: boolean } export type ImageMetadata = { version: number; rating: number; adjustments: JsonValue; tags?: string[] | null } +export type ImportComplete = JsonValue +export type ImportError = JsonValue +export type ImportProgress = JsonValue export type ImportSettings = { filenameTemplate: string; organizeByDate: boolean; dateFolderFormat: string; deleteAfterImport: boolean } +export type ImportStart = JsonValue +export type IndexingError = JsonValue +export type IndexingFinished = JsonValue +export type IndexingProgress = JsonValue +export type IndexingStarted = JsonValue export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> export type LastFolderState = { currentFolderPath: string; expandedFolders: string[] } export type LensDistortionParams = { k1: number; k2: number; k3: number; model: number; tca_vr: number; tca_vb: number; vig_k1: number; vig_k2: number; vig_k3: number } @@ -319,19 +418,31 @@ export type LutParseResult = { size: number } export type MaskDefinition = { id: string; name: string; visible: boolean; invert: boolean; opacity?: number; adjustments: JsonValue; subMasks: SubMask[] } export type MyLens = { maker: string; model: string } export type NegativeConversionParams = { red_weight: number; green_weight: number; blue_weight: number; exposure: number; contrast: number } +export type OpenWithFile = JsonValue +export type PanoramaComplete = JsonValue +export type PanoramaError = JsonValue +export type PanoramaProgress = JsonValue +export type PanoramaWarning = JsonValue export type PasteMode = "merge" | "replace" export type PatchData = { color: string; mask: string } export type Preset = { id: string; name: string; adjustments: JsonValue } export type PresetFolder = { id: string; name: string; children: Preset[] } export type PresetItem = { preset: Preset } | { folder: PresetFolder } +export type PreviewUpdateFinal = JsonValue +export type PreviewUpdateUncropped = JsonValue export type ResizeMode = "longEdge" | "shortEdge" | "width" | "height" export type ResizeOptions = { mode: ResizeMode; value: number; dontEnlarge: boolean } export type SortCriteria = { key: string; order: string } export type SubMask = { id: string; type: string; visible: boolean; invert?: boolean; opacity?: number; mode: SubMaskMode; parameters: JsonValue } export type SubMaskMode = "additive" | "subtractive" +export type ThumbnailGenerated = JsonValue +export type ThumbnailGenerationComplete = JsonValue +export type ThumbnailGenerationError = JsonValue +export type ThumbnailProgress = JsonValue export type WatermarkAnchor = "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" export type WatermarkSettings = { path: string; anchor: WatermarkAnchor; scale: number; spacing: number; opacity: number } export type WaveformData = { red: number[]; green: number[]; blue: number[]; luma: number[]; width: number; height: number } +export type WaveformUpdate = JsonValue /** tauri-specta globals **/ diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index 6abaa9220..343543ba9 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import { commands } from '../bindings'; +import { commands, events } from '../bindings'; import { ImageFile, Progress } from '../components/ui/AppProperties'; export function useThumbnails(imageList: Array, setThumbnails: any) { @@ -51,12 +50,12 @@ export function useThumbnails(imageList: Array, setThumbnails: any) { setLoading(true); setProgress({ completed: 0, total: imagePaths.length }); - unlistenProgress = await listen('thumbnail-progress', (event: any) => { + unlistenProgress = await events.thumbnailProgress.listen((event: any) => { const { completed, total } = event.payload; setProgress({ completed, total }); }); - unlistenComplete = await listen('thumbnail-generation-complete', () => { + unlistenComplete = await events.thumbnailGenerationComplete.listen(() => { setLoading(false); }); From c4d9a9399bf82ece95c4a9236891753030d5808f Mon Sep 17 00:00:00 2001 From: Yann Birba Date: Tue, 3 Mar 2026 12:11:55 +0100 Subject: [PATCH 3/3] chore: remove PLAN.md file --- PLAN.md | 234 -------------------------------------------------------- 1 file changed, 234 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index e20faf881..000000000 --- a/PLAN.md +++ /dev/null @@ -1,234 +0,0 @@ -# Plan strict d’intégration `tauri-specta` (complet + POC final) - -**Résumé** -Objectif: remplacer progressivement le contrat IPC “stringly-typed” (`invoke('...')`, `listen('...')`) par un contrat typé généré (`commands.*`, `events.*`) via `tauri-specta`, sans régression runtime, avec un POC final visible dans l’app. - -Le plan couvre: -1. l’infra Rust `tauri-specta`, -2. le typage complet commandes + events, -3. la migration frontend progressive puis totale, -4. le nettoyage des éléments obsolètes, -5. les tests de non-régression et le POC de démonstration. - -**Constat initial vérifié dans le repo** -1. Backend Tauri: 89 commandes `#[tauri::command]` exposées, actuellement enregistrées via `tauri::generate_handler!` dans [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs#L3866). -2. Frontend: enum `Invokes` central dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx#L8) + de nombreux `invoke('...')` en dur (ex: [LensCorrectionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/LensCorrectionModal.tsx#L208), [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx#L653)). -3. Incohérences commandes: -`image_processing::generate_waveform` dans l’enum (backend expose `generate_waveform`), -`invoke_generative_replace` dans l’enum sans commande backend correspondante. -4. Incohérences events: -frontend écoute `export-cancelled`, `hdr-error`, `culling-error` mais backend ne les émet pas; -backend émet `indexing-error`, `panorama-warning`, `thumbnail-generation-error`, `export-complete-with-errors` mais frontend n’en fait pas de traitement central. -5. Edge technique majeur: -3 commandes renvoient `Result` (`generate_original_transformed_preview`, `generate_preset_preview`, `generate_preview_for_path`) et doivent être migrées vers `Vec` pour un contrat Specta propre (migration complète, mais hors POC comme demandé). - ---- - -## Plan d’implémentation détaillé - -### 1. Phase 0 — Préparation et garde-fous -Actions: -1. Geler un baseline technique: `cargo check` backend + `npm run build` frontend. -2. Ajouter une checklist de validation contractuelle (script shell repo) pour comparer: -backend commands vs usage frontend, backend events vs listeners frontend. -3. Fixer dès cette phase la convention de nommage officielle: -commandes Rust snake_case -> bindings TS camelCase. - -Fichiers concernés: -[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx), [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx). - -Critère de sortie: -baseline vert + inventaire automatique des écarts reproduisible. - -### 2. Phase 1 — Infrastructure `tauri-specta` côté Rust -Actions: -1. Mettre à jour [Cargo.toml](/home/yann/dev/RapidRAW/src-tauri/Cargo.toml): -ajouter feature `specta` sur `tauri`, -ajouter `tauri-specta = "=2.0.0-rc.21"` avec features `derive, typescript`, -ajouter `specta` version compatible (`=2.0.0-rc.22`) avec features nécessaires (`derive`, `function`, `serde_json`), -ajouter `specta-typescript = "0.0.9"`. -2. Dans [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs): -créer un `tauri_specta::Builder::::new()`, -déclarer `.commands(collect_commands![...])` avec la liste complète actuelle, -déclarer `.events(collect_events![...])` (initialement vide puis complétée en phase events), -activer `.error_handling(ErrorHandlingMode::Throw)` pour conserver la sémantique frontend actuelle (`try/catch`), -remplacer `.invoke_handler(tauri::generate_handler![...])` par `.invoke_handler(builder.invoke_handler())`, -appeler `builder.mount_events(app)` dans `setup`. -3. Export TS: -générer `src/bindings.ts` depuis Rust en debug, en évitant les réécritures inutiles (comparaison `export_str` vs contenu existant avant écriture). - -Fichiers concernés: -[Cargo.toml](/home/yann/dev/RapidRAW/src-tauri/Cargo.toml), [main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), nouveau [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts). - -Critère de sortie: -`cargo check` passe avec `tauri-specta`, `bindings.ts` généré avec `commands` compilables. - -### 3. Phase 2 — Couverture complète commandes et types Specta -Actions: -1. Ajouter `#[specta::specta]` sur les 89 commandes `#[tauri::command]` (dans `main.rs` + modules). -2. Dériver `specta::Type` sur tous les types transitant dans signatures commandes/events: -types de `main.rs` (ex: `ImageDimensions`, `LoadImageResult`, `ExportSettings`, `ResizeOptions`, `Watermark*`, `CommunityPreset`, `LutParseResult`), -types modules `file_management` (`AppSettings`, `ImageFile`, `FolderNode`, `ImportSettings`, `PresetItem`, etc.), -types modules `image_processing` (`ImageMetadata`, `Crop`, `GeometryParams`, `HistogramData`, `WaveformData`), -types modules `culling`, `negative_conversion`, `lens_correction`, `mask_generation`, `ai_processing`. -3. Traiter les erreurs de compilation Specta de façon itérative jusqu’à couverture totale des types imbriqués. -4. Migration binaire complète (hors POC mais dans l’intégration finale): -`Result` -> `Result, String>` pour: -`generate_original_transformed_preview`, -`generate_preset_preview`, -`generate_preview_for_path`. - -Fichiers concernés: -[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), -[file_management.rs](/home/yann/dev/RapidRAW/src-tauri/src/file_management.rs), -[image_processing.rs](/home/yann/dev/RapidRAW/src-tauri/src/image_processing.rs), -[culling.rs](/home/yann/dev/RapidRAW/src-tauri/src/culling.rs), -[negative_conversion.rs](/home/yann/dev/RapidRAW/src-tauri/src/negative_conversion.rs), -[lens_correction.rs](/home/yann/dev/RapidRAW/src-tauri/src/lens_correction.rs), -[mask_generation.rs](/home/yann/dev/RapidRAW/src-tauri/src/mask_generation.rs), -[ai_processing.rs](/home/yann/dev/RapidRAW/src-tauri/src/ai_processing.rs). - -Critère de sortie: -`bindings.ts` contient toutes les commandes avec signatures TS strictes, sans fallback `any` côté génération. - -### 4. Phase 3 — Contrat events typé et unifié -Actions: -1. Définir des structs d’events typés (`Serialize`, `Deserialize`, `Type`, `Event`) pour chaque event métier actuellement utilisé/émis. -2. Enregistrer ces events via `collect_events![...]`. -3. Unifier le contrat event backend/frontend: -ajouter émissions backend manquantes pour préserver l’UX existante (`export-cancelled`, `hdr-error`, `culling-error`), -ajouter traitement frontend pour les events backend actuellement non gérés (`indexing-error`, `panorama-warning`, `thumbnail-generation-error`, `export-complete-with-errors`). -4. Convertir progressivement les `app_handle.emit("string", payload)` vers émissions typées `MyEvent { ... }.emit(...)` pour verrouiller les noms au compile-time. - -Fichiers concernés: -[main.rs](/home/yann/dev/RapidRAW/src-tauri/src/main.rs), -[file_management.rs](/home/yann/dev/RapidRAW/src-tauri/src/file_management.rs), -[tagging.rs](/home/yann/dev/RapidRAW/src-tauri/src/tagging.rs), -[culling.rs](/home/yann/dev/RapidRAW/src-tauri/src/culling.rs), -[denoising.rs](/home/yann/dev/RapidRAW/src-tauri/src/denoising.rs), -[panorama_stitching.rs](/home/yann/dev/RapidRAW/src-tauri/src/panorama_stitching.rs), -[App.tsx](/home/yann/dev/RapidRAW/src/App.tsx), -[useThumbnails.tsx](/home/yann/dev/RapidRAW/src/hooks/useThumbnails.tsx). - -Critère de sortie: -`events.*` générés dans `bindings.ts` couvrent tous les events utiles et le backend/front utilisent le même contrat. - -### 5. Phase 4 — Migration frontend progressive vers `commands`/`events` -Actions: -1. Introduire l’import `commands, events` depuis [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts). -2. Migrer par zones fonctionnelles pour minimiser le risque: -zone App core ([App.tsx](/home/yann/dev/RapidRAW/src/App.tsx)), -modales géométrie/lens/négatif, -exports, -presets, -thumbnails, -settings. -3. Remplacer `invoke(Invokes.X, ...)` par `commands.x(...)`. -4. Remplacer `listen('event-name', ...)` par `events.eventName.listen(...)`. -5. Remplacer les `any` de payload lorsque le type généré existe. -6. Conserver temporairement compatibilité mixte si nécessaire (zones non migrées), puis converger à 100%. - -Fichiers principaux: -[App.tsx](/home/yann/dev/RapidRAW/src/App.tsx), -[AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx), -[useThumbnails.tsx](/home/yann/dev/RapidRAW/src/hooks/useThumbnails.tsx), -[SettingsPanel.tsx](/home/yann/dev/RapidRAW/src/components/panel/SettingsPanel.tsx), -[LensCorrectionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/LensCorrectionModal.tsx), -[NegativeConversionModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/NegativeConversionModal.tsx), -[TransformModal.tsx](/home/yann/dev/RapidRAW/src/components/modals/TransformModal.tsx). - -Critère de sortie: -plus aucun appel IPC “critique” en string brut dans les zones migrées, et types TS stricts compilent. - -### 6. Phase 5 — Nettoyage (remove) et normalisation -Actions: -1. Supprimer l’enum `Invokes` dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx#L8) une fois migration frontend complète. -2. Supprimer les imports inutiles `invoke`/`listen` restants quand remplacés par `commands`/`events`. -3. Supprimer les entrées incohérentes historiques: -`invoke_generative_replace`, -`image_processing::generate_waveform`. -4. Supprimer les paths “double contrat” (fallback legacy) une fois validés. -5. Ajouter une vérification CI/lint simple qui échoue si des `invoke('...')`/`listen('...')` non autorisés réapparaissent. - -Critère de sortie: -contrat IPC centralisé dans `bindings.ts`, plus de dette legacy active. - -### 7. Phase 6 — Validation finale et verrouillage -Actions: -1. Valider builds: -`cargo check`, -`npm run build`, -`npm run lint` (si applicable dans le flux équipe). -2. Vérifier par tests manuels guidés (voir section tests). -3. Vérifier par script de cohérence: -toutes commandes backend exposées dans bindings, -pas de commandes frontend orphelines, -events frontend alignés avec backend. - -Critère de sortie: -pipeline vert + checklist fonctionnelle signée. - -### 8. Phase 7 — POC final (petit flux démonstrateur) -POC choisi: -migrer le flux “AI connector status” pour montrer commande typée + event typé de bout en bout. - -Implémentation POC: -1. Backend: -`check_ai_connector_status` reste la commande de trigger, -event `ai-connector-status-update` devient typé et enregistré dans `collect_events!`. -2. Frontend: -dans [App.tsx](/home/yann/dev/RapidRAW/src/App.tsx#L684), -remplacer `listen('ai-connector-status-update', ...)` par `events.aiConnectorStatusUpdate.listen(...)`, -remplacer `invoke(Invokes.CheckAIConnectorStatus)` par `commands.checkAiConnectorStatus()`. -3. Démo: -au lancement, statut AI se met à jour toutes les 10s avec contrat 100% typé sans string IPC. - -Critère de sortie POC: -flux visible fonctionnel, compilé strict TS, aucun `any` nécessaire sur ce flux. - ---- - -## Changements d’API / interfaces publiques (frontend-backend) -1. Nouveau contrat généré [bindings.ts](/home/yann/dev/RapidRAW/src/bindings.ts) exposant `commands` et `events`. -2. Côté frontend, l’API d’appel devient `commands.()` au lieu de `invoke('')`. -3. Côté frontend, l’API event devient `events..listen(...)` au lieu de `listen('')`. -4. Gestion d’erreurs maintenue en mode “throw” (`ErrorHandlingMode::Throw`) pour ne pas casser `try/catch`. -5. Migration complète prévue des retours binaires `Response` vers `Vec` pour typings robustes. - -## Liste explicite des suppressions prévues -1. Suppression de `Invokes` dans [AppProperties.tsx](/home/yann/dev/RapidRAW/src/components/ui/AppProperties.tsx). -2. Suppression des constantes invalides `invoke_generative_replace` et `image_processing::generate_waveform`. -3. Suppression des appels `invoke('...')` et `listen('...')` legacy une fois tous migrés. -4. Suppression des handlers/branches de compatibilité temporaires après validation. -5. Suppression des incohérences events non alignées (ou remplacement par émissions backend correspondantes). - -## Edge cases couverts explicitement -1. Compatibilité Tauri/Specta: feature `tauri/specta` obligatoire pour masquer `State`, `AppHandle`, `Window` des signatures JS. -2. `serde_json::Value` dans signatures: feature Specta correspondante requise. -3. Retours binaires `Response`: migration vers `Vec` pour éviter les trous de typing. -4. Contrat erreurs: mode `Throw` imposé pour préserver comportement actuel frontend. -5. Noms events/commands: normalisation snake_case/kebab-case -> camelCase dans bindings. -6. Écarts event payload (string vs object) traités par structs explicites. -7. Événements “oubliés” frontend/backend harmonisés avant finalisation. -8. Régressions cancellation export traitées via event explicite `export-cancelled`. -9. Structures récursives (`FolderNode`) typées et validées. -10. Tuples/Options (`Option<(String, String)>`) vérifiés dans bindings générés. - -## Tests et scénarios d’acceptation -1. Build backend: `cd src-tauri && cargo check` doit passer. -2. Build frontend: `npm run build` doit passer sans erreur TS. -3. Smoke test commandes: -chargement image, réglages, export simple, export batch, presets, import, lens tools, negative conversion. -4. Smoke test events: -preview updates, histogram/waveform, thumbnails, import/export progress, denoise/panorama/hdr, indexing. -5. Test contractuel: -aucune commande frontend orpheline, aucun event frontend mort, aucun nom IPC string non migré en zone finalisée. -6. Test POC: -statut AI connector fonctionnel via `commands + events` générés. - -## Assumptions et defaults retenus -1. Objectif final: migration complète “tout ce qui est possible” vers contrat typé. -2. Stratégie: progressive, sans big-bang, avec compat temporaire contrôlée. -3. Les 3 commandes binaires sont migrées dans l’intégration complète, mais pas dans le POC initial. -4. Les bindings générés `src/bindings.ts` sont versionnés dans le repo. -5. Le contrat event final est aligné bidirectionnellement, en privilégiant la conservation du comportement UX existant.