Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions app/desktop-ui/src-tauri/src/asset_scope.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// Scopes the Tauri asset protocol (asset://, exposed to the frontend via
// convertFileSrc()) to the configured clip/recording folders, so <video>/<img>
// tags can stream straight from disk instead of round-tripping through a local
// HTTP server. Re-run on startup and whenever settings are saved, since the
// output folders are user-configurable. See docs/DECISIONS.md.
// Refresh asset permissions after output-folder changes; include .thumbs.

use tauri::{AppHandle, Manager};

Expand Down
12 changes: 3 additions & 9 deletions app/desktop-ui/src-tauri/src/clip_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ fn media_folder(source: ClipSource) -> PathBuf {
}
}

// ── Bookmarks ─────────────────────────────────────────────────────────────
// The engine inserts bookmarks at recording-save time (timestamped live from
// its own clock); the UI reads/edits them here. Table DDL mirrors
// engine's storage.cpp so either side can create it (IF NOT EXISTS).
// Bookmark sequences are 1-based within each clip.

const BOOKMARK_DDL: &str = "CREATE TABLE IF NOT EXISTS clip_bookmarks (
clip_id INTEGER NOT NULL,
Expand Down Expand Up @@ -173,9 +170,7 @@ fn map_clip_row(
let game_display_name = row.get::<_, Option<String>>(6)?;
let discord_app_id = row.get::<_, Option<String>>(9)?;
let game_executable_path = row.get::<_, Option<String>>(10)?;
// Cache-only read: the clip grid must never make a synchronous network
// call. Artwork not yet cached shows up once the scheduled refresh
// (see game_catalog::refresh_stale) has run.
// Artwork lookup is cache-only; network refresh runs separately.
let art = artwork.resolve(discord_app_id.as_deref(), game_process_name.as_deref());
let video_path = folder.join(&video_file);
let thumbnail_path = thumbnail_file.as_ref().map(|file| {
Expand Down Expand Up @@ -633,8 +628,7 @@ pub fn rename_clip(source: ClipSource, id: i64, new_stem: &str) -> Result<(), St
id
],
) {
// Roll the filesystem back so the DB never points at files that no
// longer exist under their recorded names.
// Restore the prior filename if the catalog update fails.
let _ = fs::rename(folder.join(&new_video), folder.join(&old_video));
if thumb_renamed {
let _ = fs::rename(
Expand Down
13 changes: 3 additions & 10 deletions app/desktop-ui/src-tauri/src/collections.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// Collections ("albums"): user-curated groupings of clips that span both
// sources (replay + manual). Stored in a single global collections.db under
// %LocalAppData%\Monolith - deliberately not per-catalog, because a collection
// mixes clips from clips.db and recs.db. Rows referencing clips that have
// since been deleted are pruned on read.
// Collection members use (source, clip_id); IDs can overlap across catalogs.

use crate::clip_catalog::{self, ClipSource};
use crate::paths;
Expand Down Expand Up @@ -35,8 +31,7 @@ fn open() -> Result<Connection, String> {
Ok(conn)
}

// UTC ISO-8601 in the same format the engine's storage layer writes
// ("2026-08-11T12:34:56Z") so SQLite string ordering stays consistent.
// Use UTC ISO-8601 to match the engine catalog timestamps.
fn now_iso8601_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down Expand Up @@ -175,9 +170,7 @@ pub fn remove_clip_from_collection(
Ok(())
}

// Clips in a collection, most recently added first. Stale rows (the clip was
// deleted from its catalog, or its video file is gone) are pruned here so the
// collection never shows ghosts.
// Prune absent clips only after all required catalogs have been read.
pub fn collection_clips(collection_id: i64) -> Result<Vec<clip_catalog::Clip>, String> {
let conn = open()?;
let mut stmt = conn
Expand Down
49 changes: 6 additions & 43 deletions app/desktop-ui/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,4 @@
// Tauri IPC commands invoked from the frontend via `invoke()`. Replaces the old
// loopback HTTP `/api/*` routes (server.rs, removed) now that the window loads
// bundled assets directly (see main.rs) and has the Tauri JS API available.
//
// Read-only catalog commands work without the recorder. Delete and rename
// cross to the engine over JSON-RPC so it owns their catalog/filesystem recovery. See
// docs/DECISIONS.md.
//
// IMPORTANT: every command here is `async fn`. Tauri v2 runs plain
// (non-async) commands inline on the main thread, which is also the WebView2
// message-pump thread - a synchronous SQLite query or engine_rpc TCP call
// there freezes the whole window. `async fn` commands are dispatched via
// `async_runtime::spawn`, and the blocking bodies below run on
// `spawn_blocking` so the UI thread never waits on disk/network I/O.
// Native Tauri commands. Run blocking disk, decode and network work through spawn_blocking.

use crate::{clip_catalog, engine_rpc, game_catalog, settings_store};
#[cfg(target_os = "windows")]
Expand All @@ -27,10 +14,7 @@ fn parse_source(source: &str) -> Result<ClipSource, String> {
ClipSource::parse(source).ok_or_else(|| "bad source".to_string())
}

// Runs a blocking closure off the main thread and returns its value.
// Panics inside `f` are not expected in normal operation (they'd indicate a
// bug in the catalog/engine code, same as today), so they're surfaced by
// unwrapping the join result rather than silently swallowed.
// The closure runs on a blocking worker, outside the Tauri message loop.
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
tauri::async_runtime::spawn_blocking(f)
.await
Expand All @@ -48,7 +32,6 @@ async fn blocking_result<T: Send + 'static>(
.map_err(|err| err.to_string())?
}

// ── Clip catalog (read) ───────────────────────────────────────────────────

#[tauri::command]
pub async fn list_clips(filter: ClipFilter) -> Vec<Clip> {
Expand All @@ -65,7 +48,6 @@ pub async fn distinct_hashtags() -> Vec<String> {
blocking(clip_catalog::distinct_hashtags).await
}

// ── Engine status / recorder control (JSON-RPC to 127.0.0.1:45991) ───────

#[tauri::command]
pub async fn engine_status() -> Value {
Expand Down Expand Up @@ -109,7 +91,6 @@ pub async fn set_selected_game(exe: String, pid: Option<u32>) -> Result<(), Stri
}
}

// ── Clip mutations ──────────────────────────────────────────────────────

#[tauri::command]
pub async fn clip_set_duration(source: String, id: i64, duration: f64) -> Result<(), String> {
Expand All @@ -129,7 +110,6 @@ pub async fn thumb_capture(source: String, id: i64, data_url: String) -> Result<
})
.await;
if let Err(err) = &result {
// Surface fallback-thumbnail failures instead of silently no-opping.
eprintln!("[thumb_capture] {source} clip {id} failed: {err}");
}
result
Expand Down Expand Up @@ -185,10 +165,7 @@ pub async fn clip_delete(source: String, id: i64) -> Result<(), String> {
}
}

// Opens Explorer with the clip's video file pre-selected. `/select,` is an
// Explorer-native flag (no shell interpretation of the path), so this is safe
// against injection as long as `path` is a real filesystem path from the
// catalog rather than arbitrary user text.
// Explorer requires /select, and the path in separate arguments.
#[tauri::command]
pub async fn reveal_in_explorer(path: String) -> Result<(), String> {
blocking_result(move || {
Expand All @@ -202,11 +179,7 @@ pub async fn reveal_in_explorer(path: String) -> Result<(), String> {
.await
}

// Spawns the component updater (Updater.exe) with its window visible - the
// Settings "Check for updates" action. Resolution mirrors the engine's
// resolve_tauri_ui() (settings_window.cpp): installed exe next to the app
// root, then the CMake build output, then a dev cargo tree. The updater has
// its own single-instance guard, so a double spawn just focuses the window.
// Resolve Updater.exe beside the recorder or in the development build tree.
#[tauri::command]
pub async fn open_updater() -> Result<(), String> {
blocking_result(move || {
Expand Down Expand Up @@ -306,7 +279,6 @@ pub async fn recording_add_bookmark() -> Result<(), String> {
}
}

// ── Bookmarks (direct SQLite writes; engine is the writer at save time) ───

#[tauri::command]
pub async fn clip_list_bookmarks(source: String, id: i64) -> Result<Vec<clip_catalog::BookmarkRow>, String> {
Expand Down Expand Up @@ -344,7 +316,6 @@ pub async fn clip_delete_bookmark(source: String, id: i64, seq: i64) -> Result<(
blocking_result(move || clip_catalog::remove_bookmark(src, id, seq)).await
}

// ── Collections (global collections.db; see collections.rs) ──────────────

#[tauri::command]
pub async fn list_collections() -> Result<Vec<crate::collections::CollectionSummary>, String> {
Expand Down Expand Up @@ -391,17 +362,13 @@ pub async fn collection_clips(collection_id: i64) -> Result<Vec<Clip>, String> {
blocking_result(move || crate::collections::collection_clips(collection_id)).await
}

// ── Settings ───────────────────────────────────────────────────────────

#[tauri::command]
pub async fn get_settings() -> Value {
blocking(|| settings_store::read_config().unwrap_or(Value::Null)).await
}

// Rejects a config document that assigns the same normalized hotkey chord to
// more than one action. Defense-in-depth mirror of the check the engine's
// `settings::save()` also runs before persisting - this one catches it before
// the write even happens. "NONE" (hotkey disabled) never collides with itself.
// Ignore disabled NONE bindings when checking normalized hotkey conflicts.
fn find_hotkey_collision(config: &Value) -> Option<String> {
let hotkeys = config.get("hotkeys")?.as_object()?;
let entries: [(&str, &str); 5] = [
Expand Down Expand Up @@ -450,9 +417,7 @@ pub async fn save_settings(app: tauri::AppHandle, config: Value) -> Result<(), S
// (it may not be running - it reads the new values at next start).
eprintln!("engine settings reload failed: {err}");
}
// Output folders may have changed: re-scope the asset protocol so
// convertFileSrc() keeps working for the (possibly new) clip/recording
// dirs. Cheap in-memory scope update, safe to run on the main thread.
// Add asset permissions for the updated output folders.
crate::asset_scope::refresh(&app);
Ok(())
}
Expand All @@ -476,7 +441,6 @@ pub async fn pick_folder(current: Option<String>) -> Option<String> {
.await
}

// ── Native app icon (PNG extracted from an exe, base64 data URL) ─────────

#[cfg(target_os = "windows")]
#[tauri::command]
Expand All @@ -488,7 +452,6 @@ pub async fn exe_icon(path: String, process: String) -> Option<String> {
.await
}

// ── Game catalog / artwork ────────────────────────────────────────────────

#[tauri::command]
pub async fn game_catalog_map() -> BTreeMap<String, CatalogEntry> {
Expand Down
17 changes: 2 additions & 15 deletions app/desktop-ui/src-tauri/src/exe_icon.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
// Extracts the embedded icon of a local executable and returns it as PNG bytes.
// Used by /api/exe-icon so the UI can render the *real* application icon (title
// bar capture status pattern) instead of remote artwork or a raw process name.
// Windows-only by definition (SHDefExtractIconW).
//
// Cached in game_catalog.db keyed by process name (not install path) - see
// game_catalog::cached_exe_icon/store_exe_icon - so the same game keeps its
// icon across reinstalls, relocations, or different machines instead of
// re-extracting (and never sharing a cache entry) whenever the exe's on-disk
// path differs.
// Extract local executable icons as PNG; this path performs no network lookup.

use std::path::Path;

Expand All @@ -22,11 +13,7 @@ use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, HICON, I

const ICON_SIZE: u32 = 64;

/// PNG bytes of the executable's icon, or None when the path is not a
/// readable executable or carries no extractable icon. `process_name` (the
/// exe's own basename, e.g. "RocketLeague.exe") is the cache key; when it's
/// empty the result is extracted fresh every call rather than cached, since
/// there's no stable key to persist it under.
/// Return None when extraction fails or the executable has no icon.
pub fn icon_png(path: &str, process_name: &str) -> Option<Vec<u8>> {
if process_name.is_empty() {
return extract(path);
Expand Down
24 changes: 4 additions & 20 deletions app/desktop-ui/src-tauri/src/game_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

// Discord is only ever used to enrich the display (name/icon/cover) of a game
// the C++ recorder's local heuristic already detected - never to decide which
// process is active. All reads used by the clip grid go through
// resolve_artwork_cached (below), which never makes a network call; artwork
// is populated/refreshed only by the scheduled background job in main.rs
// (see refresh_stale) and by the live "currently playing" titlebar lookup.
// Detection uses the local game-list database; Discord supplies display artwork only.

const DISCORD_FETCH_TIMEOUT: Duration = Duration::from_secs(3);

Expand Down Expand Up @@ -170,13 +165,7 @@ pub fn resolve_icon(process_name: &str) -> Option<String> {
entry_by_process(process_name).and_then(|entry| entry.icon_url)
}

// Executable icons (PNG bytes extracted via SHDefExtractIconW, see exe_icon.rs)
// are cached here by process name rather than by install path, so the same
// game keeps its icon across reinstalls/relocations/different machines
// instead of re-extracting (and never sharing a cache entry) every time the
// exe's on-disk path differs. Reuses the same table as Discord artwork: an
// exe icon and Discord artwork for the same process name are just two
// columns of the one row keyed by process_name_lower.
// Cache executable icons by normalized process name rather than installation path.
pub fn cached_exe_icon(process_name: &str) -> Option<Vec<u8>> {
let key = process_name.to_lowercase();
let conn = open(true)?;
Expand Down Expand Up @@ -263,9 +252,7 @@ impl CatalogEntry {
}
}

// Network-capable lookup: fetches from Discord (with an explicit timeout) when
// the cache is missing or incomplete. Used only by explicit/live contexts
// (the titlebar's "currently playing" indicator), never by the clip grid.
// Network-capable artwork lookup for live/explicit requests; exclude the clip grid.
pub fn resolve_artwork(app_id: Option<&str>, process_name: Option<&str>) -> CatalogEntry {
if let Some(app_id) = app_id.filter(|id| !id.is_empty()) {
if let Some(entry) = entry_by_app_id(app_id) {
Expand Down Expand Up @@ -364,10 +351,7 @@ fn fetch_and_cache_discord_app(app_id: &str, process_name: Option<&str>) -> Opti
})
}

// Background refresh: re-fetches any catalog row with a known discord_app_id
// whose last_updated is older than max_age (or never set), so the clip grid's
// cache-only reads (resolve_artwork_cached) stay reasonably fresh without ever
// blocking a display path on the network. Call from a background thread.
// Refresh stale artwork on a background worker.
pub fn refresh_stale(max_age: Duration) {
let Some(conn) = open(false) else { return };
if let Err(err) = ensure_schema(&conn) {
Expand Down
18 changes: 3 additions & 15 deletions app/desktop-ui/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
// Monolith Desktop UI host (Tauri v2 / WebView2). The window loads the bundled
// Preact frontend directly (native Tauri IPC: invoke/commands/events/asset
// protocol) instead of navigating to a local loopback HTTP server. The engine
// (Monolith.exe) is a separate process, reached only over JSON-RPC on
// 127.0.0.1:45991 for clip mutations / settings reload / recorder control
// (engine_rpc.rs) - that transport is unchanged and also used by the Stream
// Deck plugin. See docs/DECISIONS.md (ADR superseding ADR-0011/0012).
// Tauri hosts bundled frontend assets and the native command/event bridge.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod asset_scope;
Expand All @@ -22,9 +16,7 @@ use std::thread;
use std::time::Duration;
use tauri::{Emitter, WebviewUrl, WebviewWindowBuilder};

// Polls the engine's clip-generation counter and emits a `clips` event to the
// frontend whenever it changes (a clip was saved/stopped). Mirrors the old SSE
// stream but over a native Tauri event; cheap for a single local user.
// Emit clips events when the engine generation counter changes.
fn spawn_clip_watch(app: tauri::AppHandle) {
thread::spawn(move || {
let mut last = engine_rpc::clip_generation();
Expand All @@ -40,11 +32,7 @@ fn spawn_clip_watch(app: tauri::AppHandle) {
});
}

// Discord artwork cache refresh: the clip grid only ever reads
// game_catalog.db locally (resolve_artwork_cached), so any entry with a known
// discord_app_id gets re-fetched here in the background on a 72h cadence
// instead of on the display path. Runs once at startup, then re-checks every
// 6h so a long-running session doesn't need a restart to pick up refreshes.
// Refresh artwork in the background; the grid reads only cached rows.
fn spawn_artwork_refresh() {
thread::spawn(|| {
const REFRESH_AGE: Duration = Duration::from_secs(72 * 3600);
Expand Down
6 changes: 1 addition & 5 deletions app/desktop-ui/src-tauri/src/settings_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,7 @@ pub fn read_runtime_status() -> Value {
.unwrap_or_else(|| Value::Object(Map::new()))
}

// output_dirs() is on the hot path of every catalog command (db_path +
// media_folder each call it). Reading settings.db + parsing the full JSON
// each time showed up with large libraries, so cache the result keyed by the
// settings.db mtime+size - a stat() instead of a DB round-trip, and still
// correct when the engine (or anything else) rewrites the file.
// Fingerprint both SQLite files because WAL writes may leave the database timestamp unchanged.
struct DirCache {
path: PathBuf,
stamps: [(Option<SystemTime>, u64); 2],
Expand Down
3 changes: 1 addition & 2 deletions app/desktop-ui/ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ export function App() {
return () => document.removeEventListener("contextmenu", block);
}, []);

// Stable callbacks: ClipCard is memoized, so inline closures here would
// re-render the whole grid on every unrelated state change.
// Keep callbacks stable for memoized cards.
const openMenu = useCallback((e: MouseEvent, clip: Clip) => {
setMenu({ x: e.clientX, y: e.clientY, clip });
}, []);
Expand Down
12 changes: 2 additions & 10 deletions app/desktop-ui/ui/src/home/clip-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,7 @@ export const ClipCard = memo(function ClipCard(
regenTried.current = false;
}, [clip.id, clip.source, clip.thumbnail_file, clip.duration_seconds]);

// Icon is always resolved locally: prefer the icon embedded in the game's
// own executable (no network, no cache dependency), then fall back to
// whatever list_clips already resolved from the local Discord artwork
// cache (game_icon_url) - never fetched here, so opening the library never
// triggers a network call. A game newly added to the cache picks up its
// icon on the next scheduled refresh (see game_catalog::refresh_stale).
// Prefer the executable icon, then cached artwork; avoid network lookup per card.
useEffect(() => {
let active = true;
if (!clip.game_executable_path) {
Expand Down Expand Up @@ -96,10 +91,7 @@ export const ClipCard = memo(function ClipCard(
video.load();
};

// WebView2 can't decode the default .mkv container, so the <video> canvas
// fallback below never fires for most clips. Ask the engine (FFmpeg, decodes
// mkv) to regenerate the thumbnail instead; on success it bumps the clip
// generation and the grid reloads with the populated thumbnail_file.
// Use engine thumbnail generation when WebView2 cannot decode the container.
const fallbackToEngine = async () => {
if (cancelled || regenTried.current) return;
regenTried.current = true;
Expand Down
2 changes: 0 additions & 2 deletions app/desktop-ui/ui/src/home/confirm-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ interface Props {
onCancel: () => void;
}

// Custom secondary confirmation popup (deno.md extra #1: delete asks again with
// a custom popup, not a native dialog).
export function ConfirmDialog(
{ title, message, confirmLabel = "Confirm", danger, onConfirm, onCancel }: Props,
) {
Expand Down
Loading
Loading