feat(hardening): production logging, single-instance, CSP, and scoped asset protocol

Phase 4 of the 0.1.0 release prep:
- tauri-plugin-log: rotating file log (5 MB, Info) in the app log dir plus
  stdout; all 54 backend println!/eprintln! sites migrated to log macros so
  release builds finally produce diagnostics
- tauri-plugin-single-instance (registered first): second launches focus the
  existing window instead of racing the same SQLite DB with a second worker
  fleet; tauri-plugin-window-state persists window geometry
- real CSP replacing null (scripts self-only, media/images via the asset
  protocol, IPC endpoints whitelisted, object-src none) with a devCsp
  variant for Vite HMR
- asset protocol scope narrowed from '**' to thumbnails statically plus
  per-folder runtime grants (setup, add_folder, update_folder_path)

Panic audit (plan item) concluded with no changes: worker loops already
catch and log all Result errors; remaining unwraps are startup fail-fast,
poisoned-lock convention, infallible-by-construction, or test code.
This commit is contained in:
2026-06-12 20:12:03 +01:00
parent 890c23bdce
commit 3fb4a9685f
10 changed files with 454 additions and 61 deletions
+9 -9
View File
@@ -470,7 +470,7 @@ impl FlorenceCaptioner {
acceleration,
false,
)?;
println!(
log::info!(
"Florence sessions loaded in {:?} with {:?} acceleration (total init {:?})",
sessions_started_at.elapsed(),
acceleration,
@@ -490,9 +490,9 @@ impl FlorenceCaptioner {
pub fn generate(&mut self, image_path: &Path) -> Result<String> {
let started_at = Instant::now();
println!("Florence caption started: {}", image_path.display());
log::info!("Florence caption started: {}", image_path.display());
let image_features = run_vision_encoder(&mut self.vision_session, image_path)?;
println!("Florence vision encoder done in {:?}", started_at.elapsed());
log::info!("Florence vision encoder done in {:?}", started_at.elapsed());
let prompt_ids = self
.tokenizer
.encode(self.caption_detail.prompt(), false)
@@ -502,7 +502,7 @@ impl FlorenceCaptioner {
.map(|id| i64::from(*id))
.collect::<Vec<_>>();
let prompt_embeds = run_token_embedder(&mut self.embed_session, &prompt_ids)?;
println!(
log::info!(
"Florence token embeddings done in {:?}",
started_at.elapsed()
);
@@ -513,7 +513,7 @@ impl FlorenceCaptioner {
&encoder_embeds,
&encoder_attention_mask,
)?;
println!("Florence encoder done in {:?}", started_at.elapsed());
log::info!("Florence encoder done in {:?}", started_at.elapsed());
let generated_ids = run_decoder(
&mut self.decoder_prefill_session,
@@ -523,7 +523,7 @@ impl FlorenceCaptioner {
&encoder_attention_mask,
self.caption_detail.max_new_tokens(),
)?;
println!("Florence decoder done in {:?}", started_at.elapsed());
log::info!("Florence decoder done in {:?}", started_at.elapsed());
let generated_u32 = generated_ids
.into_iter()
@@ -784,7 +784,7 @@ fn run_decoder(
"inputs_embeds" => prefill_inputs_embeds_tensor
})
.map_err(|error| anyhow::anyhow!("{error}"))?;
println!(
log::info!(
"Florence decoder prefill done in {:?}",
prefill_started_at.elapsed()
);
@@ -848,7 +848,7 @@ fn run_decoder(
decoder_kv = collect_present_key_values(&outputs, DECODER_LAYERS)?;
}
println!("Florence decoder produced {} token(s)", generated.len());
log::info!("Florence decoder produced {} token(s)", generated.len());
Ok(generated)
}
@@ -886,7 +886,7 @@ fn create_session(
CaptionAcceleration::Auto | CaptionAcceleration::Directml => {
// `allow_directml` is false for the 4 text/decoder sessions —
// they intentionally run on CPU regardless of the setting.
println!(
log::info!(
"Florence: using CPU for {} (DirectML disabled for this session type)",
path.display()
);
+17
View File
@@ -227,6 +227,14 @@ pub async fn add_folder(
return Err("Path is not a valid directory".into());
}
// Let the webview load media from this folder via the asset protocol.
if let Err(error) = app
.asset_protocol_scope()
.allow_directory(&folder_path, true)
{
log::error!("Failed to allow asset scope for {}: {}", path, error);
}
let name = folder_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
@@ -397,6 +405,15 @@ pub async fn update_folder_path(
if !new_path_buf.is_dir() {
return Err(format!("Path is not a valid directory: {}", new_path));
}
// Let the webview load media from the relocated folder via the asset protocol.
if let Err(error) = app
.asset_protocol_scope()
.allow_directory(&new_path_buf, true)
{
log::error!("Failed to allow asset scope for {}: {}", new_path, error);
}
let new_name = new_path_buf
.file_name()
.map(|n| n.to_string_lossy().to_string())
+7 -7
View File
@@ -20,7 +20,7 @@ fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Res
.lock()
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
if guard.is_none() {
println!("Initializing CLIP text embedder...");
log::info!("Initializing CLIP text embedder...");
*guard = Some(ClipImageEmbedder::new()?);
}
f(guard.as_ref().unwrap())
@@ -35,13 +35,13 @@ pub struct ClipImageEmbedder {
impl ClipImageEmbedder {
pub fn new() -> Result<Self> {
println!("Initializing CLIP image embedder...");
log::info!("Initializing CLIP image embedder...");
let api = Api::new()?;
let repo = api.repo(Repo::new(
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(),
RepoType::Model,
));
println!("Resolving CLIP model weights from Hugging Face cache...");
log::info!("Resolving CLIP model weights from Hugging Face cache...");
let model_path = repo.get("model.safetensors")?;
let tokenizer_repo = api.repo(Repo::new(
"openai/clip-vit-base-patch32".to_string(),
@@ -60,7 +60,7 @@ impl ClipImageEmbedder {
};
let model = ClipModel::new(vb, &config)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
println!("CLIP image embedder ready.");
log::info!("CLIP image embedder ready.");
Ok(Self {
model,
@@ -177,14 +177,14 @@ impl ClipImageEmbedder {
fn resolve_device() -> Result<Device> {
match Device::cuda_if_available(0) {
Ok(device) if device.is_cuda() => {
println!("CLIP embedder: using CUDA GPU (device 0).");
log::info!("CLIP embedder: using CUDA GPU (device 0).");
return Ok(device);
}
Ok(_) => {
println!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
log::info!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
}
Err(e) => {
println!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
log::info!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
}
}
Ok(Device::Cpu)
+33 -29
View File
@@ -200,7 +200,7 @@ pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: P
// not be silently destroyed.
if !folder_path.is_dir() {
let error_msg = format!("Folder not found: {}", folder_path.display());
eprintln!("Indexing error for folder {}: {}", folder_id, error_msg);
log::error!("Indexing error for folder {}: {}", folder_id, error_msg);
if let Ok(conn) = pool.get() {
let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg);
}
@@ -221,7 +221,7 @@ pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: P
let storage_profile = detect_storage_profile(&folder_path);
set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) {
eprintln!("Indexing error for folder {}: {}", folder_id, error);
log::error!("Indexing error for folder {}: {}", folder_id, error);
if let Ok(conn) = pool.get() {
let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string());
}
@@ -254,7 +254,7 @@ pub fn start_thumbnail_worker(
Ok(true) => {}
Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)),
Err(error) => {
eprintln!("Thumbnail worker error: {}", error);
log::error!("Thumbnail worker error: {}", error);
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
@@ -269,7 +269,7 @@ pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaToo
Ok(true) => {}
Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)),
Err(error) => {
eprintln!("Metadata worker error: {}", error);
log::error!("Metadata worker error: {}", error);
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
@@ -279,7 +279,7 @@ pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaToo
pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
std::thread::spawn(move || {
let mut embedder: Option<ClipImageEmbedder> = None;
println!("Embedding worker started.");
log::info!("Embedding worker started.");
loop {
// Only back off when the queue is empty (or errored); while jobs
// are pending, claim the next batch immediately.
@@ -287,7 +287,7 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
Ok(true) => {}
Ok(false) => std::thread::sleep(std::time::Duration::from_millis(500)),
Err(error) => {
eprintln!("Embedding worker error: {}", error);
log::error!("Embedding worker error: {}", error);
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
@@ -298,16 +298,16 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
std::thread::spawn(move || {
let mut captioner: Option<FlorenceCaptioner> = None;
println!("Caption worker started.");
log::info!("Caption worker started.");
loop {
// If the acceleration setting changed, drop the cached session so
// the next batch picks it up with the new execution provider.
if captioner::CAPTION_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
println!("Caption worker: acceleration setting changed — resetting session.");
log::info!("Caption worker: acceleration setting changed — resetting session.");
captioner = None;
}
if let Err(error) = process_caption_batch(&app, &pool, &app_data_dir, &mut captioner) {
eprintln!("Caption worker error: {}", error);
log::error!("Caption worker error: {}", error);
captioner = None;
}
std::thread::sleep(std::time::Duration::from_millis(750));
@@ -318,12 +318,12 @@ pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
std::thread::spawn(move || {
let mut tagger_instance: Option<WdTagger> = None;
println!("Tagging worker started.");
log::info!("Tagging worker started.");
loop {
// If the acceleration setting changed, drop the cached session so
// the next batch picks it up with the new execution provider.
if tagger::TAGGER_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
println!("Tagging worker: acceleration setting changed — resetting session.");
log::info!("Tagging worker: acceleration setting changed — resetting session.");
tagger_instance = None;
}
// Only back off when the queue is empty (or errored); while jobs
@@ -332,7 +332,7 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
Ok(true) => {}
Ok(false) => std::thread::sleep(std::time::Duration::from_millis(750)),
Err(error) => {
eprintln!("Tagging worker error: {}", error);
log::error!("Tagging worker error: {}", error);
tagger_instance = None;
std::thread::sleep(std::time::Duration::from_millis(750));
}
@@ -369,7 +369,7 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
if let Some(path) = err.path() {
unreadable_prefixes.push(path.to_string_lossy().into_owned());
}
eprintln!(
log::error!(
"WalkDir error while scanning folder {}: {}",
folder_path.display(),
err
@@ -649,7 +649,7 @@ fn process_thumbnail_batch(
return Ok(false);
}
println!("Thumbnail batch claimed: {} items", jobs.len());
log::info!("Thumbnail batch claimed: {} items", jobs.len());
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image");
@@ -846,7 +846,7 @@ fn process_embedding_batch(
*embedder = Some(ClipImageEmbedder::new()?);
}
println!("Embedding batch claimed: {} items", jobs.len());
log::info!("Embedding batch claimed: {} items", jobs.len());
let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
emit_folder_job_progress(
app,
@@ -897,7 +897,7 @@ fn process_embedding_batch(
}
}
Err(batch_error) => {
eprintln!(
log::error!(
"Embedding batch fallback to per-image mode: {}",
batch_error
);
@@ -946,7 +946,7 @@ fn process_embedding_batch(
})?;
if !updated_images.is_empty() {
println!("Embedding batch completed: {} items", updated_images.len());
log::info!("Embedding batch completed: {} items", updated_images.len());
let folder_ids = updated_images
.iter()
.map(|image| image.folder_id)
@@ -962,9 +962,13 @@ fn process_embedding_batch(
let write_elapsed = write_started_at.elapsed();
let batch_elapsed = batch_started_at.elapsed();
println!(
log::info!(
"Embedding batch timing: claimed {} in {:?}, infer {:?}, write {:?}, total {:?}",
EMBEDDING_BATCH_SIZE, claim_elapsed, infer_elapsed, write_elapsed, batch_elapsed
EMBEDDING_BATCH_SIZE,
claim_elapsed,
infer_elapsed,
write_elapsed,
batch_elapsed
);
Ok(true)
@@ -1397,7 +1401,7 @@ impl WatcherHandle {
let mut w = self.inner.watcher.lock().unwrap();
if path.is_dir() {
if let Err(e) = w.watch(&path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", path, e);
log::error!("Watcher: failed to watch {:?}: {}", path, e);
}
}
}
@@ -1422,7 +1426,7 @@ impl WatcherHandle {
let _ = w.unwatch(old_path);
if new_path.is_dir() {
if let Err(e) = w.watch(&new_path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", new_path, e);
log::error!("Watcher: failed to watch {:?}: {}", new_path, e);
}
}
}
@@ -1467,7 +1471,7 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
for path in map.keys() {
if path.is_dir() {
if let Err(e) = w.watch(path, RecursiveMode::Recursive) {
eprintln!("Watcher: failed to watch {:?}: {}", path, e);
log::error!("Watcher: failed to watch {:?}: {}", path, e);
}
}
}
@@ -1593,7 +1597,7 @@ fn process_watcher_path(
let conn = match pool.get() {
Ok(c) => c,
Err(e) => {
eprintln!("Watcher: DB pool error: {}", e);
log::error!("Watcher: DB pool error: {}", e);
return;
}
};
@@ -1628,7 +1632,7 @@ fn process_watcher_path(
}
}
Ok(_) => {}
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
Err(e) => log::error!("Watcher: commit error for {:?}: {}", path, e),
}
} else {
// File removed from disk — clean up DB row and thumbnail.
@@ -1645,7 +1649,7 @@ fn process_watcher_path(
}
}
Ok(None) => {} // never indexed or already removed
Err(e) => eprintln!("Watcher: lookup error for {:?}: {}", path, e),
Err(e) => log::error!("Watcher: lookup error for {:?}: {}", path, e),
}
}
}
@@ -1675,7 +1679,7 @@ fn process_watcher_rename(
let conn = match pool.get() {
Ok(c) => c,
Err(e) => {
eprintln!("Watcher rename: DB pool error: {}", e);
log::error!("Watcher rename: DB pool error: {}", e);
return;
}
};
@@ -1692,7 +1696,7 @@ fn process_watcher_rename(
return;
}
Err(e) => {
eprintln!("Watcher rename: DB lookup error: {}", e);
log::error!("Watcher rename: DB lookup error: {}", e);
return;
}
};
@@ -1717,7 +1721,7 @@ fn process_watcher_rename(
new_filename,
effective_thumb,
) {
eprintln!("Watcher rename: DB update error: {}", e);
log::error!("Watcher rename: DB update error: {}", e);
return;
}
@@ -1730,6 +1734,6 @@ fn process_watcher_rename(
images: vec![record],
},
),
Err(e) => eprintln!("Watcher rename: post-update fetch error: {}", e),
Err(e) => log::error!("Watcher rename: post-update fetch error: {}", e),
}
}
+37 -2
View File
@@ -16,6 +16,28 @@ use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// Must be the first plugin: a second launch hands its args to the
// running instance and exits before anything else initializes.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("phokus".into()),
}),
])
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.build(),
)
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
@@ -43,12 +65,12 @@ pub fn run() {
let backfilled =
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 {
println!("Backfilled {} embedding jobs.", backfilled);
log::info!("Backfilled {} embedding jobs.", backfilled);
}
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
.expect("Failed to repair embedding consistency");
if orphaned_vectors > 0 || missing_vectors > 0 {
println!(
log::info!(
"Repaired embedding consistency: removed {} orphaned vectors, requeued {} missing vectors.",
orphaned_vectors, missing_vectors
);
@@ -58,6 +80,19 @@ pub fn run() {
let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
// The asset protocol scope is no longer a blanket "**": thumbnails
// are allowed statically in tauri.conf.json, and each indexed
// folder is allowed here (and in add_folder/update_folder_path).
{
let scope = app.asset_protocol_scope();
let conn = pool.get().expect("Failed to get connection for asset scope");
for folder in db::get_folders(&conn).unwrap_or_default() {
if let Err(error) = scope.allow_directory(&folder.path, true) {
log::error!("Failed to allow asset scope for {}: {}", folder.path, error);
}
}
}
let thumbnail_worker_count = std::thread::available_parallelism()
.map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get()))
.unwrap_or(2);
+6 -5
View File
@@ -43,22 +43,23 @@ impl MediaTools {
}
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
println!("Downloading bundled FFmpeg...");
log::info!("Downloading bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
println!(
log::info!(
"Downloading bundled FFmpeg: {}/{} bytes",
downloaded_bytes, total_bytes
downloaded_bytes,
total_bytes
);
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
println!("Unpacking bundled FFmpeg...");
log::info!("Unpacking bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Done => {
println!("Bundled FFmpeg ready.");
log::info!("Bundled FFmpeg ready.");
}
})
}
+4 -4
View File
@@ -443,7 +443,7 @@ impl WdTagger {
let labels = load_labels(&labels_path)?;
println!(
log::info!(
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
started_at.elapsed(),
labels.len(),
@@ -519,7 +519,7 @@ impl WdTagger {
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
tags.truncate(max_tags);
println!(
log::info!(
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
tags.len(),
self.threshold,
@@ -559,13 +559,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
let mut builder = match acceleration {
TaggerAcceleration::Cpu => {
println!("WD tagger: using CPU execution provider");
log::info!("WD tagger: using CPU execution provider");
builder
}
TaggerAcceleration::Auto => builder
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
.unwrap_or_else(|error| {
println!("WD tagger: DirectML unavailable, falling back to CPU");
log::info!("WD tagger: DirectML unavailable, falling back to CPU");
error.recover()
}),
TaggerAcceleration::Directml => builder