BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: backend media download with image and video support

+1192 -11
+8 -8
docs/tasks/15-media.md
··· 8 8 9 9 ### Backend - `src-tauri/src/media.rs` + `src-tauri/src/commands/media.rs` 10 10 11 - - [ ] Add `DownloadDirectory` variant to `SettingsKey` enum, default to `~/Downloads` via `dirs::download_dir()` 12 - - [ ] `get_download_directory()` — resolve current download path (setting or OS default), validate it exists 13 - - [ ] `set_download_directory(path: String)` — validate path is a writable directory, persist to `app_settings` 14 - - [ ] `download_image(url: String, filename: Option<String>)` — HTTP fetch → write to download dir, return `{ path, bytes }` 15 - - [ ] `download_video(url: String, filename: Option<String>)` — fetch m3u8 manifest, resolve best variant, download TS segments, concatenate to MP4, return `{ path, bytes }` 16 - - [ ] Emit `download-progress` events during video download for frontend progress UI 17 - - [ ] Filename collision handling: append `_1`, `_2`, etc. if file already exists 18 - - [ ] Add `dialog:default` and scoped `fs` permissions to `capabilities/default.json` 11 + - [x] Add `DownloadDirectory` variant to `SettingsKey` enum, default to `~/Downloads` via `dirs::download_dir()` 12 + - [x] `get_download_directory()` — resolve current download path (setting or OS default), validate it exists 13 + - [x] `set_download_directory(path: String)` — validate path is a writable directory, persist to `app_settings` 14 + - [x] `download_image(url: String, filename: Option<String>)` — HTTP fetch → write to download dir, return `{ path, bytes }` 15 + - [x] `download_video(url: String, filename: Option<String>)` — fetch m3u8 manifest, resolve best variant, download TS segments, concatenate to MP4, return `{ path, bytes }` 16 + - [x] Emit `download-progress` events during video download for frontend progress UI 17 + - [x] Filename collision handling: append `_1`, `_2`, etc. if file already exists 18 + - [x] Add `dialog:default` and scoped `fs` permissions to `capabilities/default.json` 19 19 20 20 ### Frontend - Video Player (`src/components/feeds/VideoEmbed.tsx`) 21 21
+69
src-tauri/Cargo.lock
··· 3849 3849 version = "0.1.0" 3850 3850 dependencies = [ 3851 3851 "base64 0.22.1", 3852 + "dirs", 3852 3853 "fastembed", 3853 3854 "hf-hub 0.5.0", 3854 3855 "jacquard", ··· 3860 3861 "tauri", 3861 3862 "tauri-build", 3862 3863 "tauri-plugin-deep-link", 3864 + "tauri-plugin-dialog", 3865 + "tauri-plugin-fs", 3863 3866 "tauri-plugin-global-shortcut", 3864 3867 "tauri-plugin-log", 3865 3868 "tauri-plugin-notification", ··· 6132 6135 ] 6133 6136 6134 6137 [[package]] 6138 + name = "rfd" 6139 + version = "0.16.0" 6140 + source = "registry+https://github.com/rust-lang/crates.io-index" 6141 + checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" 6142 + dependencies = [ 6143 + "block2", 6144 + "dispatch2", 6145 + "glib-sys", 6146 + "gobject-sys", 6147 + "gtk-sys", 6148 + "js-sys", 6149 + "log", 6150 + "objc2", 6151 + "objc2-app-kit", 6152 + "objc2-core-foundation", 6153 + "objc2-foundation", 6154 + "raw-window-handle", 6155 + "wasm-bindgen", 6156 + "wasm-bindgen-futures", 6157 + "web-sys", 6158 + "windows-sys 0.60.2", 6159 + ] 6160 + 6161 + [[package]] 6135 6162 name = "rgb" 6136 6163 version = "0.8.53" 6137 6164 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7369 7396 "url", 7370 7397 "windows-registry 0.5.3", 7371 7398 "windows-result 0.3.4", 7399 + ] 7400 + 7401 + [[package]] 7402 + name = "tauri-plugin-dialog" 7403 + version = "2.7.0" 7404 + source = "registry+https://github.com/rust-lang/crates.io-index" 7405 + checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" 7406 + dependencies = [ 7407 + "log", 7408 + "raw-window-handle", 7409 + "rfd", 7410 + "serde", 7411 + "serde_json", 7412 + "tauri", 7413 + "tauri-plugin", 7414 + "tauri-plugin-fs", 7415 + "thiserror 2.0.18", 7416 + "url", 7417 + ] 7418 + 7419 + [[package]] 7420 + name = "tauri-plugin-fs" 7421 + version = "2.5.0" 7422 + source = "registry+https://github.com/rust-lang/crates.io-index" 7423 + checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" 7424 + dependencies = [ 7425 + "anyhow", 7426 + "dunce", 7427 + "glob", 7428 + "log", 7429 + "objc2-foundation", 7430 + "percent-encoding", 7431 + "schemars 0.8.22", 7432 + "serde", 7433 + "serde_json", 7434 + "serde_repr", 7435 + "tauri", 7436 + "tauri-plugin", 7437 + "tauri-utils", 7438 + "thiserror 2.0.18", 7439 + "toml 0.9.12+spec-1.1.0", 7440 + "url", 7372 7441 ] 7373 7442 7374 7443 [[package]]
+3
src-tauri/Cargo.toml
··· 22 22 tauri = { version = "2", features = ["image-png", "tray-icon"] } 23 23 tauri-plugin-opener = "2" 24 24 tauri-plugin-global-shortcut = "2" 25 + tauri-plugin-dialog = "2" 26 + tauri-plugin-fs = "2" 25 27 serde = { version = "1", features = ["derive"] } 26 28 serde_json = "1" 27 29 reqwest = { version = "0.12.28", features = ["json"] } ··· 37 39 tauri-plugin-notification = "2" 38 40 thiserror = "2.0.18" 39 41 uuid = { version = "1", features = ["v4"] } 42 + dirs = "6.0.0" 40 43 41 44 # TODO: add this later 42 45 # [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+14 -2
src-tauri/capabilities/default.json
··· 10 10 "opener:default", 11 11 "deep-link:default", 12 12 "log:default", 13 - "notification:default" 13 + "notification:default", 14 + "dialog:default", 15 + { 16 + "identifier": "fs:default", 17 + "allow": [ 18 + { 19 + "path": "$DOWNLOAD" 20 + }, 21 + { 22 + "path": "$DOWNLOAD/*" 23 + } 24 + ] 25 + } 14 26 ] 15 - } 27 + }
+34
src-tauri/src/commands/media.rs
··· 1 + #![allow(clippy::needless_pass_by_value)] 2 + 3 + use crate::error::Result; 4 + use crate::media::{self, DownloadResult}; 5 + use crate::state::AppState; 6 + use tauri::{AppHandle, Emitter, State}; 7 + 8 + #[tauri::command] 9 + pub fn get_download_directory(state: State<'_, AppState>) -> Result<String> { 10 + media::get_download_directory(&state) 11 + } 12 + 13 + #[tauri::command] 14 + pub fn set_download_directory(path: String, state: State<'_, AppState>) -> Result<()> { 15 + media::set_download_directory(&path, &state) 16 + } 17 + 18 + #[tauri::command] 19 + pub async fn download_image( 20 + url: String, filename: Option<String>, state: State<'_, AppState>, 21 + ) -> Result<DownloadResult> { 22 + media::download_image(&url, filename.as_deref(), &state).await 23 + } 24 + 25 + #[tauri::command] 26 + pub async fn download_video( 27 + url: String, filename: Option<String>, app: AppHandle, state: State<'_, AppState>, 28 + ) -> Result<DownloadResult> { 29 + media::download_video(&url, filename.as_deref(), &state, |progress| { 30 + app.emit("download-progress", &progress)?; 31 + Ok(()) 32 + }) 33 + .await 34 + }
+1
src-tauri/src/commands/mod.rs
··· 12 12 pub mod diagnostics; 13 13 pub mod drafts; 14 14 pub mod explorer; 15 + pub mod media; 15 16 pub mod search; 16 17 pub mod settings; 17 18
+7
src-tauri/src/lib.rs
··· 10 10 mod error; 11 11 mod explorer; 12 12 mod feed; 13 + mod media; 13 14 mod notifications; 14 15 mod search; 15 16 mod settings; ··· 73 74 .build(), 74 75 ) 75 76 .plugin(tauri_plugin_deep_link::init()) 77 + .plugin(tauri_plugin_dialog::init()) 78 + .plugin(tauri_plugin_fs::init()) 76 79 .plugin(tauri_plugin_opener::init()) 77 80 .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 78 81 .invoke_handler(tauri::generate_handler![ ··· 140 143 cmd::settings::export_data, 141 144 cmd::settings::reset_app, 142 145 cmd::settings::get_log_entries, 146 + cmd::media::get_download_directory, 147 + cmd::media::set_download_directory, 148 + cmd::media::download_image, 149 + cmd::media::download_video, 143 150 cmd::diagnostics::get_account_lists, 144 151 cmd::diagnostics::get_account_labels, 145 152 cmd::diagnostics::get_account_blocked_by,
+946
src-tauri/src/media.rs
··· 1 + use super::error::{AppError, Result}; 2 + use super::settings::SettingsKey; 3 + use super::state::AppState; 4 + use reqwest::Url; 5 + use rusqlite::{params, Connection, OptionalExtension}; 6 + use serde::Serialize; 7 + use std::collections::HashMap; 8 + use std::fs::{self, OpenOptions}; 9 + use std::io::Write; 10 + use std::path::{Path, PathBuf}; 11 + use std::time::Duration; 12 + use tauri_plugin_log::log; 13 + use uuid::Uuid; 14 + 15 + const DOWNLOAD_HTTP_TIMEOUT: Duration = Duration::from_secs(45); 16 + 17 + #[derive(Debug, Clone, Serialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct DownloadResult { 20 + pub path: String, 21 + pub bytes: u64, 22 + } 23 + 24 + #[derive(Debug, Clone, Serialize)] 25 + #[serde(rename_all = "camelCase")] 26 + pub struct DownloadProgress { 27 + pub url: String, 28 + pub path: String, 29 + pub downloaded_bytes: u64, 30 + pub downloaded_segments: usize, 31 + pub total_segments: usize, 32 + pub complete: bool, 33 + } 34 + 35 + #[derive(Debug, Clone)] 36 + struct VariantPlaylist { 37 + uri: Url, 38 + bandwidth: u64, 39 + } 40 + 41 + #[derive(Debug, Clone)] 42 + struct MediaPlaylist { 43 + init_segment: Option<Url>, 44 + segments: Vec<Url>, 45 + } 46 + 47 + pub fn get_download_directory(state: &AppState) -> Result<String> { 48 + let conn = state.auth_store.lock_connection()?; 49 + let path = db_get_download_directory(&conn)?; 50 + Ok(path.to_string_lossy().into_owned()) 51 + } 52 + 53 + pub fn set_download_directory(path: &str, state: &AppState) -> Result<()> { 54 + let conn = state.auth_store.lock_connection()?; 55 + db_set_download_directory(&conn, path) 56 + } 57 + 58 + pub async fn download_image(url: &str, filename: Option<&str>, state: &AppState) -> Result<DownloadResult> { 59 + let download_directory = { 60 + let conn = state.auth_store.lock_connection()?; 61 + db_get_download_directory(&conn)? 62 + }; 63 + 64 + download_image_to_directory(url, filename, &download_directory).await 65 + } 66 + 67 + pub async fn download_video<F>( 68 + url: &str, filename: Option<&str>, state: &AppState, mut emitter: F, 69 + ) -> Result<DownloadResult> 70 + where 71 + F: FnMut(DownloadProgress) -> Result<()>, 72 + { 73 + let download_directory = { 74 + let conn = state.auth_store.lock_connection()?; 75 + db_get_download_directory(&conn)? 76 + }; 77 + 78 + download_video_to_directory(url, filename, &download_directory, &mut emitter).await 79 + } 80 + 81 + fn db_get_download_directory(conn: &Connection) -> Result<PathBuf> { 82 + let setting_key = SettingsKey::DownloadDirectory.to_string(); 83 + let persisted: Option<String> = conn 84 + .query_row( 85 + "SELECT value FROM app_settings WHERE key = ?1", 86 + params![setting_key], 87 + |row| row.get(0), 88 + ) 89 + .optional()?; 90 + 91 + match persisted { 92 + Some(path) => normalize_and_validate_directory(&path), 93 + None => default_download_directory_path(), 94 + } 95 + } 96 + 97 + fn db_set_download_directory(conn: &Connection, path: &str) -> Result<()> { 98 + let validated = normalize_and_validate_directory(path)?; 99 + let setting_key = SettingsKey::DownloadDirectory.to_string(); 100 + 101 + conn.execute( 102 + "INSERT INTO app_settings(key, value) VALUES(?1, ?2) 103 + ON CONFLICT(key) DO UPDATE SET value = excluded.value", 104 + params![setting_key, validated.to_string_lossy().into_owned()], 105 + )?; 106 + 107 + Ok(()) 108 + } 109 + 110 + async fn download_image_to_directory( 111 + url: &str, filename: Option<&str>, download_directory: &Path, 112 + ) -> Result<DownloadResult> { 113 + ensure_directory_is_writable(download_directory)?; 114 + 115 + let source_url = parse_http_url(url)?; 116 + let client = reqwest::Client::builder() 117 + .timeout(DOWNLOAD_HTTP_TIMEOUT) 118 + .build() 119 + .map_err(|error| { 120 + log::error!("failed to construct HTTP client for image download: {error}"); 121 + AppError::validation("Couldn't start the image download.") 122 + })?; 123 + 124 + let response = client.get(source_url.clone()).send().await.map_err(|error| { 125 + log::error!("image download request failed for {source_url}: {error}"); 126 + AppError::validation("Couldn't download the image right now.") 127 + })?; 128 + 129 + if !response.status().is_success() { 130 + log::warn!( 131 + "image download request returned non-success status {} for {}", 132 + response.status(), 133 + source_url 134 + ); 135 + return Err(AppError::validation( 136 + "Couldn't download the image because the server rejected the request.", 137 + )); 138 + } 139 + 140 + let content_type = response 141 + .headers() 142 + .get(reqwest::header::CONTENT_TYPE) 143 + .and_then(|header| header.to_str().ok()) 144 + .map(str::to_string); 145 + let bytes = response.bytes().await.map_err(|error| { 146 + log::error!("failed to read image response body for {source_url}: {error}"); 147 + AppError::validation("Couldn't read the downloaded image data.") 148 + })?; 149 + 150 + let default_extension = content_type 151 + .as_deref() 152 + .and_then(extension_from_image_content_type) 153 + .unwrap_or("jpg"); 154 + let output_name = build_filename(&source_url, filename, "image", Some(default_extension)); 155 + let output_path = resolve_unique_path(download_directory, &output_name); 156 + 157 + fs::write(&output_path, &bytes).map_err(|error| { 158 + log::error!("failed to write image download to {}: {error}", output_path.display()); 159 + AppError::validation("Couldn't save the image. Check that your download folder exists and is writable.") 160 + })?; 161 + 162 + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: bytes.len() as u64 }) 163 + } 164 + 165 + async fn download_video_to_directory<F>( 166 + url: &str, filename: Option<&str>, download_directory: &Path, emit_progress: &mut F, 167 + ) -> Result<DownloadResult> 168 + where 169 + F: FnMut(DownloadProgress) -> Result<()>, 170 + { 171 + ensure_directory_is_writable(download_directory)?; 172 + 173 + let source_url = parse_http_url(url)?; 174 + let client = reqwest::Client::builder() 175 + .timeout(DOWNLOAD_HTTP_TIMEOUT) 176 + .build() 177 + .map_err(|error| { 178 + log::error!("failed to construct HTTP client for video download: {error}"); 179 + AppError::validation("Couldn't start the video download.") 180 + })?; 181 + 182 + let manifest = fetch_text(&client, &source_url, "video playlist").await?; 183 + let variants = parse_master_variants(&source_url, &manifest)?; 184 + let (playlist_url, media_playlist_body) = 185 + if let Some(variant) = variants.iter().max_by_key(|variant| variant.bandwidth) { 186 + let body = fetch_text(&client, &variant.uri, "video variant playlist").await?; 187 + (variant.uri.clone(), body) 188 + } else { 189 + (source_url.clone(), manifest) 190 + }; 191 + 192 + let playlist = parse_media_playlist(&playlist_url, &media_playlist_body)?; 193 + 194 + let output_name = build_filename(&playlist_url, filename, "video", Some("mp4")); 195 + let output_path = resolve_unique_path(download_directory, &output_name); 196 + let mut output_file = OpenOptions::new() 197 + .write(true) 198 + .create_new(true) 199 + .open(&output_path) 200 + .map_err(|error| { 201 + log::error!("failed to create output video file {}: {error}", output_path.display()); 202 + AppError::validation("Couldn't create a file in your download folder.") 203 + })?; 204 + 205 + let mut downloaded_bytes: u64 = 0; 206 + let total_segments = playlist.segments.len(); 207 + 208 + maybe_emit_progress( 209 + emit_progress, 210 + DownloadProgress { 211 + url: source_url.to_string(), 212 + path: output_path.to_string_lossy().into_owned(), 213 + downloaded_bytes, 214 + downloaded_segments: 0, 215 + total_segments, 216 + complete: false, 217 + }, 218 + ); 219 + 220 + let write_result = async { 221 + if let Some(init_segment_url) = &playlist.init_segment { 222 + let init_bytes = fetch_binary(&client, init_segment_url, "video init segment").await?; 223 + output_file.write_all(&init_bytes).map_err(|error| { 224 + log::error!( 225 + "failed to write video init segment to {}: {error}", 226 + output_path.display() 227 + ); 228 + AppError::validation("Couldn't write the video to disk.") 229 + })?; 230 + downloaded_bytes += init_bytes.len() as u64; 231 + } 232 + 233 + for (index, segment_url) in playlist.segments.iter().enumerate() { 234 + let segment = fetch_binary(&client, segment_url, "video segment").await?; 235 + output_file.write_all(&segment).map_err(|error| { 236 + log::error!( 237 + "failed to write video segment {} to {}: {error}", 238 + segment_url, 239 + output_path.display() 240 + ); 241 + AppError::validation("Couldn't write the video to disk.") 242 + })?; 243 + downloaded_bytes += segment.len() as u64; 244 + 245 + maybe_emit_progress( 246 + emit_progress, 247 + DownloadProgress { 248 + url: source_url.to_string(), 249 + path: output_path.to_string_lossy().into_owned(), 250 + downloaded_bytes, 251 + downloaded_segments: index + 1, 252 + total_segments, 253 + complete: false, 254 + }, 255 + ); 256 + } 257 + 258 + output_file.flush().map_err(|error| { 259 + log::error!("failed to flush output video file {}: {error}", output_path.display()); 260 + AppError::validation("Couldn't finish writing the video to disk.") 261 + })?; 262 + 263 + Ok::<(), AppError>(()) 264 + } 265 + .await; 266 + 267 + if let Err(error) = write_result { 268 + if let Err(cleanup_error) = fs::remove_file(&output_path) { 269 + log::warn!( 270 + "failed to delete partial video download {}: {cleanup_error}", 271 + output_path.display() 272 + ); 273 + } 274 + return Err(error); 275 + } 276 + 277 + maybe_emit_progress( 278 + emit_progress, 279 + DownloadProgress { 280 + url: source_url.to_string(), 281 + path: output_path.to_string_lossy().into_owned(), 282 + downloaded_bytes, 283 + downloaded_segments: total_segments, 284 + total_segments, 285 + complete: true, 286 + }, 287 + ); 288 + 289 + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: downloaded_bytes }) 290 + } 291 + 292 + fn maybe_emit_progress<F>(emit_progress: &mut F, payload: DownloadProgress) 293 + where 294 + F: FnMut(DownloadProgress) -> Result<()>, 295 + { 296 + if let Err(error) = emit_progress(payload) { 297 + log::warn!("failed to emit download-progress event: {error}"); 298 + } 299 + } 300 + 301 + async fn fetch_text(client: &reqwest::Client, url: &Url, label: &str) -> Result<String> { 302 + let response = client.get(url.clone()).send().await.map_err(|error| { 303 + log::error!("failed to fetch {label} {url}: {error}"); 304 + AppError::validation("Couldn't download the video playlist.") 305 + })?; 306 + 307 + if !response.status().is_success() { 308 + log::warn!("{label} request for {url} returned status {}", response.status()); 309 + return Err(AppError::validation( 310 + "Couldn't download the video playlist from the server.", 311 + )); 312 + } 313 + 314 + response.text().await.map_err(|error| { 315 + log::error!("failed to read {label} response body for {url}: {error}"); 316 + AppError::validation("Couldn't read the video playlist data.") 317 + }) 318 + } 319 + 320 + async fn fetch_binary(client: &reqwest::Client, url: &Url, label: &str) -> Result<Vec<u8>> { 321 + let response = client.get(url.clone()).send().await.map_err(|error| { 322 + log::error!("failed to fetch {label} {url}: {error}"); 323 + AppError::validation("Couldn't download part of the video.") 324 + })?; 325 + 326 + if !response.status().is_success() { 327 + log::warn!("{label} request for {url} returned status {}", response.status()); 328 + return Err(AppError::validation( 329 + "Couldn't download part of the video from the server.", 330 + )); 331 + } 332 + 333 + response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| { 334 + log::error!("failed to read {label} response body for {url}: {error}"); 335 + AppError::validation("Couldn't read part of the downloaded video.") 336 + }) 337 + } 338 + 339 + fn parse_master_variants(base_url: &Url, manifest: &str) -> Result<Vec<VariantPlaylist>> { 340 + let mut variants = Vec::new(); 341 + let mut pending_bandwidth: Option<u64> = None; 342 + 343 + for raw_line in manifest.lines() { 344 + let line = raw_line.trim(); 345 + if line.is_empty() { 346 + continue; 347 + } 348 + 349 + if let Some(attributes) = line.strip_prefix("#EXT-X-STREAM-INF:") { 350 + let parsed = parse_m3u8_attributes(attributes); 351 + let bandwidth = parsed 352 + .get("BANDWIDTH") 353 + .and_then(|value| value.parse::<u64>().ok()) 354 + .unwrap_or(0); 355 + pending_bandwidth = Some(bandwidth); 356 + continue; 357 + } 358 + 359 + if let Some(bandwidth) = pending_bandwidth.take() { 360 + if line.starts_with('#') { 361 + continue; 362 + } 363 + 364 + let uri = resolve_manifest_url(base_url, line)?; 365 + variants.push(VariantPlaylist { uri, bandwidth }); 366 + } 367 + } 368 + 369 + Ok(variants) 370 + } 371 + 372 + fn parse_media_playlist(base_url: &Url, playlist: &str) -> Result<MediaPlaylist> { 373 + let mut init_segment: Option<Url> = None; 374 + let mut segments: Vec<Url> = Vec::new(); 375 + 376 + for raw_line in playlist.lines() { 377 + let line = raw_line.trim(); 378 + if line.is_empty() { 379 + continue; 380 + } 381 + 382 + if let Some(attributes) = line.strip_prefix("#EXT-X-MAP:") { 383 + let parsed = parse_m3u8_attributes(attributes); 384 + if let Some(uri) = parsed.get("URI") { 385 + init_segment = Some(resolve_manifest_url(base_url, uri)?); 386 + } 387 + continue; 388 + } 389 + 390 + if let Some(attributes) = line.strip_prefix("#EXT-X-KEY:") { 391 + let parsed = parse_m3u8_attributes(attributes); 392 + let method = parsed 393 + .get("METHOD") 394 + .map(String::as_str) 395 + .unwrap_or("NONE") 396 + .to_ascii_uppercase(); 397 + if method != "NONE" { 398 + return Err(AppError::validation( 399 + "This video stream is encrypted and can't be downloaded yet.", 400 + )); 401 + } 402 + continue; 403 + } 404 + 405 + if line.starts_with('#') { 406 + continue; 407 + } 408 + 409 + segments.push(resolve_manifest_url(base_url, line)?); 410 + } 411 + 412 + if segments.is_empty() { 413 + return Err(AppError::validation( 414 + "The video playlist did not contain any downloadable segments.", 415 + )); 416 + } 417 + 418 + Ok(MediaPlaylist { init_segment, segments }) 419 + } 420 + 421 + fn parse_m3u8_attributes(raw: &str) -> HashMap<String, String> { 422 + let mut attributes = HashMap::new(); 423 + let mut current = String::new(); 424 + let mut in_quotes = false; 425 + 426 + for character in raw.chars() { 427 + match character { 428 + '"' => { 429 + in_quotes = !in_quotes; 430 + current.push(character); 431 + } 432 + ',' if !in_quotes => { 433 + if let Some((key, value)) = parse_m3u8_attribute_chunk(&current) { 434 + attributes.insert(key, value); 435 + } 436 + current.clear(); 437 + } 438 + _ => current.push(character), 439 + } 440 + } 441 + 442 + if let Some((key, value)) = parse_m3u8_attribute_chunk(&current) { 443 + attributes.insert(key, value); 444 + } 445 + 446 + attributes 447 + } 448 + 449 + fn parse_m3u8_attribute_chunk(chunk: &str) -> Option<(String, String)> { 450 + let (key, value) = chunk.split_once('=')?; 451 + let normalized_key = key.trim().to_ascii_uppercase(); 452 + if normalized_key.is_empty() { 453 + return None; 454 + } 455 + 456 + let normalized_value = value.trim().trim_matches('"').to_string(); 457 + Some((normalized_key, normalized_value)) 458 + } 459 + 460 + fn resolve_manifest_url(base_url: &Url, candidate: &str) -> Result<Url> { 461 + let trimmed = candidate.trim(); 462 + if trimmed.is_empty() { 463 + return Err(AppError::validation( 464 + "The video playlist referenced an empty segment URL.", 465 + )); 466 + } 467 + 468 + let url = Url::parse(trimmed) 469 + .or_else(|_| base_url.join(trimmed)) 470 + .map_err(|error| { 471 + log::error!("failed to resolve manifest URL '{trimmed}' against {base_url}: {error}"); 472 + AppError::validation("The video playlist contained an invalid segment URL.") 473 + })?; 474 + 475 + ensure_http_url(&url)?; 476 + Ok(url) 477 + } 478 + 479 + fn parse_http_url(raw_url: &str) -> Result<Url> { 480 + let trimmed = raw_url.trim(); 481 + if trimmed.is_empty() { 482 + return Err(AppError::validation("A download URL is required.")); 483 + } 484 + 485 + let url = Url::parse(trimmed).map_err(|error| { 486 + log::error!("failed to parse download URL '{trimmed}': {error}"); 487 + AppError::validation("The download URL is not valid.") 488 + })?; 489 + 490 + ensure_http_url(&url)?; 491 + Ok(url) 492 + } 493 + 494 + fn ensure_http_url(url: &Url) -> Result<()> { 495 + if matches!(url.scheme(), "http" | "https") { 496 + Ok(()) 497 + } else { 498 + Err(AppError::validation( 499 + "Only http:// and https:// download URLs are supported.", 500 + )) 501 + } 502 + } 503 + 504 + fn normalize_and_validate_directory(path: &str) -> Result<PathBuf> { 505 + let expanded = expand_tilde(path.trim()); 506 + ensure_directory_is_writable(&expanded)?; 507 + 508 + fs::canonicalize(&expanded).map_err(|error| { 509 + log::error!( 510 + "failed to canonicalize download directory {}: {error}", 511 + expanded.display() 512 + ); 513 + AppError::validation("Couldn't resolve the selected download folder.") 514 + }) 515 + } 516 + 517 + fn ensure_directory_is_writable(directory: &Path) -> Result<()> { 518 + if !directory.exists() { 519 + return Err(AppError::validation("The download folder does not exist.")); 520 + } 521 + 522 + if !directory.is_dir() { 523 + return Err(AppError::validation("The download folder must be a directory.")); 524 + } 525 + 526 + let probe_path = directory.join(format!(".lazurite-write-check-{}", Uuid::new_v4())); 527 + let mut probe_file = OpenOptions::new() 528 + .write(true) 529 + .create_new(true) 530 + .open(&probe_path) 531 + .map_err(|error| { 532 + log::warn!("download directory {} is not writable: {error}", directory.display()); 533 + AppError::validation("The download folder is not writable.") 534 + })?; 535 + 536 + probe_file.write_all(b"ok").map_err(|error| { 537 + log::warn!( 538 + "failed to write probe file {} for download directory {}: {error}", 539 + probe_path.display(), 540 + directory.display() 541 + ); 542 + AppError::validation("The download folder is not writable.") 543 + })?; 544 + 545 + if let Err(error) = fs::remove_file(&probe_path) { 546 + log::warn!( 547 + "failed to remove probe file {} for download directory {}: {error}", 548 + probe_path.display(), 549 + directory.display() 550 + ); 551 + } 552 + 553 + Ok(()) 554 + } 555 + 556 + fn default_download_directory_path() -> Result<PathBuf> { 557 + let Some(path) = dirs::download_dir().or_else(|| dirs::home_dir().map(|home| home.join("Downloads"))) else { 558 + return Err(AppError::validation( 559 + "Couldn't locate a default Downloads folder on this system.", 560 + )); 561 + }; 562 + 563 + normalize_and_validate_directory(&path.to_string_lossy()) 564 + } 565 + 566 + fn expand_tilde(path: &str) -> PathBuf { 567 + if path == "~" { 568 + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)); 569 + } 570 + 571 + if let Some(rest) = path.strip_prefix("~/") { 572 + if let Some(home) = dirs::home_dir() { 573 + return home.join(rest); 574 + } 575 + } 576 + 577 + PathBuf::from(path) 578 + } 579 + 580 + fn extension_from_image_content_type(content_type: &str) -> Option<&'static str> { 581 + let normalized = content_type 582 + .split(';') 583 + .next() 584 + .unwrap_or(content_type) 585 + .trim() 586 + .to_ascii_lowercase(); 587 + 588 + match normalized.as_str() { 589 + "image/jpeg" | "image/jpg" => Some("jpg"), 590 + "image/png" => Some("png"), 591 + "image/webp" => Some("webp"), 592 + "image/gif" => Some("gif"), 593 + "image/avif" => Some("avif"), 594 + "image/svg+xml" => Some("svg"), 595 + "image/bmp" => Some("bmp"), 596 + _ => None, 597 + } 598 + } 599 + 600 + fn build_filename(source_url: &Url, requested: Option<&str>, default_stem: &str, default_ext: Option<&str>) -> String { 601 + let requested_name = requested 602 + .map(str::trim) 603 + .filter(|name| !name.is_empty()) 604 + .map(sanitize_filename) 605 + .filter(|name| !name.is_empty()); 606 + 607 + let mut filename = requested_name.unwrap_or_else(|| { 608 + let derived = source_url 609 + .path_segments() 610 + .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) 611 + .unwrap_or(default_stem); 612 + sanitize_filename(derived) 613 + }); 614 + 615 + if filename.is_empty() { 616 + filename = default_stem.to_string(); 617 + } 618 + 619 + if Path::new(&filename).extension().is_none() { 620 + if let Some(extension) = default_ext.filter(|extension| !extension.is_empty()) { 621 + filename.push('.'); 622 + filename.push_str(extension); 623 + } 624 + } 625 + 626 + filename 627 + } 628 + 629 + fn sanitize_filename(raw: &str) -> String { 630 + let basename = Path::new(raw) 631 + .file_name() 632 + .and_then(|name| name.to_str()) 633 + .unwrap_or(raw) 634 + .trim(); 635 + 636 + basename 637 + .chars() 638 + .map(|character| match character { 639 + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', 640 + _ if character.is_control() => '_', 641 + _ => character, 642 + }) 643 + .collect::<String>() 644 + .trim() 645 + .trim_matches('.') 646 + .to_string() 647 + } 648 + 649 + fn resolve_unique_path(directory: &Path, filename: &str) -> PathBuf { 650 + let safe_filename = sanitize_filename(filename); 651 + let fallback_name = if safe_filename.is_empty() { "download.bin".to_string() } else { safe_filename }; 652 + 653 + let candidate = directory.join(&fallback_name); 654 + if !candidate.exists() { 655 + return candidate; 656 + } 657 + 658 + let path = Path::new(&fallback_name); 659 + let stem = path 660 + .file_stem() 661 + .and_then(|value| value.to_str()) 662 + .filter(|value| !value.is_empty()) 663 + .unwrap_or("download"); 664 + let extension = path.extension().and_then(|value| value.to_str()); 665 + 666 + for suffix in 1..10_000 { 667 + let numbered = match extension { 668 + Some(extension) if !extension.is_empty() => format!("{stem}_{suffix}.{extension}"), 669 + _ => format!("{stem}_{suffix}"), 670 + }; 671 + 672 + let numbered_path = directory.join(numbered); 673 + if !numbered_path.exists() { 674 + return numbered_path; 675 + } 676 + } 677 + 678 + directory.join(format!("{}_{}", stem, Uuid::new_v4())) 679 + } 680 + 681 + #[cfg(test)] 682 + mod tests { 683 + use super::*; 684 + use std::collections::HashMap; 685 + use std::io::{Read, Write}; 686 + use std::net::{SocketAddr, TcpListener}; 687 + use std::sync::mpsc; 688 + use std::thread; 689 + 690 + #[derive(Clone)] 691 + struct TestResponse { 692 + status_line: &'static str, 693 + content_type: &'static str, 694 + body: Vec<u8>, 695 + } 696 + 697 + struct TestServer { 698 + address: SocketAddr, 699 + shutdown_tx: mpsc::Sender<()>, 700 + handle: Option<thread::JoinHandle<()>>, 701 + } 702 + 703 + impl TestServer { 704 + fn url(&self, path: &str) -> String { 705 + format!("http://{}{}", self.address, path) 706 + } 707 + } 708 + 709 + impl Drop for TestServer { 710 + fn drop(&mut self) { 711 + let _ = self.shutdown_tx.send(()); 712 + if let Some(handle) = self.handle.take() { 713 + let _ = handle.join(); 714 + } 715 + } 716 + } 717 + 718 + fn start_test_server(routes: HashMap<String, TestResponse>) -> TestServer { 719 + let listener = TcpListener::bind("127.0.0.1:0").expect("test server should bind"); 720 + listener 721 + .set_nonblocking(true) 722 + .expect("test server listener should be nonblocking"); 723 + let address = listener.local_addr().expect("test server should expose local address"); 724 + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); 725 + 726 + let handle = thread::spawn(move || loop { 727 + if shutdown_rx.try_recv().is_ok() { 728 + break; 729 + } 730 + 731 + match listener.accept() { 732 + Ok((mut stream, _)) => { 733 + let mut buffer = [0_u8; 4096]; 734 + let read = match stream.read(&mut buffer) { 735 + Ok(read) if read > 0 => read, 736 + _ => continue, 737 + }; 738 + 739 + let request_line = String::from_utf8_lossy(&buffer[..read]); 740 + let target = request_line 741 + .lines() 742 + .next() 743 + .and_then(|line| line.split_whitespace().nth(1)) 744 + .unwrap_or("/") 745 + .split('?') 746 + .next() 747 + .unwrap_or("/") 748 + .to_string(); 749 + 750 + let response = routes.get(&target).cloned().unwrap_or(TestResponse { 751 + status_line: "HTTP/1.1 404 Not Found", 752 + content_type: "text/plain", 753 + body: b"not found".to_vec(), 754 + }); 755 + 756 + let headers = format!( 757 + "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 758 + response.status_line, 759 + response.content_type, 760 + response.body.len() 761 + ); 762 + 763 + if stream.write_all(headers.as_bytes()).is_ok() { 764 + let _ = stream.write_all(&response.body); 765 + } 766 + } 767 + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 768 + thread::sleep(Duration::from_millis(5)); 769 + } 770 + Err(_) => break, 771 + } 772 + }); 773 + 774 + TestServer { address, shutdown_tx, handle: Some(handle) } 775 + } 776 + 777 + fn settings_db() -> Connection { 778 + let conn = Connection::open_in_memory().expect("in-memory db should open"); 779 + conn.execute_batch(include_str!("migrations/006_app_settings.sql")) 780 + .expect("settings migration should apply"); 781 + conn 782 + } 783 + 784 + fn temp_directory() -> PathBuf { 785 + let path = std::env::temp_dir().join(format!("lazurite-media-tests-{}", Uuid::new_v4())); 786 + fs::create_dir_all(&path).expect("temporary directory should be created"); 787 + path 788 + } 789 + 790 + #[test] 791 + fn set_download_directory_persists_value() { 792 + let conn = settings_db(); 793 + let directory = temp_directory(); 794 + 795 + db_set_download_directory(&conn, directory.to_str().expect("path should be utf-8")) 796 + .expect("set download directory should succeed"); 797 + let resolved = db_get_download_directory(&conn).expect("get download directory should succeed"); 798 + 799 + assert_eq!( 800 + resolved, 801 + fs::canonicalize(directory).expect("directory should canonicalize") 802 + ); 803 + } 804 + 805 + #[test] 806 + fn set_download_directory_rejects_missing_path() { 807 + let conn = settings_db(); 808 + let missing = std::env::temp_dir().join(format!("lazurite-missing-{}", Uuid::new_v4())); 809 + 810 + let error = db_set_download_directory(&conn, missing.to_str().expect("path should be utf-8")) 811 + .expect_err("missing path should be rejected"); 812 + 813 + assert!( 814 + error.to_string().contains("download folder"), 815 + "error should describe invalid folder" 816 + ); 817 + } 818 + 819 + #[test] 820 + fn resolve_unique_path_appends_numeric_suffixes() { 821 + let directory = temp_directory(); 822 + let first = directory.join("image.jpg"); 823 + let second = directory.join("image_1.jpg"); 824 + fs::write(&first, b"first").expect("first file should be written"); 825 + fs::write(&second, b"second").expect("second file should be written"); 826 + 827 + let resolved = resolve_unique_path(&directory, "image.jpg"); 828 + assert_eq!(resolved.file_name().and_then(|name| name.to_str()), Some("image_2.jpg")); 829 + } 830 + 831 + #[test] 832 + fn parse_master_variants_extracts_bandwidth_and_urls() { 833 + let base_url = Url::parse("https://example.com/path/master.m3u8").expect("url should parse"); 834 + let manifest = 835 + "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1280000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2560000\nhigh.m3u8\n"; 836 + 837 + let variants = parse_master_variants(&base_url, manifest).expect("master playlist should parse"); 838 + 839 + assert_eq!(variants.len(), 2); 840 + assert_eq!(variants[0].bandwidth, 1_280_000); 841 + assert_eq!(variants[1].bandwidth, 2_560_000); 842 + assert_eq!(variants[1].uri.as_str(), "https://example.com/path/high.m3u8"); 843 + } 844 + 845 + #[test] 846 + fn parse_media_playlist_extracts_segments_and_init_map() { 847 + let base_url = Url::parse("https://cdn.example.com/video/index.m3u8").expect("url should parse"); 848 + let playlist = "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:1.0,\nseg-1.ts\n#EXTINF:1.0,\nseg-2.ts\n"; 849 + 850 + let parsed = parse_media_playlist(&base_url, playlist).expect("media playlist should parse"); 851 + 852 + assert_eq!(parsed.segments.len(), 2); 853 + assert_eq!( 854 + parsed.init_segment.as_ref().map(Url::as_str), 855 + Some("https://cdn.example.com/video/init.mp4") 856 + ); 857 + assert_eq!(parsed.segments[0].as_str(), "https://cdn.example.com/video/seg-1.ts"); 858 + } 859 + 860 + #[tokio::test] 861 + async fn download_image_writes_file_to_target_directory() { 862 + let server = start_test_server(HashMap::from([( 863 + "/image.jpg".to_string(), 864 + TestResponse { status_line: "HTTP/1.1 200 OK", content_type: "image/jpeg", body: b"fake-jpeg".to_vec() }, 865 + )])); 866 + let directory = temp_directory(); 867 + 868 + let result = download_image_to_directory(&server.url("/image.jpg"), None, &directory) 869 + .await 870 + .expect("image download should succeed"); 871 + 872 + assert_eq!(result.bytes, 9); 873 + assert!(result.path.ends_with("image.jpg")); 874 + assert_eq!( 875 + fs::read(result.path).expect("downloaded image should be readable"), 876 + b"fake-jpeg" 877 + ); 878 + } 879 + 880 + #[tokio::test] 881 + async fn download_video_downloads_highest_bandwidth_variant_and_emits_progress() { 882 + let server = start_test_server(HashMap::from([ 883 + ( 884 + "/master.m3u8".to_string(), 885 + TestResponse { 886 + status_line: "HTTP/1.1 200 OK", 887 + content_type: "application/vnd.apple.mpegurl", 888 + body: b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=64000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=128000\nhigh.m3u8\n" 889 + .to_vec(), 890 + }, 891 + ), 892 + ( 893 + "/high.m3u8".to_string(), 894 + TestResponse { 895 + status_line: "HTTP/1.1 200 OK", 896 + content_type: "application/vnd.apple.mpegurl", 897 + body: b"#EXTM3U\n#EXTINF:1.0,\nseg-a.ts\n#EXTINF:1.0,\nseg-b.ts\n".to_vec(), 898 + }, 899 + ), 900 + ( 901 + "/seg-a.ts".to_string(), 902 + TestResponse { 903 + status_line: "HTTP/1.1 200 OK", 904 + content_type: "video/mp2t", 905 + body: b"segment-a".to_vec(), 906 + }, 907 + ), 908 + ( 909 + "/seg-b.ts".to_string(), 910 + TestResponse { 911 + status_line: "HTTP/1.1 200 OK", 912 + content_type: "video/mp2t", 913 + body: b"segment-b".to_vec(), 914 + }, 915 + ), 916 + ])); 917 + let directory = temp_directory(); 918 + let mut progress_events = Vec::new(); 919 + 920 + let result = download_video_to_directory( 921 + &server.url("/master.m3u8"), 922 + Some("clip.mp4"), 923 + &directory, 924 + &mut |progress| { 925 + progress_events.push(progress); 926 + Ok(()) 927 + }, 928 + ) 929 + .await 930 + .expect("video download should succeed"); 931 + 932 + assert!(result.path.ends_with("clip.mp4")); 933 + assert_eq!(result.bytes, (b"segment-a".len() + b"segment-b".len()) as u64); 934 + 935 + let final_progress = progress_events 936 + .last() 937 + .expect("at least one progress event should be emitted"); 938 + assert!(final_progress.complete); 939 + assert_eq!(final_progress.downloaded_segments, 2); 940 + assert_eq!(final_progress.total_segments, 2); 941 + assert_eq!( 942 + fs::read(result.path).expect("downloaded video should be readable"), 943 + b"segment-asegment-b" 944 + ); 945 + } 946 + }
+110 -1
src-tauri/src/settings.rs
··· 9 9 use rusqlite::{params, Connection}; 10 10 use serde::Serialize; 11 11 use std::fs; 12 - use std::io::{BufRead, BufReader}; 12 + use std::io::{BufRead, BufReader, Write}; 13 13 use std::path::{Path, PathBuf}; 14 14 use tauri::{AppHandle, Manager}; 15 15 use tauri_plugin_global_shortcut::Shortcut; 16 16 use tauri_plugin_log::log; 17 + use uuid::Uuid; 17 18 18 19 const APP_DEFAULT_THEME: &str = "auto"; 19 20 const APP_DEFAULT_TIMELINE_REFRESH_SECS: u32 = 60; ··· 34 35 SpacedustInstant, 35 36 SpacedustEnabled, 36 37 GlobalShortcut, 38 + DownloadDirectory, 37 39 } 38 40 39 41 impl SettingsKey { ··· 50 52 SettingsKey::SpacedustInstant => "spacedust_instant", 51 53 SettingsKey::SpacedustEnabled => "spacedust_enabled", 52 54 SettingsKey::GlobalShortcut => "global_shortcut", 55 + SettingsKey::DownloadDirectory => "download_directory", 53 56 } 54 57 } 55 58 ··· 66 69 "spacedust_instant" => Some(Self::SpacedustInstant), 67 70 "spacedust_enabled" => Some(Self::SpacedustEnabled), 68 71 "global_shortcut" => Some(Self::GlobalShortcut), 72 + "download_directory" => Some(Self::DownloadDirectory), 69 73 _ => None, 70 74 } 71 75 } ··· 83 87 Self::SpacedustInstant, 84 88 Self::SpacedustEnabled, 85 89 Self::GlobalShortcut, 90 + Self::DownloadDirectory, 86 91 ] 87 92 } 88 93 } ··· 107 112 pub spacedust_instant: bool, 108 113 pub spacedust_enabled: bool, 109 114 pub global_shortcut: String, 115 + pub download_directory: String, 110 116 } 111 117 112 118 impl Default for AppSettings { ··· 123 129 spacedust_instant: false, 124 130 spacedust_enabled: false, 125 131 global_shortcut: APP_DEFAULT_GLOBAL_SHORTCUT.to_string(), 132 + download_directory: default_download_directory_value(), 126 133 } 127 134 } 128 135 } ··· 212 219 Ok(trimmed.to_string()) 213 220 } 214 221 222 + fn default_download_directory_value() -> String { 223 + dirs::download_dir() 224 + .or_else(|| dirs::home_dir().map(|home| home.join("Downloads"))) 225 + .unwrap_or_else(|| PathBuf::from(".")) 226 + .to_string_lossy() 227 + .into_owned() 228 + } 229 + 230 + fn expand_tilde(path: &str) -> PathBuf { 231 + if path == "~" { 232 + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)); 233 + } 234 + 235 + if let Some(rest) = path.strip_prefix("~/") { 236 + if let Some(home) = dirs::home_dir() { 237 + return home.join(rest); 238 + } 239 + } 240 + 241 + PathBuf::from(path) 242 + } 243 + 244 + fn normalize_download_directory_value(value: &str) -> Result<String> { 245 + let trimmed = value.trim(); 246 + if trimmed.is_empty() { 247 + return Err(AppError::validation("download_directory must not be empty")); 248 + } 249 + 250 + let expanded = expand_tilde(trimmed); 251 + if !expanded.exists() { 252 + return Err(AppError::validation("download_directory must exist")); 253 + } 254 + 255 + if !expanded.is_dir() { 256 + return Err(AppError::validation("download_directory must be a directory")); 257 + } 258 + 259 + let probe_path = expanded.join(format!(".lazurite-download-check-{}", Uuid::new_v4())); 260 + let mut probe_file = fs::OpenOptions::new() 261 + .write(true) 262 + .create_new(true) 263 + .open(&probe_path) 264 + .map_err(|error| { 265 + log::warn!("download_directory '{}' is not writable: {error}", expanded.display()); 266 + AppError::validation("download_directory must be writable") 267 + })?; 268 + probe_file.write_all(b"ok").map_err(|error| { 269 + log::warn!( 270 + "download_directory '{}' probe write failed: {error}", 271 + expanded.display() 272 + ); 273 + AppError::validation("download_directory must be writable") 274 + })?; 275 + if let Err(error) = fs::remove_file(&probe_path) { 276 + log::warn!( 277 + "failed to remove download_directory probe file '{}': {error}", 278 + probe_path.display() 279 + ); 280 + } 281 + 282 + let canonical = fs::canonicalize(&expanded).map_err(|error| { 283 + log::warn!( 284 + "failed to canonicalize download_directory '{}': {error}", 285 + expanded.display() 286 + ); 287 + AppError::validation("download_directory must be a valid path") 288 + })?; 289 + 290 + Ok(canonical.to_string_lossy().into_owned()) 291 + } 292 + 215 293 fn validate_and_normalize_setting(key: SettingsKey, value: &str) -> Result<String> { 216 294 match key { 217 295 SettingsKey::Theme => { ··· 230 308 | SettingsKey::SpacedustEnabled => normalize_bool_value(value, &key), 231 309 SettingsKey::ConstellationUrl | SettingsKey::SpacedustUrl => validate_url_setting(&key, value), 232 310 SettingsKey::GlobalShortcut => validate_global_shortcut_value(value), 311 + SettingsKey::DownloadDirectory => normalize_download_directory_value(value), 233 312 } 234 313 } 235 314 ··· 250 329 SettingsKey::SpacedustInstant => settings.spacedust_instant = parse_bool(&value), 251 330 SettingsKey::SpacedustEnabled => settings.spacedust_enabled = parse_bool(&value), 252 331 SettingsKey::GlobalShortcut => settings.global_shortcut = value, 332 + SettingsKey::DownloadDirectory => settings.download_directory = value, 253 333 } 254 334 } 255 335 ··· 735 815 assert!(!settings.spacedust_instant); 736 816 assert!(!settings.spacedust_enabled); 737 817 assert_eq!(settings.global_shortcut, "Ctrl+Shift+N"); 818 + assert_eq!(settings.download_directory, default_download_directory_value()); 738 819 } 739 820 740 821 #[test] ··· 839 920 let error = validate_and_normalize_setting(SettingsKey::GlobalShortcut, "not-a-shortcut") 840 921 .expect_err("invalid shortcut should reject"); 841 922 assert!(error.to_string().contains("global_shortcut")); 923 + } 924 + 925 + #[test] 926 + fn download_directory_values_are_validated() { 927 + let temp_directory = std::env::temp_dir().join(format!("lazurite-settings-download-{}", Uuid::new_v4())); 928 + fs::create_dir_all(&temp_directory).expect("temporary directory should be created"); 929 + 930 + let normalized = validate_and_normalize_setting( 931 + SettingsKey::DownloadDirectory, 932 + temp_directory.to_str().expect("path should be utf-8"), 933 + ) 934 + .expect("download directory should validate"); 935 + assert_eq!( 936 + normalized, 937 + fs::canonicalize(&temp_directory) 938 + .expect("temp directory should canonicalize") 939 + .to_string_lossy() 940 + ); 941 + 942 + let missing_path = std::env::temp_dir().join(format!("lazurite-settings-missing-{}", Uuid::new_v4())); 943 + let error = validate_and_normalize_setting( 944 + SettingsKey::DownloadDirectory, 945 + missing_path.to_str().expect("path should be utf-8"), 946 + ) 947 + .expect_err("missing path should be rejected"); 948 + assert!(error.to_string().contains("download_directory")); 949 + 950 + let _ = fs::remove_dir_all(temp_directory); 842 951 } 843 952 844 953 #[test]