Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ This guide covers how to set up the development environment and build Handy from

#### Windows

- Microsoft C++ Build Tools
- Visual Studio 2019/2022 with C++ development tools
- Or Visual Studio Build Tools 2019/2022
- Microsoft C++ Build Tools:
- Visual Studio 2019/2022 with C++ development tools
- Or Visual Studio Build Tools 2019/2022
- [LLVM](https://github.com/llvm/llvm-project/releases) — required by `whisper-rs-sys` for bindgen (`libclang.dll`)
- [Vulkan SDK](https://vulkan.lunarg.com/sdk/home#windows) — required by `whisper-rs-sys` for GPU acceleration

#### Linux

Expand Down
162 changes: 162 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,168 @@ pub fn open_app_data_dir(app: AppHandle) -> Result<(), String> {
Ok(())
}

#[specta::specta]
#[tauri::command]
pub fn get_models_dir_path(app: AppHandle) -> Result<String, String> {
let models_dir = crate::settings::resolve_models_dir(&app)?;
Ok(models_dir.to_string_lossy().to_string())
}

#[specta::specta]
#[tauri::command]
pub fn open_models_folder(app: AppHandle) -> Result<(), String> {
let models_dir = crate::settings::resolve_models_dir(&app)?;
let path = models_dir.to_string_lossy().into_owned();
app.opener()
.open_path(path, None::<String>)
.map_err(|e| format!("Failed to open models folder: {}", e))?;

Ok(())
}

#[derive(serde::Serialize, serde::Deserialize, specta::Type, Debug)]
pub struct SetModelsDirResult {
pub moved: usize,
pub skipped: usize,
pub failed: usize,
}

fn move_model_files_between_dirs(
old_dir: &std::path::Path,
new_dir: &std::path::Path,
) -> Result<SetModelsDirResult, String> {
let mut result = SetModelsDirResult {
moved: 0,
skipped: 0,
failed: 0,
};

if !old_dir.exists() {
return Ok(result);
}

let entries = std::fs::read_dir(old_dir)
.map_err(|e| format!("Failed to read models directory: {}", e))?;

for entry in entries.flatten() {
let file_name = entry.file_name();
let source = old_dir.join(&file_name);
let target = new_dir.join(&file_name);

if target.exists() {
result.skipped += 1;
continue;
}

// Try rename first (fast, same-filesystem). If that fails (e.g.
// cross-filesystem), fall back to copy + delete.
let ok = std::fs::rename(&source, &target).is_ok() || {
let copied = if source.is_dir() {
copy_dir_recursive(&source, &target)
} else {
std::fs::copy(&source, &target).map(|_| ())
};
match copied {
Ok(()) => {
if source.is_dir() {
std::fs::remove_dir_all(&source).ok();
} else {
std::fs::remove_file(&source).ok();
}
true
}
Err(_) => false,
}
};

if ok {
result.moved += 1;
} else {
result.failed += 1;
}
}

Ok(result)
}

fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let target = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}

/// Set (or clear) the custom models directory.
///
/// - `path = Some(...)` activates a custom folder.
/// - `path = None` reverts to the default `<app_data_dir>/models`.
/// - When `move_existing = true` the existing model files are moved from
/// old effective directory to the new one.
#[tauri::command]
#[specta::specta]
pub async fn set_models_directory(
app: AppHandle,
path: Option<String>,
move_existing: bool,
) -> Result<SetModelsDirResult, String> {
let old_dir = crate::settings::resolve_models_dir(&app)?;

let new_dir: std::path::PathBuf = if let Some(ref p) = path {
if p.trim().is_empty() {
return Err("Models directory path must not be empty.".to_string());
}

let candidate = std::path::PathBuf::from(p);

std::fs::create_dir_all(&candidate)
.map_err(|e| format!("Failed to create directory: {}", e))?;

let test = candidate.join(".handy_write_test");
std::fs::write(&test, b"").map_err(|e| format!("Directory is not writable: {}", e))?;
std::fs::remove_file(&test).ok();

candidate
} else {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
let default_dir = app_data_dir.join("models");
std::fs::create_dir_all(&default_dir)
.map_err(|e| format!("Failed to create default models directory: {}", e))?;
default_dir
};

let mut settings = crate::settings::get_settings(&app);
settings.models_custom_dir = path;
crate::settings::write_settings(&app, settings);

let mut result = SetModelsDirResult {
moved: 0,
skipped: 0,
failed: 0,
};

if move_existing && old_dir != new_dir && old_dir.exists() {
result = move_model_files_between_dirs(&old_dir, &new_dir)?;
}

// Refresh the ModelManager so it picks up the new directory immediately
let model_manager = app.state::<std::sync::Arc<crate::managers::model::ModelManager>>();
model_manager
.update_models_dir(new_dir)
.map_err(|e| format!("Failed to refresh model manager: {}", e))?;

Ok(result)
}

/// Check if Apple Intelligence is available on this device.
/// Called by the frontend when the user selects Apple Intelligence provider.
#[specta::specta]
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ pub fn run(cli_args: CliArgs) {
commands::open_recordings_folder,
commands::open_log_dir,
commands::open_app_data_dir,
commands::get_models_dir_path,
commands::open_models_folder,
commands::set_models_directory,
commands::check_apple_intelligence_available,
commands::initialize_enigo,
commands::initialize_shortcuts,
Expand Down
70 changes: 46 additions & 24 deletions src-tauri/src/managers/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,17 @@ pub struct DownloadProgress {

pub struct ModelManager {
app_handle: AppHandle,
models_dir: PathBuf,
models_dir: Mutex<PathBuf>,
available_models: Mutex<HashMap<String, ModelInfo>>,
cancel_flags: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
extracting_models: Arc<Mutex<HashSet<String>>>,
}

impl ModelManager {
pub fn new(app_handle: &AppHandle) -> Result<Self> {
// Create models directory in app data
let models_dir = crate::portable::app_data_dir(app_handle)
.map_err(|e| anyhow::anyhow!("Failed to get app data dir: {}", e))?
.join("models");

if !models_dir.exists() {
fs::create_dir_all(&models_dir)?;
}
// Get models directory (supports custom path via settings)
let models_dir = crate::settings::resolve_models_dir(app_handle)
.map_err(|e| anyhow::anyhow!("Failed to resolve models directory: {}", e))?;

let mut available_models = HashMap::new();

Expand Down Expand Up @@ -509,7 +504,7 @@ impl ModelManager {

let manager = Self {
app_handle: app_handle.clone(),
models_dir,
models_dir: Mutex::new(models_dir),
available_models: Mutex::new(available_models),
cancel_flags: Arc::new(Mutex::new(HashMap::new())),
extracting_models: Arc::new(Mutex::new(HashSet::new())),
Expand Down Expand Up @@ -537,6 +532,29 @@ impl ModelManager {
models.get(model_id).cloned()
}

fn models_dir(&self) -> PathBuf {
self.models_dir.lock().unwrap().clone()
}

/// Update the models directory to a new path and re-scan for models.
pub fn update_models_dir(&self, new_dir: PathBuf) -> Result<()> {
{
let mut dir = self.models_dir.lock().unwrap();
*dir = new_dir;
}
// Re-discover custom models and refresh download status
let models_dir = self.models_dir();
let mut available_models = self.available_models.lock().unwrap();
// Remove old custom models before re-scanning
available_models.retain(|_, m| !m.is_custom);
if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) {
warn!("Failed to discover custom models after dir change: {}", e);
}
drop(available_models);
self.update_download_status()?;
Ok(())
}

fn migrate_bundled_models(&self) -> Result<()> {
// Check for bundled models and copy them to user directory
let bundled_models = ["ggml-small.bin"]; // Add other bundled models here if any
Expand All @@ -549,7 +567,7 @@ impl ModelManager {

if let Ok(bundled_path) = bundled_path {
if bundled_path.exists() {
let user_path = self.models_dir.join(filename);
let user_path = self.models_dir().join(filename);

// Only copy if user doesn't already have the model
if !user_path.exists() {
Expand All @@ -570,10 +588,12 @@ impl ModelManager {
for model in models.values_mut() {
if model.is_directory {
// For directory-based models, check if the directory exists
let model_path = self.models_dir.join(&model.filename);
let partial_path = self.models_dir.join(format!("{}.partial", &model.filename));
let model_path = self.models_dir().join(&model.filename);
let partial_path = self
.models_dir()
.join(format!("{}.partial", &model.filename));
let extracting_path = self
.models_dir
.models_dir()
.join(format!("{}.extracting", &model.filename));

// Clean up any leftover .extracting directories from interrupted extractions
Expand All @@ -598,8 +618,10 @@ impl ModelManager {
}
} else {
// For file-based models (existing logic)
let model_path = self.models_dir.join(&model.filename);
let partial_path = self.models_dir.join(format!("{}.partial", &model.filename));
let model_path = self.models_dir().join(&model.filename);
let partial_path = self
.models_dir()
.join(format!("{}.partial", &model.filename));

model.is_downloaded = model_path.exists();
model.is_downloading = false;
Expand Down Expand Up @@ -790,9 +812,9 @@ impl ModelManager {
let url = model_info
.url
.ok_or_else(|| anyhow::anyhow!("No download URL for model"))?;
let model_path = self.models_dir.join(&model_info.filename);
let model_path = self.models_dir().join(&model_info.filename);
let partial_path = self
.models_dir
.models_dir()
.join(format!("{}.partial", &model_info.filename));

// Don't download if complete version already exists
Expand Down Expand Up @@ -1025,9 +1047,9 @@ impl ModelManager {

// Use a temporary extraction directory to ensure atomic operations
let temp_extract_dir = self
.models_dir
.models_dir()
.join(format!("{}.extracting", &model_info.filename));
let final_model_dir = self.models_dir.join(&model_info.filename);
let final_model_dir = self.models_dir().join(&model_info.filename);

// Clean up any previous incomplete extraction
if temp_extract_dir.exists() {
Expand Down Expand Up @@ -1141,9 +1163,9 @@ impl ModelManager {

debug!("ModelManager: Found model info: {:?}", model_info);

let model_path = self.models_dir.join(&model_info.filename);
let model_path = self.models_dir().join(&model_info.filename);
let partial_path = self
.models_dir
.models_dir()
.join(format!("{}.partial", &model_info.filename));
debug!("ModelManager: Model path: {:?}", model_path);
debug!("ModelManager: Partial path: {:?}", partial_path);
Expand Down Expand Up @@ -1215,9 +1237,9 @@ impl ModelManager {
));
}

let model_path = self.models_dir.join(&model_info.filename);
let model_path = self.models_dir().join(&model_info.filename);
let partial_path = self
.models_dir
.models_dir()
.join(format!("{}.partial", &model_info.filename));

if model_info.is_directory {
Expand Down
Loading