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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user