1434 lines
56 KiB
Rust
1434 lines
56 KiB
Rust
//! gtauri — the gallery, on Tauri v2 (Chromium does the heavy lifting).
|
||
//!
|
||
//! Division of labor (the experiment's verdict, 2026-10-31):
|
||
//! Chromium/webview: grid layout, compositor-driven native scroll, lazy
|
||
//! <img> fetch+decode (async decode threads, downsampled decode, image
|
||
//! cache + memory-pressure eviction), viewer compositing (CSS transforms).
|
||
//! Rust: file scan, SQLite, and the on-disk THUMBNAIL CACHE served over
|
||
//! the `gallery://` uri scheme — `gallery://thumb/<id>` returns a small
|
||
//! JPEG (cache hit: straight from disk; miss: turbojpeg DCT-scaled
|
||
//! decode → re-encode → write cache → serve), `gallery://full/<id>`
|
||
//! returns the original file bytes for the viewer.
|
||
//!
|
||
//! The webview's own lazy loader is the demand scheduler: no Rust-side
|
||
//! prefetch queue, no worker pool — Chromium asks for exactly what the
|
||
//! viewport is about to show.
|
||
|
||
use std::collections::{HashMap, HashSet, VecDeque};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use std::sync::{mpsc, LazyLock, Mutex, RwLock, Condvar};
|
||
|
||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use tauri::Emitter as _;
|
||
use tauri::Listener as _;
|
||
use tauri::Manager as _;
|
||
use sha1::{Digest, Sha1};
|
||
|
||
mod db;
|
||
mod images;
|
||
mod scan;
|
||
|
||
#[cfg(target_os = "android")]
|
||
const ANDROID_PACKAGE: &str = "wow.gmp.gallery";
|
||
|
||
/// Grid thumbnail longest side (px). Cells are ~410 device px wide on the
|
||
/// Pixel 9 Pro (1280 / 3 cols); 512 is sharp at cell size while letting a
|
||
/// 48 MP photo decode at 1/8 DCT scale instead of 1/4 (4x cheaper).
|
||
const THUMB_TARGET: u32 = 512;
|
||
const THUMB_QUALITY: u8 = 82;
|
||
|
||
/// One image known to the app. `id` is the DB rowid (stable per row).
|
||
#[derive(Debug, Clone, Serialize)]
|
||
struct Meta {
|
||
id: i64,
|
||
path: String,
|
||
mtime: i64,
|
||
size: i64,
|
||
}
|
||
|
||
/// id → metadata, refreshed after every DB apply.
|
||
static META: LazyLock<RwLock<HashMap<i64, Meta>>> =
|
||
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||
|
||
/// FR log path (Tauri document_dir = getExternalFilesDir(DOCUMENTS); the
|
||
/// framework creates it — a raw std mkdir of the app external tree is
|
||
/// SELinux-denied). Same FR-line format as the other harnesses so
|
||
/// fr_capture.py remains the referee.
|
||
static FR_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||
static FR_TX: Mutex<Option<mpsc::Sender<String>>> = Mutex::new(None);
|
||
|
||
/// Count of images served from the on-disk cache (vs decoded live) —
|
||
/// reported on the IMG status line.
|
||
static CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// thumbnail memory cache + scroll prefetch
|
||
|
||
/// In-memory LRU of ENCODED bytes (thumb JPEGs, full-res originals).
|
||
/// A mem hit is ~0 ms — the whole point: fast/long scrolls hit cache
|
||
/// because the workers raced ahead of the viewport.
|
||
struct ByteLru {
|
||
cap: u64,
|
||
map: HashMap<i64, Vec<u8>>,
|
||
order: VecDeque<i64>,
|
||
bytes: u64,
|
||
}
|
||
|
||
impl ByteLru {
|
||
fn new(cap: u64) -> Self {
|
||
Self { cap, map: HashMap::new(), order: VecDeque::new(), bytes: 0 }
|
||
}
|
||
fn get(&self, id: i64) -> Option<Vec<u8>> {
|
||
self.map.get(&id).cloned()
|
||
}
|
||
fn has(&self, id: i64) -> bool {
|
||
self.map.contains_key(&id)
|
||
}
|
||
fn put(&mut self, id: i64, bytes: Vec<u8>) {
|
||
if let Some(old) = self.map.get(&id) {
|
||
self.bytes -= old.len() as u64;
|
||
}
|
||
self.bytes += bytes.len() as u64;
|
||
self.order.push_back(id);
|
||
self.map.insert(id, bytes);
|
||
while self.bytes > self.cap {
|
||
match self.order.pop_front() {
|
||
Some(old) => {
|
||
if let Some(v) = self.map.remove(&old) {
|
||
self.bytes -= v.len() as u64;
|
||
}
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
static MEM: LazyLock<Mutex<ByteLru>> =
|
||
LazyLock::new(|| Mutex::new(ByteLru::new(128 * 1024 * 1024))); // ~5000 encoded thumbs
|
||
/// Full-res originals for the viewer: a hit makes openViewer instant even
|
||
/// after scrolling far (the file read never touches the main thread).
|
||
static FULL_MEM: LazyLock<Mutex<ByteLru>> =
|
||
LazyLock::new(|| Mutex::new(ByteLru::new(256 * 1024 * 1024)));
|
||
|
||
fn mem_get(id: i64) -> Option<Vec<u8>> {
|
||
MEM.lock().unwrap().get(id)
|
||
}
|
||
|
||
fn mem_put(id: i64, bytes: Vec<u8>) {
|
||
MEM.lock().unwrap().put(id, bytes);
|
||
}
|
||
|
||
/// NORMAL queue: the prefetch window (off-screen, ahead of the viewport).
|
||
/// URGENT queue: ids Chromium actually requested (visible band). The worker
|
||
/// always drains URGENT first — requested cells jump the off-screen line.
|
||
///
|
||
/// WHY A QUEUE AT ALL: wry calls our protocol handler SYNCHRONOUSLY from
|
||
/// shouldInterceptRequest on Chromium's MAIN thread (wry RustWebViewClient.kt).
|
||
/// Decoding there would serialize ~20 visible cells × 30-100 ms of file read
|
||
/// + decode + encode on the main thread (observed: seconds of blank cells
|
||
/// after a jump). So the handler returns a 1x1 placeholder instantly on a
|
||
/// miss; the worker decodes off-main; JS re-requests on `thumb-ready` and
|
||
/// gets a mem hit. wry also forces Cache-Control: no-store on our responses,
|
||
/// so the mem cache is effectively the HTTP cache.
|
||
/// ONE queue, one lock, one condvar (the classic pattern — the earlier
|
||
/// two-queue design stranded urgent items when the worker slept on the
|
||
/// other queue's condvar, and a variant holding the urgent lock across the
|
||
/// sleep was a straight deadlock). `urgent` jobs (real requests for visible
|
||
/// cells) push_front; prefetch jobs push_back.
|
||
struct Job {
|
||
id: i64,
|
||
/// Display-order index — used to DROP stale work: once the viewport
|
||
/// has moved STALE_ROWS past a job, that job's cell is far off-screen
|
||
/// and decoding it is wasted CPU (a user who fled leaves no backlog).
|
||
idx: u32,
|
||
urgent: bool,
|
||
/// true = full-res read for the viewer, false = grid thumbnail.
|
||
full: bool,
|
||
}
|
||
static Q: LazyLock<Mutex<VecDeque<Job>>> = LazyLock::new(|| Mutex::new(VecDeque::new()));
|
||
static Q_CV: LazyLock<Condvar> = LazyLock::new(|| Condvar::new());
|
||
static INFLIGHT: LazyLock<Mutex<HashSet<i64>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||
/// First visible grid cell (display order) — the viewport anchor from JS.
|
||
static VPORT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||
/// Jobs farther behind the viewport than this are dropped, not decoded.
|
||
const STALE_ROWS: u32 = 60;
|
||
/// Hard queue ceiling: beyond this, new urgent work is deferred to the
|
||
/// JS 400 ms safety-net retry instead of growing an unbounded backlog.
|
||
const Q_CAP: usize = 600;
|
||
/// id → display-order index (rebuilt on every emit_files).
|
||
static ID_TO_IDX: LazyLock<RwLock<HashMap<i64, u32>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
|
||
/// ids with decode/read failures: id -> consecutive failure count.
|
||
/// STAGED corruption: with live watching, a photo still being written
|
||
/// (camera mid-save) fails one or two times and then decodes fine, so a
|
||
/// single failure must NOT be final. Genuine garbage (mislabeled WEBP,
|
||
/// 17-byte files, …) keeps failing and gives up after BROKEN_AFTER —
|
||
/// placeholder forever, no retry loops. A success clears the count.
|
||
const BROKEN_AFTER: u32 = 3;
|
||
static FAILS: LazyLock<Mutex<HashMap<i64, u32>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||
|
||
fn is_broken(id: i64) -> bool {
|
||
FAILS.lock().unwrap().get(&id).is_some_and(|n| *n >= BROKEN_AFTER)
|
||
}
|
||
|
||
fn note_fail(id: i64) {
|
||
*FAILS.lock().unwrap().entry(id).or_insert(0) += 1;
|
||
}
|
||
|
||
fn note_ok(id: i64) {
|
||
FAILS.lock().unwrap().remove(&id);
|
||
}
|
||
|
||
/// 1x1 JPEG in the cell background color (#1c1c22) — served instantly for
|
||
/// cold misses; JS swaps in the real bytes when the worker finishes.
|
||
static PLACEHOLDER: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||
let rgb: Vec<u8> = vec![28, 28, 34];
|
||
images::encode_jpeg(&rgb, 1, 1, 80).expect("1x1 jpeg")
|
||
});
|
||
|
||
/// App handle for worker → webview events (`thumb-ready`).
|
||
static APP_HANDLE: LazyLock<Mutex<Option<tauri::AppHandle>>> =
|
||
LazyLock::new(|| Mutex::new(None));
|
||
|
||
/// Display order (mtime desc, path asc) — same order the grid renders.
|
||
/// Prefetch windows are expressed as index ranges into this.
|
||
static DISPLAY_IDS: LazyLock<RwLock<Vec<i64>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||
|
||
/// Unique tmp-name suffix so two concurrent decode_and_cache calls (the
|
||
/// request path and the prefetch worker can hit the same file) never write
|
||
/// the same tmp file.
|
||
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
|
||
/// Decode → encode → write disk cache. Called from the REQUEST path (which
|
||
/// must be instant — see set_prefetch_window) and from the prefetch worker.
|
||
/// No shared lock: a visible-region request never waits on off-screen work.
|
||
fn decode_and_cache(meta: &Meta) -> Result<Vec<u8>, String> {
|
||
let cp = thumb_cache_path(&meta.path, meta.mtime, meta.size);
|
||
if cp.is_file() {
|
||
// Another path (worker or protocol) filled the disk cache just now.
|
||
let b = std::fs::read(&cp).map_err(|e| format!("cache read: {e}"))?;
|
||
if !b.is_empty() {
|
||
return Ok(b);
|
||
}
|
||
}
|
||
let thumb = images::make_thumb(Path::new(&meta.path), THUMB_TARGET)
|
||
.map_err(|e| format!("thumb {}: {e}", meta.id))?;
|
||
let jpeg = images::encode_jpeg(&thumb.rgb, thumb.width, thumb.height, THUMB_QUALITY)
|
||
.map_err(|e| format!("thumb encode {}: {e}", meta.id))?;
|
||
// Atomic-ish write: unique tmp then rename (a torn cache file would
|
||
// poison the key forever).
|
||
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let tmp = cp.with_file_name(format!("{}.tmp{}-{}", cp.file_stem().unwrap().to_string_lossy(), std::process::id(), seq));
|
||
std::fs::write(&tmp, &jpeg).map_err(|e| format!("cache write: {e}"))?;
|
||
std::fs::rename(&tmp, &cp).map_err(|e| format!("cache rename: {e}"))?;
|
||
Ok(jpeg)
|
||
}
|
||
|
||
/// Set the prefetch window to [lo, hi) — REPLACING whatever is queued.
|
||
/// The queue always holds exactly "what is about to be seen": when the
|
||
/// viewport jumps, stale off-screen work (any job, urgent or not) is
|
||
/// dropped instantly, so a fast scroll can never leave a decode backlog
|
||
/// that starves the visible region.
|
||
fn set_prefetch_window(lo: usize, hi: usize) {
|
||
let ids = DISPLAY_IDS.read().unwrap();
|
||
if ids.is_empty() || lo >= hi {
|
||
return;
|
||
}
|
||
let lo = lo.min(ids.len());
|
||
let hi = hi.min(ids.len());
|
||
if lo >= hi {
|
||
return;
|
||
}
|
||
VPORT.store(lo as u32, Ordering::Relaxed);
|
||
let mut q = Q.lock().unwrap();
|
||
let mut inq = INFLIGHT.lock().unwrap();
|
||
// The anchor just moved to `lo`: everything more than STALE_ROWS
|
||
// behind it is dead work (urgent included — a fling leaves urgent
|
||
// jobs for every band it crossed). Drop it instantly so it can never
|
||
// delay the new viewport or eat queue capacity.
|
||
let kept = q.len();
|
||
q.retain(|j| {
|
||
if j.idx + STALE_ROWS >= lo as u32 {
|
||
return true;
|
||
}
|
||
inq.remove(&j.id);
|
||
false
|
||
});
|
||
if q.len() != kept {
|
||
tr(&format!("window a={lo} drop-stale {}", kept - q.len()));
|
||
}
|
||
let mut added = false;
|
||
for (off, &id) in ids[lo..hi].iter().enumerate() {
|
||
if inq.contains(&id) {
|
||
continue; // one decode in flight; it will land in mem soon
|
||
}
|
||
if MEM.lock().unwrap().has(id) {
|
||
continue;
|
||
}
|
||
inq.insert(id);
|
||
q.push_back(Job { id, idx: (lo + off) as u32, urgent: false, full: false });
|
||
added = true;
|
||
}
|
||
if added {
|
||
Q_CV.notify_one();
|
||
}
|
||
}
|
||
|
||
/// Pop the next job, dropping stale ones (far behind the viewport) as we
|
||
/// pass them. Blocks on the condvar when the queue is empty. Hold nothing
|
||
/// across the block; the wait re-checks under the same lock the pushers
|
||
/// use, so no notify can be lost.
|
||
fn pop_next() -> Job {
|
||
let mut g = Q.lock().unwrap();
|
||
loop {
|
||
if g.is_empty() {
|
||
g = Q_CV.wait(g).unwrap();
|
||
continue;
|
||
}
|
||
// Distance-from-viewport policy: any job more than STALE_ROWS
|
||
// behind the viewport is dead work — the user flung past it, and
|
||
// decoding it would only delay what is actually on screen. This
|
||
// applies to URGENT jobs too (a fling queues urgent work for every
|
||
// band it crosses; all of it except the final band is stale). Safe
|
||
// because the anchor now always settles at the final scroll
|
||
// position (trailing send), and on-screen cells have idx >= VPORT
|
||
// so they can never be dropped. (A pure LIFO would not solve this:
|
||
// the stale pile sits at the stack bottom, never popped, still
|
||
// blocking the queue cap.)
|
||
if let Some(j) = g.front() {
|
||
if j.idx + STALE_ROWS < VPORT.load(Ordering::Relaxed) {
|
||
let j = g.pop_front().unwrap();
|
||
INFLIGHT.lock().unwrap().remove(&j.id);
|
||
tr(&format!("drop stale id={} idx={} urgent={}", j.id, j.idx, j.urgent));
|
||
continue;
|
||
}
|
||
}
|
||
return g.pop_front().unwrap();
|
||
}
|
||
}
|
||
|
||
/// One decode worker (N spawned in setup). Decodes OFF the main thread;
|
||
/// emits `thumb-ready` / `full-ready` so JS re-requests (a mem hit, ~1 ms
|
||
/// on main). Parallel workers keep up with fast long scrolls: one worker
|
||
/// decodes ~3 phones/sec of full-res photos; four decode ~12/sec.
|
||
fn decode_worker() {
|
||
loop {
|
||
let job = pop_next();
|
||
let id = job.id;
|
||
tr(&format!("decode {} start full={}", id, job.full));
|
||
let t0 = std::time::Instant::now();
|
||
if let Some(meta) = META.read().unwrap().get(&id).cloned() {
|
||
if job.full {
|
||
match std::fs::read(&meta.path) {
|
||
Ok(bytes) => {
|
||
note_ok(id);
|
||
FULL_MEM.lock().unwrap().put(id, bytes);
|
||
tr(&format!("full {id} done {:.0}ms", t0.elapsed().as_secs_f64()));
|
||
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
|
||
let _ = app.emit("full-ready", serde_json::json!({ "id": id }));
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("full {id}: {e}");
|
||
note_fail(id);
|
||
tr(&format!("full {id} fail {}: {e}", FAILS.lock().unwrap().get(&id).copied().unwrap_or(0)));
|
||
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
|
||
let _ = app.emit("full-ready", serde_json::json!({ "id": id }));
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
match decode_and_cache(&meta) {
|
||
Ok(jpeg) => {
|
||
note_ok(id);
|
||
mem_put(id, jpeg);
|
||
tr(&format!("decode {id} done {:.0}ms", t0.elapsed().as_secs_f64()));
|
||
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
|
||
let _ = app.emit("thumb-ready", serde_json::json!({ "id": id }));
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("decode {id}: {e}");
|
||
note_fail(id);
|
||
tr(&format!("decode {id} fail {}: {e}", FAILS.lock().unwrap().get(&id).copied().unwrap_or(0)));
|
||
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
|
||
let _ = app.emit("thumb-ready", serde_json::json!({ "id": id }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
INFLIGHT.lock().unwrap().remove(&id);
|
||
}
|
||
}
|
||
|
||
/// Writes a heartbeat line every 2 s. Rust-side liveness: if these stop,
|
||
/// the process died; if they continue but req/js lines stop, the webview
|
||
/// main thread or renderer is dead.
|
||
fn heartbeat() {
|
||
let mut n = 0u64;
|
||
loop {
|
||
let qlen = Q.lock().unwrap().len();
|
||
let memkb = MEM.lock().unwrap().bytes / 1024;
|
||
let broken = FAILS.lock().unwrap().values().filter(|n| **n >= BROKEN_AFTER).count();
|
||
tr(&format!(
|
||
"hb n={n} q={qlen} mem={}KB broken={broken}",
|
||
memkb,
|
||
));
|
||
n += 1;
|
||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// logging (logcat on Android — the log crate is invisible without a backend)
|
||
|
||
#[cfg(target_os = "android")]
|
||
fn init_logcat() {
|
||
extern "C" {
|
||
fn __android_log_write(prio: i32, tag: *const u8, msg: *const u8) -> i32;
|
||
}
|
||
struct Cat;
|
||
impl Log for Cat {
|
||
fn enabled(&self, m: &Metadata) -> bool {
|
||
m.level() <= Level::Info
|
||
}
|
||
fn log(&self, record: &Record) {
|
||
if !self.enabled(record.metadata()) {
|
||
return;
|
||
}
|
||
let prio = match record.level() {
|
||
Level::Error => 6,
|
||
Level::Warn => 5,
|
||
Level::Info => 4,
|
||
_ => 3,
|
||
};
|
||
let mut s = format!("{}\0", record.args());
|
||
unsafe {
|
||
__android_log_write(prio, b"gtauri\0".as_ptr(), s.as_ptr());
|
||
}
|
||
}
|
||
fn flush(&self) {}
|
||
}
|
||
let _ = log::set_boxed_logger(Box::new(Cat)).map(|()| log::set_max_level(LevelFilter::Info));
|
||
}
|
||
|
||
/// File-based trace (logcat drops lines under load — unreliable referee).
|
||
static TRACE: Mutex<Option<std::fs::File>> = Mutex::new(None);
|
||
pub fn init_trace(dir: &Path) {
|
||
if let Ok(f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("trace.log")) {
|
||
*TRACE.lock().unwrap() = Some(f);
|
||
}
|
||
}
|
||
pub fn tr(msg: &str) {
|
||
use std::io::Write;
|
||
if let Some(f) = TRACE.lock().unwrap().as_mut() {
|
||
let _ = writeln!(f, "{:.2} {}", T0.elapsed().as_secs_f64(), msg);
|
||
}
|
||
}
|
||
static T0: LazyLock<std::time::Instant> = LazyLock::new(std::time::Instant::now);
|
||
|
||
fn init_fr_logger(dir: &Path) {
|
||
let path = dir.join("fr.log");
|
||
match std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
||
Ok(f) => {
|
||
let (tx, rx) = mpsc::channel::<String>();
|
||
*FR_TX.lock().unwrap() = Some(tx);
|
||
std::thread::spawn(move || {
|
||
let mut w = std::io::BufWriter::new(f);
|
||
for line in rx {
|
||
use std::io::Write;
|
||
let _ = w.write_all(line.as_bytes());
|
||
let _ = w.flush();
|
||
}
|
||
});
|
||
*FR_PATH.lock().unwrap() = Some(path.clone());
|
||
log::info!("FR log → {:?}", path);
|
||
}
|
||
Err(e) => log::error!("cannot open FR log {:?}: {e}", path),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// storage layout
|
||
|
||
/// App-writable external dir (same as db.rs's convention; the egui gallery's
|
||
/// DB from earlier builds lives here and is reused as-is).
|
||
fn external_files_dir() -> PathBuf {
|
||
if let Ok(d) = std::env::var("EXTERNAL_DATA_DIR") {
|
||
let p = PathBuf::from(d);
|
||
if std::fs::create_dir_all(&p).is_ok() {
|
||
return p;
|
||
}
|
||
}
|
||
if let Ok(st) = std::env::var("EXTERNAL_STORAGE") {
|
||
let p = PathBuf::from(st).join("Android/data").join(ANDROID_PACKAGE).join("files");
|
||
if std::fs::create_dir_all(&p).is_ok() {
|
||
return p;
|
||
}
|
||
}
|
||
PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files"))
|
||
}
|
||
|
||
/// Thumbnail cache dir: <external files>/.thumbs.
|
||
fn thumbs_dir() -> PathBuf {
|
||
let d = external_files_dir().join(".thumbs");
|
||
let _ = std::fs::create_dir_all(&d);
|
||
d
|
||
}
|
||
|
||
/// Cache key: sha1(path | mtime | size | target) — invalidates automatically
|
||
/// when the file changes or the render target moves.
|
||
fn thumb_cache_path(path: &str, mtime: i64, size: i64) -> PathBuf {
|
||
let mut h = Sha1::new();
|
||
h.update(path.as_bytes());
|
||
h.update(mtime.to_le_bytes());
|
||
h.update(size.to_le_bytes());
|
||
h.update(THUMB_TARGET.to_le_bytes());
|
||
let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
|
||
thumbs_dir().join(format!("{hex}.jpg"))
|
||
}
|
||
|
||
/// Cache ceiling; oldest entries evicted when exceeded.
|
||
const THUMB_CACHE_MAX: u64 = 700 * 1024 * 1024;
|
||
|
||
fn prune_cache(dir: &Path, max: u64) {
|
||
let mut files: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir)
|
||
.into_iter()
|
||
.flatten()
|
||
.flatten()
|
||
.filter_map(|e| {
|
||
let m = e.metadata().ok()?;
|
||
let ft = m.modified().ok()?;
|
||
Some((ft, e.path()))
|
||
})
|
||
.collect();
|
||
let total: u64 = files.iter().map(|(_, p)| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)).sum();
|
||
if total <= max {
|
||
return;
|
||
}
|
||
files.sort(); // oldest first
|
||
let mut to_drop = total - max;
|
||
for (_, p) in files {
|
||
if to_drop == 0 {
|
||
break;
|
||
}
|
||
if let Ok(m) = std::fs::metadata(&p) {
|
||
let _ = std::fs::remove_file(&p);
|
||
to_drop = to_drop.saturating_sub(m.len());
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// the gallery:// protocol
|
||
|
||
/// Mem → disk → decode → serve a grid thumbnail for `id`. Returns JPEG
|
||
/// bytes. Each tier below is only reached when the one above is cold, and
|
||
/// the prefetch worker keeps the top two warm ahead of the viewport.
|
||
fn serve_thumb(id: i64) -> Result<Vec<u8>, String> {
|
||
tr(&format!("req {id}"));
|
||
if let Some(b) = mem_get(id) {
|
||
tr(&format!("req {id} hit-mem"));
|
||
return Ok(b);
|
||
}
|
||
let meta = {
|
||
let m = META.read().unwrap();
|
||
m.get(&id).cloned().ok_or_else(|| format!("no item {id}"))?
|
||
};
|
||
let cp = thumb_cache_path(&meta.path, meta.mtime, meta.size);
|
||
if cp.is_file() {
|
||
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
|
||
let b = std::fs::read(&cp).map_err(|e| format!("cache read: {e}"))?;
|
||
if !b.is_empty() {
|
||
mem_put(id, b.clone());
|
||
tr(&format!("req {id} hit-disk"));
|
||
return Ok(b);
|
||
}
|
||
}
|
||
// Confirmed corrupt (BROKEN_AFTER consecutive failures): placeholder
|
||
// forever, no retry loop. A file still being written is NOT broken
|
||
// yet — it gets queued and retried until it decodes.
|
||
if is_broken(id) {
|
||
tr(&format!("req {id} broken"));
|
||
return Ok(PLACEHOLDER.as_slice().to_vec());
|
||
}
|
||
// Cold miss: NEVER decode here (main thread!). Queue it URGENT (front
|
||
// of the queue) and return the 1x1 placeholder in ~1 ms. The worker
|
||
// emits `thumb-ready`; JS re-requests; that hit is instant.
|
||
let idx = ID_TO_IDX.read().unwrap().get(&id).copied().unwrap_or(u32::MAX);
|
||
{
|
||
let mut q = Q.lock().unwrap();
|
||
let mut inq = INFLIGHT.lock().unwrap();
|
||
if !inq.contains(&id) && q.len() < Q_CAP {
|
||
inq.insert(id);
|
||
q.push_front(Job { id, idx, urgent: true, full: false });
|
||
}
|
||
}
|
||
Q_CV.notify_one();
|
||
tr(&format!("req {id} placeholder"));
|
||
Ok(PLACEHOLDER.as_slice().to_vec())
|
||
}
|
||
|
||
/// Serve the original file (viewer). NEVER reads the file on the main
|
||
/// thread: a 30-50 MB flash read there would freeze the whole WebView.
|
||
/// Mem hit → real bytes; miss → enqueue an urgent FULL job (front of the
|
||
/// queue, ahead of any grid work) + 1x1 placeholder. JS already showed
|
||
/// the grid thumb; when the read lands it swaps in the full-res on
|
||
/// `full-ready`.
|
||
fn serve_full(id: i64) -> Result<(Vec<u8>, &'static str), String> {
|
||
let meta = META.read().unwrap().get(&id).cloned().ok_or_else(|| format!("no item {id}"))?;
|
||
let ct = match meta.path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
|
||
"png" => "image/png",
|
||
"webp" => "image/webp",
|
||
"gif" => "image/gif",
|
||
_ => "image/jpeg",
|
||
};
|
||
if is_broken(id) {
|
||
return Ok((PLACEHOLDER.as_slice().to_vec(), "image/jpeg"));
|
||
}
|
||
if let Some(b) = FULL_MEM.lock().unwrap().get(id) {
|
||
tr(&format!("full {id} hit-mem"));
|
||
return Ok((b, ct));
|
||
}
|
||
tr(&format!("full {id} placeholder"));
|
||
let idx = ID_TO_IDX.read().unwrap().get(&id).copied().unwrap_or(u32::MAX);
|
||
{
|
||
let mut q = Q.lock().unwrap();
|
||
let mut inq = INFLIGHT.lock().unwrap();
|
||
if !inq.contains(&id) && q.len() < Q_CAP {
|
||
inq.insert(id);
|
||
q.push_front(Job { id, idx, urgent: true, full: true });
|
||
}
|
||
}
|
||
Q_CV.notify_one();
|
||
Ok((PLACEHOLDER.as_slice().to_vec(), "image/jpeg"))
|
||
}
|
||
|
||
/// `gallery://thumb/<id>` and `gallery://full/<id>`.
|
||
fn serve_sync(ctx: tauri::UriSchemeContext<'_, tauri::Wry>, request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> {
|
||
let _ = ctx;
|
||
handle_gallery_inner(request)
|
||
}
|
||
|
||
fn handle_gallery_inner(request: http::Request<Vec<u8>>) -> http::Response<Vec<u8>> {
|
||
|
||
// request.uri() looks like "gallery/thumb/123" (scheme stripped by wry on
|
||
// Android the path comes through as "thumb/123"); be liberal about it.
|
||
// wry (Android) reverts the workaround URL to `gallery://localhost/thumb/1`
|
||
// — strip scheme, then the `localhost` authority, then any '/'.
|
||
let uri = request.uri().to_string();
|
||
let path = uri
|
||
.trim_start_matches("gallery://")
|
||
.trim_start_matches("gallery/")
|
||
.trim_start_matches("localhost/")
|
||
.trim_start_matches('/');
|
||
let mut parts = path.splitn(2, '/');
|
||
let kind = parts.next().unwrap_or("");
|
||
|
||
let id: i64 = match parts.next().and_then(|s| s.split('?').next()).and_then(|s| s.parse().ok()) {
|
||
Some(i) => i,
|
||
None => {
|
||
return http::Response::builder()
|
||
.status(404)
|
||
.body(b"bad request".to_vec())
|
||
.unwrap();
|
||
}
|
||
};
|
||
let (status, ct, bytes) = match kind {
|
||
"thumb" => match serve_thumb(id) {
|
||
Ok(b) => (200, "image/jpeg", b),
|
||
Err(e) => {
|
||
log::warn!("{e}");
|
||
(500, "text/plain", e.into_bytes())
|
||
}
|
||
},
|
||
"full" => match serve_full(id) {
|
||
Ok((b, ct)) => (200, ct, b),
|
||
Err(e) => {
|
||
log::warn!("{e}");
|
||
(500, "text/plain", e.into_bytes())
|
||
}
|
||
},
|
||
_ => (404, "text/plain", format!("unknown {kind}").into_bytes()),
|
||
};
|
||
|
||
http::Response::builder()
|
||
.status(status)
|
||
.header("content-type", ct)
|
||
.header("cache-control", "max-age=31536000")
|
||
.body(bytes)
|
||
.unwrap()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// file list + scan
|
||
|
||
/// Emit the current id list (display order: mtime desc, path asc — the DB
|
||
/// query already does that) to the webview.
|
||
fn emit_files(app: &tauri::AppHandle, scanning: bool, found: usize) {
|
||
let ids: Vec<i64> = {
|
||
let m = META.read().unwrap();
|
||
// rebuild display order from the map (mtime desc, path asc)
|
||
let mut v: Vec<Meta> = m.values().cloned().collect();
|
||
v.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
|
||
v.into_iter().map(|x| x.id).collect()
|
||
};
|
||
*DISPLAY_IDS.write().unwrap() = ids.clone();
|
||
*ID_TO_IDX.write().unwrap() =
|
||
ids.iter().enumerate().map(|(i, &id)| (id, i as u32)).collect();
|
||
tr(&format!("emit n={} scanning={scanning}", ids.len()));
|
||
let _ = app.emit("gallery-files", FileList { ids, scanning, found });
|
||
}
|
||
|
||
#[derive(Serialize, Clone)]
|
||
struct FileList {
|
||
ids: Vec<i64>,
|
||
scanning: bool,
|
||
found: usize,
|
||
}
|
||
|
||
/// Load the DB (reusing the existing file if present) into META and return
|
||
/// a live connection.
|
||
fn load_meta(conn: &mut db::Connection) -> Result<(), rusqlite::Error> {
|
||
let mut m = META.write().unwrap();
|
||
m.clear();
|
||
let mut st = conn.prepare("SELECT rowid, path, mtime, size FROM items ORDER BY mtime DESC, path ASC")?;
|
||
let rows = st.query_map([], |r| {
|
||
Ok(Meta {
|
||
id: r.get(0)?,
|
||
path: r.get(1)?,
|
||
mtime: r.get(2)?,
|
||
size: r.get(3)?,
|
||
})
|
||
})?;
|
||
for r in rows {
|
||
let meta = r?;
|
||
m.insert(meta.id, meta);
|
||
}
|
||
log::info!("loaded {} items from DB", m.len());
|
||
Ok(())
|
||
}
|
||
|
||
/// Walk the roots once, streaming progress emits.
|
||
/// A filesystem error fails the walk (see scan.rs) — the caller must
|
||
/// then NOT apply a partial result to the DB.
|
||
fn walk_once(app: &tauri::AppHandle) -> std::io::Result<Vec<scan::Entry>> {
|
||
let roots = scan::roots();
|
||
let last_emit = std::cell::Cell::new(std::time::Instant::now());
|
||
scan::walk_stream(&roots, &|batch| {
|
||
if last_emit.get().elapsed() < std::time::Duration::from_millis(500) {
|
||
return;
|
||
}
|
||
last_emit.set(std::time::Instant::now());
|
||
emit_files(app, true, batch.len());
|
||
})
|
||
}
|
||
|
||
/// Set while a full rescan's walk→apply is in flight. The watch loop
|
||
/// must NOT consume MediaStore payloads meanwhile: a delete consumed
|
||
/// mid-walk would race the walk's in-memory snapshot (the walk would
|
||
/// re-add the row the delete already processed). Payloads simply wait
|
||
/// as files and are drained by the rescan's post-apply drain.
|
||
static RESCANNING: std::sync::atomic::AtomicBool =
|
||
std::sync::atomic::AtomicBool::new(false);
|
||
|
||
struct RescanGuard;
|
||
impl RescanGuard {
|
||
fn new() -> Self {
|
||
RESCANNING.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
Self
|
||
}
|
||
}
|
||
impl Drop for RescanGuard {
|
||
fn drop(&mut self) {
|
||
RESCANNING.store(false, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
}
|
||
|
||
/// Full rescan: walk the roots, diff against the DB (insert new /
|
||
/// update changed / delete removed), refresh META, emit the list.
|
||
/// Used by the startup scan AND the 5-minute reconciliation pass.
|
||
/// If the walk fails, nothing is applied — an error is not "those
|
||
/// files are gone"; the next pass retries.
|
||
fn rescan(app: &tauri::AppHandle) {
|
||
let _guard = RescanGuard::new();
|
||
let entries = match walk_once(app) {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tr(&format!("rescan: walk FAILED ({e}) — not applying"));
|
||
log::warn!("rescan walk failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let mut conn = match db::open(&db::db_path()) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
log::warn!("rescan db: {e}");
|
||
return;
|
||
}
|
||
};
|
||
if let Err(e) = db::apply(&mut conn, &entries) {
|
||
log::warn!("rescan apply: {e}");
|
||
return;
|
||
}
|
||
if let Err(e) = load_meta(&mut conn) {
|
||
log::warn!("rescan meta: {e}");
|
||
return;
|
||
}
|
||
emit_files(app, false, entries.len());
|
||
log::info!("rescan: {} files", entries.len());
|
||
// Draining happens in the watch loop's next tick: the RESCANNING
|
||
// flag lifted at drop time guarantees any payload consumed from now
|
||
// on is applied AFTER this walk's apply, so mid-walk deletions
|
||
// always win over the stale snapshot. (The 5-min pass catches any
|
||
// straggler in the worst-case millisecond window.)
|
||
}
|
||
|
||
fn spawn_scan(app: tauri::AppHandle) {
|
||
std::thread::spawn(move || rescan(&app));
|
||
}
|
||
|
||
fn mtime_ns(m: &std::fs::Metadata) -> i64 {
|
||
m.modified()
|
||
.ok()
|
||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_nanos() as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Tree signature: (dir, mtime) for every directory under the roots.
|
||
/// Verified on the phone: Android's FUSE DOES update a directory's
|
||
/// mtime when files are added/removed inside it (stat before/after a
|
||
/// copy showed the change). Cost per tick: one stat per DIRECTORY and
|
||
/// one readdir per directory — no per-FILE stat. (The old version
|
||
/// called is_dir() on every entry: 10k stats per second on a 10k-file
|
||
/// Camera dir, which put a 10 s I/O floor under every event.)
|
||
fn dir_sig(roots: &[PathBuf]) -> HashMap<PathBuf, i64> {
|
||
let mut out = HashMap::new();
|
||
let mut stack: Vec<PathBuf> = roots.iter().filter(|r| r.is_dir()).cloned().collect();
|
||
while let Some(dir) = stack.pop() {
|
||
let Ok(m) = std::fs::metadata(&dir) else { continue };
|
||
out.insert(dir.clone(), mtime_ns(&m));
|
||
match std::fs::read_dir(&dir) {
|
||
Ok(rd) => {
|
||
for ent in rd.flatten() {
|
||
let p = ent.path();
|
||
// file_type() uses the d_type from getdents (no
|
||
// syscall); is_dir() would stat the entry.
|
||
let Ok(ft) = ent.file_type() else { continue };
|
||
if ft.is_symlink() || !ft.is_dir() {
|
||
continue;
|
||
}
|
||
if p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
|
||
continue;
|
||
}
|
||
stack.push(p);
|
||
}
|
||
}
|
||
// A directory we cannot read this tick: skip it; the next
|
||
// tick or the full pass will pick up anything that changed.
|
||
Err(_) => {}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Above 2000 files, stat'ing every file on FUSE costs tens of seconds;
|
||
/// for such directories a change event is handled readdir-only (adds and
|
||
/// deletes are still detected exactly; in-place edits wait for the
|
||
/// periodic full pass). Small directories are walked with stat so edits
|
||
/// are caught too.
|
||
const BIG_DIR: usize = 2000;
|
||
|
||
/// Incremental rescan limited to the changed directory subtrees.
|
||
/// Everything outside the changed trees keeps its DB metadata, so the
|
||
/// walk cost is proportional to the change, not the library.
|
||
fn rescan_subtrees(app: &tauri::AppHandle, changed: &[PathBuf]) {
|
||
let mut conn = match db::open(&db::db_path()) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
log::warn!("incr db: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let existing = match db::list_entries(&mut conn) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
log::warn!("incr list: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let mut fresh: Vec<scan::Entry> = Vec::new();
|
||
for d in changed {
|
||
let prefix = d.to_string_lossy().into_owned() + "/";
|
||
let under: Vec<&scan::Entry> =
|
||
existing.iter().filter(|e| e.path.starts_with(&prefix)).collect();
|
||
let known: HashMap<&str, &scan::Entry> =
|
||
under.iter().map(|e| (e.path.as_str(), *e)).collect();
|
||
if under.len() <= BIG_DIR {
|
||
// Small: full walk with stat — adds, deletes AND edits.
|
||
if let Err(e) = scan::walk_dir_into(d, &mut fresh) {
|
||
tr(&format!("incr: walk {d:?} FAILED ({e}) — not applying"));
|
||
log::warn!("incr walk failed: {e}");
|
||
return; // a failed subtree must not be applied as empty
|
||
}
|
||
} else {
|
||
// Huge: readdir-only. New files get stat'd; known files keep
|
||
// their DB mtime/size (an in-place edit there is caught by
|
||
// the 5-minute full pass).
|
||
if let Err(e) = scan::walk_dir_nostats(d, &mut |p| {
|
||
let ps = p.to_string_lossy();
|
||
let entry = match known.get(ps.as_ref()) {
|
||
Some(old) => scan::Entry {
|
||
path: ps.into_owned(),
|
||
mtime: old.mtime,
|
||
size: old.size,
|
||
},
|
||
None => match std::fs::metadata(p) {
|
||
Ok(md) => scan::Entry {
|
||
path: ps.into_owned(),
|
||
mtime: mtime_ns(&md),
|
||
size: md.len() as i64,
|
||
},
|
||
Err(_) => return, // file vanished mid-walk — normal race
|
||
},
|
||
};
|
||
fresh.push(entry);
|
||
}) {
|
||
tr(&format!("incr: walk {d:?} FAILED ({e}) — not applying"));
|
||
log::warn!("incr walk failed: {e}");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
let mut all = existing
|
||
.into_iter()
|
||
.filter(|e| !changed.iter().any(|d| e.path.starts_with(&*(d.to_string_lossy().into_owned() + "/"))))
|
||
.collect::<Vec<_>>();
|
||
let added = fresh.len();
|
||
all.extend(fresh);
|
||
if let Err(e) = db::apply(&mut conn, &all) {
|
||
log::warn!("incr apply: {e}");
|
||
return;
|
||
}
|
||
if let Err(e) = load_meta(&mut conn) {
|
||
log::warn!("incr meta: {e}");
|
||
return;
|
||
}
|
||
emit_files(app, false, all.len());
|
||
tr(&format!("watch: incr changed={} fresh={added}", changed.len()));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// MediaStore fast path (Android). MainActivity's ContentObserver gets
|
||
// the CHANGED URI(s) directly from the OS; it queries that exact row
|
||
// (a few ms) and drops a one-line payload file, then wakes us with a
|
||
// zero-arg JNI call. No filesystem scanning on the event path: the
|
||
// payload IS the change (added, modified, or deleted).
|
||
//
|
||
// Payload line formats (one per file, one file per event — per-event
|
||
// files so a burst never overwrites an unread event):
|
||
// A|<media_id>|<path>|<size> row exists → upsert
|
||
// D|<media_id> row gone → delete by id
|
||
// ---------------------------------------------------------------------
|
||
/// Consume pending event files. Returns true when at least one change
|
||
/// line was applied (the watch loop uses this to decide whether the
|
||
/// OS event was fully handled by the payload path).
|
||
#[cfg(target_os = "android")]
|
||
fn apply_mediasotre_payload(app: &tauri::AppHandle) -> bool {
|
||
// Consume every pending .ms_recent.* event file (oldest first).
|
||
let dir = external_files_dir();
|
||
let Ok(rd) = std::fs::read_dir(&dir) else { return false };
|
||
let mut files: Vec<std::path::PathBuf> = rd
|
||
.flatten()
|
||
.filter(|e| {
|
||
e.file_name()
|
||
.to_string_lossy()
|
||
.starts_with(".ms_recent.")
|
||
})
|
||
.map(|e| e.path())
|
||
.collect();
|
||
if files.is_empty() {
|
||
return false;
|
||
}
|
||
files.sort();
|
||
let mut applied = false;
|
||
for f in files {
|
||
let name = f.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||
let Ok(text) = std::fs::read_to_string(&f) else { continue };
|
||
let _ = std::fs::remove_file(&f);
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
tr(&format!("mediastore event {name}: {} lines", text.lines().count()));
|
||
applied |= mediastore_apply(app, &text);
|
||
}
|
||
applied
|
||
}
|
||
|
||
#[cfg(not(target_os = "android"))]
|
||
fn apply_mediasotre_payload(_app: &tauri::AppHandle) -> bool {
|
||
false
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
fn mediastore_apply(app: &tauri::AppHandle, text: &str) -> bool {
|
||
let mut conn = match db::open(&db::db_path()) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
log::warn!("mediastore db: {e}");
|
||
return false;
|
||
}
|
||
};
|
||
let mut changed = 0usize;
|
||
let mut removed = 0usize;
|
||
for line in text.lines() {
|
||
let mut f = line.splitn(5, '|');
|
||
let tag = f.next().unwrap_or("");
|
||
match tag {
|
||
// A|media_id|path|size
|
||
"A" => {
|
||
let Ok(id) = f.next().unwrap_or("").parse::<i64>() else { continue };
|
||
let Some(path) = f.next() else { continue };
|
||
if path.is_empty() || !scan::is_image_path(path) {
|
||
continue;
|
||
}
|
||
let _size = f.next().and_then(|s| s.parse::<i64>().ok()); // disk stat is the truth
|
||
// Always record the id→path mapping: a later delete
|
||
// event must be resolvable even for rows created by a
|
||
// filesystem walk (no media_id) or never created at all
|
||
// (the size-0 mid-write row below).
|
||
if let Err(e) = db::map_media(&mut conn, id, path) {
|
||
log::warn!("mediastore map: {e}");
|
||
}
|
||
// Trust the DISK, not the provider row: the scanner can
|
||
// leave _size NULL, and a file still being written is
|
||
// fine — its finalization event re-upserts with the new
|
||
// size (content change resets the decode state).
|
||
let Ok(md) = std::fs::metadata(path) else { continue };
|
||
let mtime = mtime_ns(&md);
|
||
match db::upsert_ms(&mut conn, path, mtime, md.len() as i64, id) {
|
||
Ok(content_changed) => {
|
||
// Row id → the rowid is what the grid/queue use;
|
||
// clear decode-failure state only after a real
|
||
// content change (a still-being-written file
|
||
// that failed early gets fresh retries).
|
||
let rowid: Option<i64> = conn
|
||
.query_row(
|
||
"SELECT rowid FROM items WHERE path = ?1",
|
||
rusqlite::params![path],
|
||
|r| r.get(0),
|
||
)
|
||
.ok();
|
||
if content_changed {
|
||
if let Some(rid) = rowid {
|
||
FAILS.lock().unwrap().remove(&rid);
|
||
}
|
||
}
|
||
changed += 1;
|
||
}
|
||
Err(e) => log::warn!("mediastore upsert: {e}"),
|
||
}
|
||
}
|
||
// D|media_id — the row is gone from MediaStore; resolve the
|
||
// path (items row first, then the add-event mapping) and
|
||
// delete by path: the items row may have been created by a
|
||
// filesystem walk, which doesn't store the media id.
|
||
"D" => {
|
||
let Ok(id) = f.next().unwrap_or("").parse::<i64>() else { continue };
|
||
let path = match db::path_for_media(&mut conn, id) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
log::warn!("mediastore dlookup: {e}");
|
||
continue;
|
||
}
|
||
};
|
||
if let Some(path) = path {
|
||
// clear decode-failure state before deleting
|
||
if let Some(rid) = conn
|
||
.query_row(
|
||
"SELECT rowid FROM items WHERE path = ?1",
|
||
rusqlite::params![path],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()
|
||
{
|
||
FAILS.lock().unwrap().remove(&rid);
|
||
}
|
||
if let Ok(n) = db::delete_by_path(&mut conn, &path) {
|
||
removed += n;
|
||
if n > 0 {
|
||
tr(&format!("mediastore del id={id} {path}"));
|
||
}
|
||
}
|
||
let _ = db::unmap_media(&mut conn, id);
|
||
}
|
||
}
|
||
"D_PATH" => {
|
||
let Some(path) = f.next() else { continue };
|
||
if let Some(rid) = conn
|
||
.query_row(
|
||
"SELECT rowid FROM items WHERE path = ?1",
|
||
rusqlite::params![path],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()
|
||
{
|
||
FAILS.lock().unwrap().remove(&rid);
|
||
}
|
||
if let Ok(n) = db::delete_by_path(&mut conn, path) {
|
||
removed += n;
|
||
if n > 0 {
|
||
tr(&format!("mediastore del path={path}"));
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if changed + removed > 0 {
|
||
tr(&format!("mediastore applied +{changed} -{removed}"));
|
||
if let Err(e) = load_meta(&mut conn) {
|
||
log::warn!("mediastore meta: {e}");
|
||
}
|
||
emit_files(app, false, META.read().unwrap().len());
|
||
}
|
||
changed + removed > 0
|
||
}
|
||
|
||
/// Watcher wake events.
|
||
#[derive(Debug, Clone, Copy)]
|
||
enum WEvent {
|
||
/// The OS said the filesystem changed (ContentObserver on Android,
|
||
/// inotify/FSEvents on desktop).
|
||
Fs,
|
||
/// Periodic full-pass timer.
|
||
Full,
|
||
}
|
||
|
||
static WATCH_TX: LazyLock<Mutex<Option<mpsc::Sender<WEvent>>>> =
|
||
LazyLock::new(|| Mutex::new(None));
|
||
|
||
/// Android: called by MainActivity's ContentObserver via JNI (and after
|
||
/// each MediaStore fast-path payload drop). Signature: (JNIEnv*,
|
||
/// jobject) for an instance method. One channel send — nothing heavy,
|
||
/// never blocks the main thread.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_wow_gmp_gallery_MainActivity_nativeFsChange(
|
||
_env: *mut std::ffi::c_void,
|
||
_obj: *mut std::ffi::c_void,
|
||
) {
|
||
if let Some(tx) = WATCH_TX.lock().unwrap().as_ref() {
|
||
let _ = tx.send(WEvent::Fs);
|
||
}
|
||
}
|
||
|
||
/// Desktop: inotify (Linux) / FSEvents (macOS) on the scan roots.
|
||
#[cfg(not(target_os = "android"))]
|
||
fn spawn_fs_watcher(tx: mpsc::Sender<WEvent>) {
|
||
std::thread::spawn(move || {
|
||
use notify::{RecursiveMode, Watcher};
|
||
let (wt, wm) = mpsc::channel();
|
||
let mut w = match notify::recommended_watcher(move |res: Result<notify::Event, _>| {
|
||
if res.is_ok() {
|
||
let _ = wt.send(()); // consumer coalesces + diffs
|
||
}
|
||
}) {
|
||
Ok(w) => w,
|
||
Err(e) => {
|
||
log::warn!("fs watcher: {e}");
|
||
return;
|
||
}
|
||
};
|
||
for r in scan::roots() {
|
||
if r.is_dir() {
|
||
let _ = w.watch(&r, RecursiveMode::Recursive);
|
||
}
|
||
}
|
||
let _keep = wm; // drop = stop watching
|
||
loop {
|
||
if wt.recv().is_err() {
|
||
break;
|
||
}
|
||
let _ = tx.send(WEvent::Fs);
|
||
}
|
||
});
|
||
}
|
||
|
||
fn collect_watched_dirs(roots: &[PathBuf]) -> HashSet<PathBuf> {
|
||
let mut dirs = HashSet::new();
|
||
for r in roots {
|
||
if r.is_dir() {
|
||
dirs.insert(r.clone());
|
||
if let Ok(rd) = std::fs::read_dir(r) {
|
||
for ent in rd.flatten() {
|
||
if let Ok(ft) = ent.file_type() {
|
||
if ft.is_dir() {
|
||
let p = ent.path();
|
||
if !p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
|
||
dirs.insert(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Ok(mut conn) = db::open(&db::db_path()) {
|
||
if let Ok(entries) = db::list_entries(&mut conn) {
|
||
for e in entries {
|
||
if let Some(parent) = std::path::Path::new(&e.path).parent() {
|
||
dirs.insert(parent.to_path_buf());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
dirs
|
||
}
|
||
|
||
fn fast_dir_sig(dirs: &HashSet<PathBuf>) -> HashMap<PathBuf, i64> {
|
||
let mut out = HashMap::new();
|
||
for d in dirs {
|
||
if let Ok(m) = std::fs::metadata(d) {
|
||
out.insert(d.clone(), mtime_ns(&m));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Diff two dir-mtime signatures: dirs added, removed, or changed.
|
||
fn changed_dirs(
|
||
old: &HashMap<PathBuf, i64>,
|
||
next: &HashMap<PathBuf, i64>,
|
||
) -> Vec<PathBuf> {
|
||
let mut v: Vec<PathBuf> = next
|
||
.iter()
|
||
.filter(|(d, t)| old.get(d.as_path()) != Some(t))
|
||
.map(|(d, _)| d.clone())
|
||
.collect();
|
||
for d in old.keys() {
|
||
if !next.contains_key(d) {
|
||
v.push(d.clone());
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Live file watching — EVENT-DRIVEN, no tree polling:
|
||
/// - Android: MainActivity's MediaStore ContentObserver → JNI → this
|
||
/// thread (camera saves, media-scanner indexing, indexed writes).
|
||
/// - Desktop: inotify/FSEvents via `notify` on the scan roots.
|
||
/// - On wake: coalesce the burst, debounce for the write to settle,
|
||
/// diff dir mtimes (a few stats), and rescan ONLY the changed
|
||
/// subtrees. A 5-minute full pass is the safety net (in-place edits
|
||
/// in the huge dir, anything the OS never reported).
|
||
/// - The 1 s recv timeout doubles as a cheap secondary detector
|
||
/// (dir-mtime diff, ~0.05 ms): raw out-of-band copies that the media
|
||
/// scanner hasn't indexed yet are still caught within ~1 s.
|
||
fn watch_files(app: tauri::AppHandle) {
|
||
let (tx, rx) = mpsc::channel::<WEvent>();
|
||
*WATCH_TX.lock().unwrap() = Some(tx.clone());
|
||
std::thread::spawn(move || {
|
||
loop {
|
||
std::thread::sleep(std::time::Duration::from_secs(300));
|
||
let _ = tx.send(WEvent::Full);
|
||
}
|
||
});
|
||
#[cfg(not(target_os = "android"))]
|
||
spawn_fs_watcher(tx);
|
||
|
||
let mut watched_dirs = collect_watched_dirs(&scan::roots());
|
||
let mut sig = fast_dir_sig(&watched_dirs);
|
||
let mut baseline = true;
|
||
let mut last_dir_refresh = std::time::Instant::now();
|
||
let mut last_full = std::time::Instant::now();
|
||
|
||
loop {
|
||
let woke = match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||
Ok(e) => Some(e),
|
||
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||
};
|
||
let do_full = matches!(woke, Some(WEvent::Full));
|
||
if let Some(WEvent::Fs) = woke {
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
apply_mediasotre_payload(&app);
|
||
continue;
|
||
}
|
||
#[cfg(not(target_os = "android"))]
|
||
{
|
||
while rx.try_recv().is_ok() {}
|
||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||
while rx.try_recv().is_ok() {}
|
||
}
|
||
}
|
||
if do_full {
|
||
tr("watch: full rescan (5min)");
|
||
rescan(&app);
|
||
watched_dirs = collect_watched_dirs(&scan::roots());
|
||
sig = fast_dir_sig(&watched_dirs);
|
||
last_full = std::time::Instant::now();
|
||
continue;
|
||
}
|
||
|
||
// Refresh watched dirs periodically (every 30s) to pick up new subdirs
|
||
if last_dir_refresh.elapsed() >= std::time::Duration::from_secs(30) {
|
||
watched_dirs = collect_watched_dirs(&scan::roots());
|
||
last_dir_refresh = std::time::Instant::now();
|
||
}
|
||
|
||
// MediaStore event path (Android): MainActivity drops a one-line
|
||
// payload per changed URI and wakes us via nativeFsChange.
|
||
let _applied_ms = if RESCANNING.load(std::sync::atomic::Ordering::Relaxed) {
|
||
false
|
||
} else {
|
||
apply_mediasotre_payload(&app)
|
||
};
|
||
|
||
// Fast directory mtime-diff watch (Android and desktop): catches
|
||
// out-of-band filesystem adds/removes within ~1 second without
|
||
// scanning 10k files.
|
||
let next = fast_dir_sig(&watched_dirs);
|
||
let changed = if baseline {
|
||
baseline = false;
|
||
Vec::new()
|
||
} else {
|
||
changed_dirs(&sig, &next)
|
||
};
|
||
sig = next;
|
||
|
||
if !changed.is_empty() {
|
||
tr(&format!("watch: {} changed dir(s)", changed.len()));
|
||
rescan_subtrees(&app, &changed);
|
||
watched_dirs = collect_watched_dirs(&scan::roots());
|
||
sig = fast_dir_sig(&watched_dirs);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
#[cfg(target_os = "android")]
|
||
init_logcat();
|
||
|
||
tauri::Builder::default()
|
||
.register_uri_scheme_protocol("gallery", |ctx, request| {
|
||
// Sync protocol: the handler runs on tauri's io thread pool,
|
||
// off the webview main thread; no response channel involved.
|
||
serve_sync(ctx, request)
|
||
})
|
||
.setup(|app| {
|
||
let handle = app.handle().clone();
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
// Tauri's document_dir = getExternalFilesDir(DOCUMENTS);
|
||
// calling the resolver is what creates the dir.
|
||
match app.path().document_dir() {
|
||
Ok(dir) => {
|
||
init_fr_logger(&dir);
|
||
init_trace(&dir);
|
||
}
|
||
Err(e) => log::error!("document_dir: {e}"),
|
||
}
|
||
}
|
||
log::info!("setup (mobile={})", cfg!(target_os = "android"));
|
||
|
||
// FR batch → file (same as the harnesses).
|
||
let _ = app.listen("gtauri-fr", |event| {
|
||
if let Some(tx) = FR_TX.lock().unwrap().as_ref() {
|
||
// payload arrives JSON-encoded (quoted, \n-escaped) — decode it
|
||
let s = serde_json::from_str::<String>(event.payload())
|
||
.unwrap_or_else(|_| event.payload().to_string());
|
||
let _ = tx.send(s);
|
||
}
|
||
});
|
||
let _ = app.listen("gtauri-log", |event| {
|
||
// JS-side trace lines (JSON-encoded string payload)
|
||
let s = serde_json::from_str::<String>(event.payload())
|
||
.unwrap_or_else(|_| event.payload().to_string());
|
||
tr(&s);
|
||
});
|
||
|
||
// JS ready handshake: the WebView's page loads ~2-3 s after
|
||
// process start, while the first gallery-files emit can land
|
||
// 10 ms in (small/fast DB) and be lost. On handshake, re-emit
|
||
// the current state so the grid always gets its initial list.
|
||
let _ = app.listen("gtauri-js-ready", move |event| {
|
||
let _ = event;
|
||
let h = APP_HANDLE.lock().unwrap().clone();
|
||
if let Some(h) = h {
|
||
tr("js ready — re-emitting state");
|
||
emit_files(&h, false, META.read().unwrap().len());
|
||
}
|
||
});
|
||
|
||
// Scroll anchor from JS (throttled to ~7 Hz): `a` = index of the
|
||
// first visible grid cell. REPLACES the prefetch window with
|
||
// [a, a+60) — the visible band plus ~13 rows ahead. Stale
|
||
// off-screen work from a previous position is dropped, never
|
||
// allowed to run ahead of what is about to be seen.
|
||
let _ = app.listen("gtauri-scroll", |event| {
|
||
#[derive(Deserialize)]
|
||
struct Anchor {
|
||
a: usize,
|
||
}
|
||
if let Ok(x) = serde_json::from_str::<Anchor>(event.payload()) {
|
||
tr(&format!("anchor a={}", x.a));
|
||
// Window starts AT the first visible row, not behind it:
|
||
// Chromium's lazy loader can lag a fast jump by a beat,
|
||
// and the workers warming the visible band make those
|
||
// requests pure mem hits. (Warm cells dedupe out.)
|
||
// set_prefetch_window also VPORTs and drops all work
|
||
// left more than STALE_ROWS behind the new position.
|
||
set_prefetch_window(x.a, x.a + 60);
|
||
}
|
||
});
|
||
|
||
*APP_HANDLE.lock().unwrap() = Some(handle.clone());
|
||
// Parallel decode pool: one worker could not keep up with a
|
||
// fast long scroll (observed 2400-deep backlog draining at
|
||
// ~3 cells/s). Four workers decode ~12 full-res photos/s.
|
||
for _ in 0..4 {
|
||
std::thread::spawn(decode_worker);
|
||
}
|
||
std::thread::spawn(heartbeat);
|
||
|
||
// Cache prune on startup (cheap: one readdir).
|
||
std::thread::spawn(|| prune_cache(&thumbs_dir(), THUMB_CACHE_MAX));
|
||
|
||
// Initial meta from the existing DB (instant grid — "show what
|
||
// is available immediately"), then the scan refreshes it.
|
||
let path = db::db_path();
|
||
match db::open(&path) {
|
||
Ok(mut conn) => {
|
||
if let Err(e) = load_meta(&mut conn) {
|
||
log::error!("load meta: {e}");
|
||
}
|
||
}
|
||
Err(e) => log::warn!("no DB yet ({e}); scan will create it"),
|
||
}
|
||
emit_files(&handle, true, META.read().unwrap().len());
|
||
// Warm the first two screens before Chromium's lazy loader gets
|
||
// to them (it requests ~30 at once; we'd rather be ahead).
|
||
set_prefetch_window(0, 36);
|
||
// (The startup MediaStore fast path is triggered by
|
||
// MainActivity.onCreate — it pushes via
|
||
// nativeMediaStoreResults ~1.5 s in.)
|
||
spawn_scan(handle.clone());
|
||
// New photos appear in the grid ~2 s after being written.
|
||
std::thread::spawn(move || watch_files(handle));
|
||
|
||
Ok(())
|
||
})
|
||
.run(tauri::generate_context!())
|
||
.expect("tauri run");
|
||
}
|