feat(onboarding): guided first-run tour with background FFmpeg provisioning

Phase 5 of the 0.1.0 release prep:
- FFmpeg no longer blocks startup (or crashes the app offline): provisioning
  runs in a background thread emitting throttled ffmpeg-progress events,
  with installed/downloading/failed state queryable via get_ffmpeg_status
  and a retry command
- video jobs are invisible to claiming and tier-priority checks until FFmpeg
  is ready, so they wait as pending without failing — and without starving
  image embeddings/tagging (include_videos gating in db.rs)
- 7-step show-don't-tell onboarding wizard: welcome + live FFmpeg progress,
  real first-folder picker, faked animating pipeline bar, placeholder
  gallery tiles, cycling search-syntax demo, views overview, and an AI
  features step with a real opt-in tagger download
- skippable at every point (Escape included), persisted via
  settings/onboarding_completed.txt, re-runnable from Settings > General,
  which also gains an FFmpeg status/retry row
This commit is contained in:
2026-06-12 23:09:05 +01:00
parent 1640e30330
commit 7403f0cfeb
17 changed files with 1139 additions and 30 deletions
+47
View File
@@ -2101,3 +2101,50 @@ pub async fn set_notifications_paused(app: AppHandle, paused: bool) -> Result<()
)
.map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct FfmpegStatus {
pub installed: bool,
pub downloading: bool,
pub failed: bool,
}
#[tauri::command]
pub async fn get_ffmpeg_status() -> Result<FfmpegStatus, String> {
Ok(FfmpegStatus {
installed: crate::media::ffmpeg_ready(),
downloading: crate::media::ffmpeg_downloading(),
failed: crate::media::ffmpeg_failed(),
})
}
#[tauri::command]
pub async fn retry_ffmpeg_download(app: AppHandle) -> Result<(), String> {
crate::media::spawn_ffmpeg_provision(app);
Ok(())
}
const ONBOARDING_COMPLETED_FILE: &str = "settings/onboarding_completed.txt";
#[tauri::command]
pub async fn get_onboarding_completed(app: AppHandle) -> Result<bool, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(ONBOARDING_COMPLETED_FILE);
if !path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
Ok(content.trim() == "true")
}
#[tauri::command]
pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let settings_dir = app_dir.join("settings");
std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
std::fs::write(
settings_dir.join("onboarding_completed.txt"),
if completed { "true" } else { "false" },
)
.map_err(|e| e.to_string())
}
+28 -2
View File
@@ -1132,6 +1132,7 @@ pub fn get_all_folder_job_progress(conn: &Connection) -> Result<Vec<FolderJobPro
fn get_pending_thumbnail_jobs_excluding(
conn: &Connection,
excluded_folder_ids: &std::collections::HashSet<i64>,
include_videos: bool,
limit: usize,
) -> Result<Vec<ThumbnailJob>> {
let sql = format!(
@@ -1140,8 +1141,10 @@ fn get_pending_thumbnail_jobs_excluding(
JOIN images i ON i.id = j.image_id
WHERE j.status = 'pending'
{}
{}
ORDER BY j.updated_at, j.image_id
LIMIT ?1",
media_kind_clause(include_videos),
folder_exclusion_clause("i", excluded_folder_ids)
);
let mut stmt = conn.prepare(&sql)?;
@@ -1156,6 +1159,17 @@ fn get_pending_thumbnail_jobs_excluding(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible
/// to both claiming and the tier-priority checks, or pending video jobs would
/// stall every lower tier indefinitely.
fn media_kind_clause(include_videos: bool) -> &'static str {
if include_videos {
""
} else {
"AND i.media_kind = 'image'"
}
}
/// True if any claimable (pending, non-excluded) jobs exist in `job_table`.
/// Used by lower-priority workers to defer to higher-priority queues.
/// `extra_predicate` narrows the image join (e.g. videos only for metadata).
@@ -1184,8 +1198,14 @@ fn has_claimable_jobs(
pub fn has_claimable_thumbnail_jobs(
conn: &Connection,
excluded_folder_ids: &std::collections::HashSet<i64>,
include_videos: bool,
) -> Result<bool> {
has_claimable_jobs(conn, "thumbnail_jobs", "", excluded_folder_ids)
has_claimable_jobs(
conn,
"thumbnail_jobs",
media_kind_clause(include_videos),
excluded_folder_ids,
)
}
pub fn has_claimable_metadata_jobs(
@@ -1211,6 +1231,7 @@ pub fn claim_thumbnail_jobs(
conn: &mut Connection,
active_folder_ids: &std::collections::HashSet<i64>,
paused_folder_ids: &std::collections::HashSet<i64>,
include_videos: bool,
fetch_limit: usize,
claim_limit: usize,
) -> Result<Vec<ThumbnailJob>> {
@@ -1219,7 +1240,12 @@ pub fn claim_thumbnail_jobs(
.union(paused_folder_ids)
.copied()
.collect::<std::collections::HashSet<_>>();
let candidates = get_pending_thumbnail_jobs_excluding(&tx, &excluded_folder_ids, fetch_limit)?;
let candidates = get_pending_thumbnail_jobs_excluding(
&tx,
&excluded_folder_ids,
include_videos,
fetch_limit,
)?;
let mut claimed = Vec::with_capacity(claim_limit);
for job in candidates {
+12 -2
View File
@@ -142,14 +142,18 @@ fn higher_priority_work_pending(pool: &DbPool, own_tier: WorkerTier) -> Result<b
let conn = pool.get()?;
let active_folders = active_indexing_folders();
// Video jobs are unclaimable until FFmpeg is provisioned; they must not
// count as pending higher-priority work or they'd stall lower tiers.
let ffmpeg_ready = crate::media::ffmpeg_ready();
if own_tier > WorkerTier::Thumbnail {
let mut excluded = paused_folder_ids("thumbnail");
excluded.extend(active_folders.iter().copied());
if db::has_claimable_thumbnail_jobs(&conn, &excluded)? {
if db::has_claimable_thumbnail_jobs(&conn, &excluded, ffmpeg_ready)? {
return Ok(true);
}
}
if own_tier > WorkerTier::Metadata {
if own_tier > WorkerTier::Metadata && ffmpeg_ready {
let mut excluded = paused_folder_ids("metadata");
excluded.extend(active_folders.iter().copied());
if db::has_claimable_metadata_jobs(&conn, &excluded)? {
@@ -641,6 +645,7 @@ fn process_thumbnail_batch(
&mut conn,
&active_folders,
&paused_folders,
crate::media::ffmpeg_ready(),
worker_fetch_size,
worker_batch_size,
)
@@ -748,6 +753,11 @@ fn process_metadata_batch(
pool: &DbPool,
media_tools: &MediaTools,
) -> Result<bool> {
// Metadata jobs are video-only and need ffprobe; leave them pending until
// FFmpeg is provisioned — the worker poll loop drains them once ready.
if !crate::media::ffmpeg_ready() {
return Ok(false);
}
if higher_priority_work_pending(pool, WorkerTier::Metadata)? {
return Ok(false);
}
+8 -1
View File
@@ -52,7 +52,10 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar");
// FFmpeg provisioning happens in the background so the window
// appears immediately; workers gate video jobs on readiness and
// the onboarding/Settings UI shows progress and retry.
media::spawn_ffmpeg_provision(app.handle().clone());
let db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
@@ -186,6 +189,10 @@ pub fn run() {
commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids,
commands::set_muted_folder_ids,
commands::get_ffmpeg_status,
commands::retry_ffmpeg_download,
commands::get_onboarding_completed,
commands::set_onboarding_completed,
commands::get_notifications_paused,
commands::set_notifications_paused,
])
+128 -25
View File
@@ -2,9 +2,136 @@ use anyhow::{anyhow, Result};
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
use ffmpeg_sidecar::ffprobe::ffprobe_path;
use ffmpeg_sidecar::paths::ffmpeg_path;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
static FFMPEG_READY: AtomicBool = AtomicBool::new(false);
static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false);
// Set when a provision attempt fails, so the frontend can distinguish
// "failed before the event listener attached" from "about to start".
static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false);
/// True once both ffmpeg and ffprobe binaries are present on disk. Workers
/// gate video jobs on this so a missing/in-flight download never fails jobs.
pub fn ffmpeg_ready() -> bool {
FFMPEG_READY.load(Ordering::Relaxed)
}
pub fn ffmpeg_downloading() -> bool {
FFMPEG_DOWNLOADING.load(Ordering::Relaxed)
}
pub fn ffmpeg_failed() -> bool {
FFMPEG_FAILED.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, Serialize)]
pub struct FfmpegProgressPayload {
pub phase: String,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub error: Option<String>,
}
impl FfmpegProgressPayload {
fn phase(phase: &str) -> Self {
Self {
phase: phase.to_string(),
downloaded_bytes: None,
total_bytes: None,
error: None,
}
}
}
const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress";
/// Provision FFmpeg in the background, streaming progress to the frontend as
/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in
/// flight is a no-op, and re-invoking after success only re-emits `done`.
pub fn spawn_ffmpeg_provision(app: AppHandle) {
// Fast path: binaries already on disk (previous run, or a manual install).
if ffmpeg_path().exists() && ffprobe_path().exists() {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
return;
}
if FFMPEG_DOWNLOADING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // a download is already running
}
FFMPEG_FAILED.store(false, Ordering::SeqCst);
std::thread::spawn(move || {
// The Downloading callback fires very frequently; throttle emissions.
// Cell because the download callback is Fn, not FnMut.
let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1));
let result = auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
log::info!("Downloading bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("starting"),
);
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
if last_emit.get().elapsed() >= Duration::from_millis(250) {
last_emit.set(Instant::now());
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "downloading".to_string(),
downloaded_bytes: Some(downloaded_bytes),
total_bytes: Some(total_bytes),
error: None,
},
);
}
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
log::info!("Unpacking bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("unpacking"),
);
}
FfmpegDownloadProgressEvent::Done => {
log::info!("Bundled FFmpeg ready.");
}
});
FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst);
match result {
Ok(()) => {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
}
Err(error) => {
FFMPEG_FAILED.store(true, Ordering::SeqCst);
log::error!("FFmpeg provisioning failed: {error}");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "error".to_string(),
downloaded_bytes: None,
total_bytes: None,
error: Some(error.to_string()),
},
);
}
}
});
}
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
@@ -36,30 +163,6 @@ impl MediaTools {
}
}
pub fn ensure_installed() -> Result<()> {
// Skip download entirely if both binaries are already present.
if ffmpeg_path().exists() && ffprobe_path().exists() {
return Ok(());
}
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
log::info!("Downloading bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
log::info!("Downloading bundled FFmpeg: {downloaded_bytes}/{total_bytes} bytes");
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
log::info!("Unpacking bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Done => {
log::info!("Bundled FFmpeg ready.");
}
})
}
pub fn ffmpeg_command(&self) -> Command {
let mut cmd = Command::new(&self.ffmpeg_path);
#[cfg(target_os = "windows")]