refactor(ipc): complete tauri-specta typed-IPC cutover, contract guards, and layering cleanup (#1230)

This commit is contained in:
Psychotoxical
2026-07-04 16:00:31 +02:00
committed by GitHub
parent fdbb9deac6
commit 77ab95170d
199 changed files with 3691 additions and 1252 deletions
+2 -2
View File
@@ -97,7 +97,7 @@ pub struct CoverBackfillWorker {
rerun_pending: AtomicBool,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillPulseDto {
pub scheduled: u32,
@@ -108,7 +108,7 @@ pub struct CoverBackfillPulseDto {
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillRunDto {
pub started: bool,
+30 -5
View File
@@ -51,7 +51,7 @@ use std::time::Duration;
use tokio::sync::{Mutex, Semaphore};
use tauri::{AppHandle, Emitter, Manager};
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureResult {
pub hit: bool,
@@ -65,7 +65,7 @@ pub struct CoverCacheEnsureResult {
/// path, which writes every tier up front).
type EncodeTiersOutcome = Result<(bool, Vec<(u32, PathBuf)>, Option<DynamicImage>), String>;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheStatsDto {
pub bytes: u64,
@@ -76,7 +76,7 @@ pub struct CoverCacheStatsDto {
}
/// Live cover HTTP / WebP-encode slots — mirrors analysis pipeline probe shape.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverPipelineQueueStatsDto {
pub http_max: u32,
@@ -121,7 +121,7 @@ pub(crate) fn cover_pipeline_queue_stats(
}
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureArgs {
pub server_index_key: String,
@@ -752,6 +752,7 @@ pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_run_full_pass(
app: AppHandle,
force: Option<bool>,
@@ -762,6 +763,7 @@ pub async fn library_cover_backfill_run_full_pass(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfillPulseDto, String> {
let worker = app
.try_state::<Arc<CoverBackfillWorker>>()
@@ -770,6 +772,7 @@ pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfil
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), String> {
let worker = app
.try_state::<Arc<CoverBackfillWorker>>()
@@ -780,6 +783,7 @@ pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), S
/// Pause library backfill while the user navigates / visible covers load (Rust pass yields).
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_ui_priority(
app: AppHandle,
hold: bool,
@@ -795,6 +799,7 @@ pub async fn library_cover_backfill_set_ui_priority(
/// + encode pools move together). Not exposed in app Settings by design.
/// Returns the clamped value actually applied.
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_parallel(
app: AppHandle,
threads: usize,
@@ -810,6 +815,7 @@ pub async fn library_cover_backfill_set_parallel(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_configure(
app: AppHandle,
enabled: bool,
@@ -848,6 +854,7 @@ pub async fn library_cover_backfill_configure(
/// timed out against the old address) is cleared and a pass is kicked so they
/// retry on the now-reachable endpoint.
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_base_url(
app: AppHandle,
rest_base_url: String,
@@ -867,7 +874,7 @@ pub async fn library_cover_backfill_set_base_url(
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCachePeekItem {
pub server_index_key: String,
@@ -880,6 +887,7 @@ pub struct CoverCachePeekItem {
/// Best-effort disk hit without network (exact tier, then largest tier on disk ≤ wanted).
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_peek_batch(
app: AppHandle,
items: Vec<CoverCachePeekItem>,
@@ -956,6 +964,7 @@ fn ensure_peek(dir: &Path, tier: u32, args: &CoverCacheEnsureArgs) -> Option<Pat
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_ensure(
app: AppHandle,
args: CoverCacheEnsureArgs,
@@ -965,6 +974,7 @@ pub async fn cover_cache_ensure(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_ensure_batch(
app: AppHandle,
items: Vec<CoverCacheEnsureArgs>,
@@ -984,6 +994,7 @@ pub async fn cover_cache_ensure_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, String> {
let st = state(&app)?;
let root = {
@@ -1006,11 +1017,13 @@ pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, Str
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_evict_tick(_app: AppHandle) -> Result<u32, String> {
Ok(0)
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_stats_server(
app: AppHandle,
server_index_key: String,
@@ -1029,6 +1042,7 @@ pub async fn cover_cache_stats_server(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_get_pipeline_queue_stats(
app: AppHandle,
) -> Result<CoverPipelineQueueStatsDto, String> {
@@ -1042,6 +1056,7 @@ pub async fn cover_cache_get_pipeline_queue_stats(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_clear_server(
app: AppHandle,
server_index_key: String,
@@ -1113,6 +1128,7 @@ fn purge_external_files(server_dir: &Path) -> usize {
/// intact. Fired when the user turns the External Artwork toggle off. Unlike
/// `cover_cache_clear_server`, Navidrome tiers survive.
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_purge_external(
app: AppHandle,
server_index_key: String,
@@ -1156,6 +1172,7 @@ pub async fn cover_cache_purge_external(
/// Always emits `cover:bucket-renamed` with `{oldKey, newKey}` on success so
/// the frontend in-memory disk-src cache can invalidate stale entries.
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_rename_server_bucket(
app: AppHandle,
old_key: String,
@@ -1259,6 +1276,7 @@ fn merge_cover_bucket(old_dir: &std::path::Path, new_dir: &std::path::Path) -> R
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_configure(
app: AppHandle,
max_mb: u64,
@@ -1274,6 +1292,7 @@ pub async fn cover_cache_configure(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
let st = state(&app)?;
let guard = st.lock().await;
@@ -1302,6 +1321,7 @@ pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_batch(
app: AppHandle,
server_index_key: String,
@@ -1333,6 +1353,7 @@ pub async fn library_cover_backfill_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_progress(
app: AppHandle,
server_index_key: String,
@@ -1363,6 +1384,7 @@ pub async fn library_cover_progress(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_clear_fetch_failures(
app: AppHandle,
server_index_key: String,
@@ -1373,6 +1395,7 @@ pub async fn library_cover_clear_fetch_failures(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_catalog_size(
app: AppHandle,
library_server_id: String,
@@ -1389,11 +1412,13 @@ pub async fn library_cover_catalog_size(
}
#[tauri::command]
#[specta::specta]
pub fn cover_revalidate_enqueue() -> Result<(), String> {
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn cover_revalidate_tick(_cycle_days: Option<u32>) -> Result<u32, String> {
Ok(0)
}
+581 -6
View File
@@ -3,9 +3,9 @@
pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
pub(crate) mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub(crate) mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -86,10 +86,232 @@ fn set_app_user_model_id() {
/// crate-by-crate (see the specta-contract plan).
fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Map Rust `i64`/`u64`/`usize`/… to TS `number` globally. This is
// behaviour-preserving, NOT a change: Tauri already serialises these as
// JSON numbers and the hand-written FE DTOs already type them `number`
// (with the same >2^53 truncation JSON.parse does today). The flag only
// makes the generated types match that reality — it avoids ~200 per-field
// `#[specta(type = Number)]` casts across the DTOs. `enable_lossless_bigints`
// (→ TS `bigint`) is the real behaviour change and stays rejected.
//
// Bigint precision audit (Option-A guard, done 2026-07-02): every returned
// i64/u64 field across the collected DTOs is an epoch-ms timestamp (~1e13),
// a catalog/play count (~1e7), a byte size (~1e13 for a large library) or a
// tiny scalar (year, track/disc no., bpm, rating) — all well under 2^53
// (~9e15). No nanosecond timestamps, no integer-encoded hashes (content_hash
// is a String), no unbounded accumulators. If a future DTO adds one, model
// it as a String at the edge instead of relying on this cast.
.dangerously_cast_bigints_to_number()
.commands(tauri_specta::collect_commands![
crate::lib_commands::app_api::core::greet,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
// psysonic-library — remaining typeable commands. Excluded (stay on
// generate_handler! only): the 10 search/browse/track reads whose envelopes
// carry LibraryTrack/Album/ArtistDto (each has `raw_json: Value`, dto.rs) +
// library_upsert_songs_from_api / library_patch_track (serde_json::Value args).
psysonic_library::browse_support::library_reconcile_album_stars,
psysonic_library::commands::library_resolve_cover_entry,
psysonic_library::commands::library_analysis_backfill_batch,
psysonic_library::commands::library_analysis_progress,
psysonic_library::commands::library_count_live_tracks,
psysonic_library::commands::library_get_status,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_sync_bind_session,
psysonic_library::commands::library_sync_clear_session,
psysonic_library::commands::library_set_playback_hint,
psysonic_library::commands::library_get_playback_hint,
psysonic_library::commands::library_sync_start,
psysonic_library::commands::library_sync_verify_integrity,
psysonic_library::commands::library_sync_cancel,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_fact,
psysonic_library::commands::library_record_play_session,
psysonic_library::commands::library_get_player_stats_year_summary,
psysonic_library::commands::library_get_player_stats_heatmap,
psysonic_library::commands::library_get_player_stats_day_detail,
psysonic_library::commands::library_get_player_stats_year_bounds,
psysonic_library::commands::library_get_player_stats_recent_days,
psysonic_library::commands::library_get_recent_play_sessions,
psysonic_library::commands::library_purge_server,
psysonic_library::commands::library_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data,
// psysonic-audio (audio_play + audio_chain_preload excluded: >10 args
// exceed specta's SpectaFn limit — see the note at their definitions)
audio::transport_commands::audio_pause,
audio::transport_commands::audio_resume,
audio::transport_commands::audio_stop,
audio::transport_commands::audio_seek,
audio::mix_commands::audio_set_volume,
audio::mix_commands::audio_update_replay_gain,
audio::mix_commands::audio_set_eq,
audio::mix_commands::audio_set_playback_rate,
audio::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_begin_outgoing_fade,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
audio::autoeq_commands::autoeq_entries,
audio::autoeq_commands::autoeq_fetch_profile,
audio::preload_commands::audio_preload,
audio::radio_commands::audio_play_radio,
audio::preview::audio_preview_play,
audio::preview::audio_preview_stop,
audio::preview::audio_preview_stop_silent,
audio::preview::audio_preview_set_volume,
audio::device_commands::audio_list_devices,
audio::device_commands::audio_canonicalize_selected_device,
audio::device_commands::audio_default_output_device_name,
audio::device_commands::audio_set_device,
// psysonic-analysis (no Value / all ≤10 args — fully typeable)
psysonic_analysis::commands::analysis_get_waveform,
psysonic_analysis::commands::analysis_get_waveform_for_track,
psysonic_analysis::commands::analysis_get_loudness_for_track,
psysonic_analysis::commands::analysis_delete_loudness_for_track,
psysonic_analysis::commands::analysis_delete_waveform_for_track,
psysonic_analysis::commands::analysis_delete_all_waveforms,
psysonic_analysis::commands::analysis_delete_all_for_server,
psysonic_analysis::commands::analysis_get_failed_track_count,
psysonic_analysis::commands::analysis_list_failed_tracks,
psysonic_analysis::commands::analysis_clear_failed_tracks,
psysonic_analysis::commands::analysis_migrate_server_index_keys,
psysonic_analysis::commands::analysis_enqueue_seed_from_url,
psysonic_analysis::commands::analysis_set_playback_priority_hints,
psysonic_analysis::commands::analysis_set_pipeline_parallelism,
psysonic_analysis::commands::analysis_get_pipeline_queue_stats,
psysonic_analysis::commands::analysis_get_backfill_queue_stats,
psysonic_analysis::commands::analysis_prune_pending_to_track_ids,
// psysonic-syncfs (calculate_sync_payload + write/read_device_manifest excluded: serde_json::Value)
psysonic_syncfs::cache::offline::download_track_offline,
psysonic_syncfs::cache::offline::cancel_offline_downloads,
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
psysonic_syncfs::cache::hot::delete_hot_cache_track,
psysonic_syncfs::cache::hot::purge_hot_cache,
psysonic_syncfs::sync::device::sync_track_to_device,
psysonic_syncfs::sync::batch::sync_batch_to_device,
psysonic_syncfs::sync::batch::cancel_device_sync,
psysonic_syncfs::sync::device::compute_sync_paths,
psysonic_syncfs::sync::batch::list_device_dir_files,
psysonic_syncfs::sync::batch::delete_device_file,
psysonic_syncfs::sync::batch::delete_device_files,
psysonic_syncfs::sync::device::get_removable_drives,
psysonic_syncfs::sync::device::write_playlist_m3u8,
psysonic_syncfs::sync::device::rename_device_files,
psysonic_syncfs::cache::downloads::download_zip,
psysonic_syncfs::cache::downloads::check_arch_linux,
psysonic_syncfs::cache::downloads::download_update,
psysonic_syncfs::cache::downloads::open_folder,
psysonic_syncfs::cache::downloads::get_embedded_lyrics,
psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
// cover_cache (cover_revalidate_batch excluded: returns serde_json::Value)
cover_cache::cover_cache_peek_batch,
cover_cache::cover_cache_ensure,
cover_cache::cover_cache_ensure_batch,
cover_cache::cover_cache_stats,
cover_cache::cover_cache_evict_tick,
cover_cache::cover_cache_configure,
cover_cache::cover_cache_clear,
cover_cache::cover_cache_clear_server,
cover_cache::cover_cache_purge_external,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
cover_cache::library_cover_backfill_batch,
cover_cache::library_cover_progress,
cover_cache::library_cover_catalog_size,
cover_cache::library_cover_clear_fetch_failures,
cover_cache::library_cover_backfill_configure,
cover_cache::library_cover_backfill_set_base_url,
cover_cache::library_cover_backfill_pulse,
cover_cache::library_cover_backfill_reset_cursor,
cover_cache::library_cover_backfill_set_ui_priority,
cover_cache::library_cover_backfill_set_parallel,
cover_cache::library_cover_backfill_run_full_pass,
cover_cache::cover_revalidate_enqueue,
cover_cache::cover_revalidate_tick,
// top crate shell commands (set_tray_menu_labels >10 args, backup_*_full/cli_publish_* are Value — all excluded)
crate::lib_commands::app_api::core::exit_app,
crate::lib_commands::app_api::core::set_logging_mode,
crate::lib_commands::app_api::core::get_logging_mode,
crate::lib_commands::app_api::core::tail_runtime_logs,
crate::lib_commands::app_api::core::export_runtime_logs,
crate::lib_commands::app_api::core::frontend_debug_log,
crate::lib_commands::app_api::core::set_subsonic_wire_user_agent,
crate::lib_commands::app_api::perf::performance_cpu_snapshot,
crate::lib_commands::app_api::platform::set_window_decorations,
crate::lib_commands::app_api::platform::set_linux_webkit_smooth_scrolling,
crate::lib_commands::app_api::platform::linux_wayland_gpu_font_tuning_active,
crate::lib_commands::app_api::platform::linux_wayland_text_render_settings_available,
crate::lib_commands::app_api::platform::set_linux_wayland_text_render_profile,
crate::lib_commands::app_api::platform::theme_animation_risk,
crate::lib_commands::app_api::migration::migration_inspect,
crate::lib_commands::app_api::migration::migration_run,
crate::lib_commands::app_api::network::resolve_host_addresses,
crate::lib_commands::app_api::network::server_http_context_clear,
crate::lib_commands::app_api::network::server_http_context_sync,
crate::lib_commands::app_api::network::server_http_context_sync_all,
crate::lib_commands::app_api::backup::backup_export_library_db,
crate::lib_commands::app_api::backup::backup_import_library_db,
crate::lib_commands::app_api::integration::register_global_shortcut,
crate::lib_commands::app_api::integration::unregister_global_shortcut,
crate::lib_commands::app_api::integration::mpris_set_metadata,
crate::lib_commands::app_api::integration::mpris_set_playback,
crate::lib_commands::app_api::integration::check_dir_accessible,
crate::lib_commands::ui::mini::open_mini_player,
crate::lib_commands::ui::mini::preload_mini_player,
crate::lib_commands::ui::mini::close_mini_player,
crate::lib_commands::ui::mini::set_mini_player_always_on_top,
crate::lib_commands::ui::mini::resize_mini_player,
crate::lib_commands::ui::mini::show_main_window,
crate::lib_commands::ui::mini::pause_rendering,
crate::lib_commands::ui::mini::resume_rendering,
crate::lib_commands::sync::tray::no_compositing_mode,
crate::lib_commands::sync::tray::linux_xdg_session_type,
crate::lib_commands::sync::tray::is_tiling_wm_cmd,
crate::lib_commands::sync::tray::toggle_tray_icon,
crate::lib_commands::sync::tray::set_tray_tooltip,
crate::lib_commands::sync::tray::set_tray_menu_labels,
crate::theme_import::import_theme_zip,
crate::library_analysis_backfill::library_analysis_backfill_configure,
// psysonic-integration — typeable subset. Excluded (stay on generate_handler!):
// the nd_list_*/nd_create_*/nd_update_* + scrobbler (audioscrobbler/listenbrainz/
// maloja) + radio-browser + fetch_json_url raw-JSON commands (serde_json::Value /
// passthrough), and discord_update_presence (>10 args) — noted at their defs.
psysonic_integration::bandsintown::fetch_bandsintown_events,
psysonic_integration::navidrome::covers::upload_playlist_cover,
psysonic_integration::navidrome::covers::upload_radio_cover,
psysonic_integration::navidrome::covers::upload_artist_image,
psysonic_integration::navidrome::covers::delete_radio_cover,
psysonic_integration::navidrome::playlists::nd_delete_playlist,
psysonic_integration::navidrome::queries::nd_set_user_libraries,
psysonic_integration::navidrome::queries::nd_get_song_path,
psysonic_integration::navidrome::users::navidrome_login,
psysonic_integration::navidrome::users::nd_delete_user,
psysonic_integration::remote::fetch_url_bytes,
psysonic_integration::remote::fetch_icy_metadata,
psysonic_integration::remote::resolve_stream_url,
psysonic_integration::discord::discord_clear_presence,
])
}
@@ -949,6 +1171,32 @@ pub fn run() {
psysonic_syncfs::cache::downloads::open_folder,
psysonic_syncfs::cache::downloads::get_embedded_lyrics,
psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
// cover_cache (cover_revalidate_batch excluded: returns serde_json::Value)
cover_cache::cover_cache_peek_batch,
cover_cache::cover_cache_ensure,
cover_cache::cover_cache_ensure_batch,
cover_cache::cover_cache_stats,
cover_cache::cover_cache_evict_tick,
cover_cache::cover_cache_configure,
cover_cache::cover_cache_clear,
cover_cache::cover_cache_clear_server,
cover_cache::cover_cache_purge_external,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
cover_cache::library_cover_backfill_batch,
cover_cache::library_cover_progress,
cover_cache::library_cover_catalog_size,
cover_cache::library_cover_clear_fetch_failures,
cover_cache::library_cover_backfill_configure,
cover_cache::library_cover_backfill_set_base_url,
cover_cache::library_cover_backfill_pulse,
cover_cache::library_cover_backfill_reset_cursor,
cover_cache::library_cover_backfill_set_ui_priority,
cover_cache::library_cover_backfill_set_parallel,
cover_cache::library_cover_backfill_run_full_pass,
cover_cache::cover_revalidate_enqueue,
cover_cache::cover_revalidate_tick,
psysonic_integration::bandsintown::fetch_bandsintown_events,
#[cfg(target_os = "windows")]
taskbar_win::update_taskbar_icon,
@@ -959,12 +1207,339 @@ pub fn run() {
#[cfg(test)]
mod specta_export {
// Headless generator + freshness seed: runs the same export as a debug launch
// without a GUI, so `cargo test` regenerates `bindings.ts` and CI can diff it.
// Freshness gate. Exports to a throwaway temp path and asserts byte-equality
// with the committed `src/generated/bindings.ts`. A Rust command/DTO change
// that isn't regenerated diverges here → `cargo test` fails → CI catches the
// stale bindings. Crucially the test exports to a temp file, never the tracked
// one, so it neither dirties the working tree nor races other export tests.
// Regenerate the committed file headlessly with the `#[ignore]`d
// `regenerate_committed_bindings` below (or a `npm run tauri:dev` debug
// launch, which runs `export_specta_bindings()`), then commit the result.
#[test]
fn bindings_are_exportable() {
fn committed_bindings_are_fresh() {
let committed_path = "../src/generated/bindings.ts";
let tmp = std::env::temp_dir().join(format!(
"psysonic-bindings-freshness-{}.ts",
std::process::id()
));
super::specta_builder()
.export(super::bindings_exporter(), "../src/generated/bindings.ts")
.export(super::bindings_exporter(), &tmp)
.expect("failed to export typescript bindings");
let generated = std::fs::read_to_string(&tmp).expect("read freshly exported bindings");
let _ = std::fs::remove_file(&tmp);
let committed =
std::fs::read_to_string(committed_path).expect("read committed bindings.ts");
assert_eq!(
committed, generated,
"src/generated/bindings.ts is stale — regenerate it with \
`cargo test -p psysonic regenerate_committed_bindings -- --ignored` \
and commit the result"
);
}
// Headless regeneration of the committed bindings. `#[ignore]`d so it never
// runs in CI / default `cargo test` (which keeps the tracked file untouched
// and lets `committed_bindings_are_fresh` be the gate); run it explicitly to
// rewrite the file after annotating commands:
// cargo test -p psysonic regenerate_committed_bindings -- --ignored
#[test]
#[ignore = "writes the tracked src/generated/bindings.ts; run explicitly to regenerate"]
fn regenerate_committed_bindings() {
// Export to a sibling temp file then atomically rename over the tracked
// one, so a concurrent reader (e.g. `committed_bindings_are_fresh` under
// `--include-ignored`) never observes a half-written/truncated file.
let target = "../src/generated/bindings.ts";
let tmp = "../src/generated/bindings.ts.regen.tmp";
super::specta_builder()
.export(super::bindings_exporter(), tmp)
.expect("failed to export typescript bindings");
std::fs::rename(tmp, target).expect("failed to move regenerated bindings into place");
}
// ── G-sync anti-drift guard (Option A) ──────────────────────────────────
// Every command registered in the live `generate_handler!` must EITHER be
// collected into `collect_commands!` (so the FE gets a typed binding) OR
// appear in `UNTYPEABLE` below with the reason it can't be typed under the
// pinned specta =2.0.0-rc.25. This fails if a new command lands in the
// handler without doing one or the other — so the typed IPC surface can't
// silently rot as commands are added. The allowlist is exact and can only
// shrink: making a command typeable (a `Value` return replaced by a DTO, an
// arg count dropping to ≤10) means collecting it AND removing it here.
//
// Under Option A `generate_handler!` stays the permanent live handler; this
// test — not a handler flip — is what keeps the two lists in sync.
#[test]
fn typeable_commands_are_all_collected() {
// Known-untypeable under specta =2.0.0-rc.25 (see the specta-contract
// plan). Three reasons only:
const UNTYPEABLE: &[&str] = &[
// (1) serde_json::Value / raw-JSON passthrough in the signature —
// rc.25 registers `Value` as inline-self-recursive and overflows the
// exporter. `raw_json`-carrying library DTO envelopes count too.
"audioscrobbler_request",
"listenbrainz_request",
"maloja_request",
"backup_export_full",
"backup_import_full",
"cli_publish_library_list",
"cli_publish_player_snapshot",
"cli_publish_search_results",
"cli_publish_server_list",
"calculate_sync_payload",
"read_device_manifest",
"write_device_manifest",
"cover_revalidate_batch",
"fetch_json_url",
"get_top_radio_stations",
"search_radio_browser",
"library_advanced_search",
"library_get_artist_lossless_browse",
"library_get_track",
"library_get_tracks_batch",
"library_get_tracks_by_album",
"library_list_albums_by_genre",
"library_list_lossless_albums",
"library_live_search",
"library_search",
"library_search_cross_server",
"library_patch_track",
"library_upsert_songs_from_api",
"nd_create_playlist",
"nd_get_playlist",
"nd_list_playlists",
"nd_update_playlist",
"nd_create_user",
"nd_list_users",
"nd_update_user",
"nd_list_albums_by_artist_role",
"nd_list_artists_by_role",
"nd_list_libraries",
"nd_list_songs",
// (2) >10 total params (State/AppHandle/Window included) exceed
// specta's SpectaFn arg cap. Typing needs the args bundled into a
// struct = an IPC arg-shape change, out of scope for Option A.
"audio_play",
"audio_chain_preload",
"discord_update_presence",
"download_track_local",
// (3) platform-gated — `#[cfg(target_os = "windows")]`, so absent
// from the Linux specta export the committed bindings are built from.
"update_taskbar_icon",
];
let src = include_str!("lib.rs");
let handler = command_names(src, "generate_handler!");
let collected = command_names(src, "collect_commands!");
assert!(
handler.len() > 200 && collected.len() > 150,
"command-list parser found too few entries (handler={}, collected={}) \
— the macro formatting probably changed; fix `command_names`",
handler.len(),
collected.len()
);
let mut uncollected: Vec<&str> =
handler.iter().filter(|c| !collected.contains(*c)).map(String::as_str).collect();
uncollected.sort_unstable();
let unexplained: Vec<&str> =
uncollected.iter().copied().filter(|c| !UNTYPEABLE.contains(c)).collect();
assert!(
unexplained.is_empty(),
"these commands are in generate_handler! but neither collected into \
collect_commands! nor listed in UNTYPEABLE — annotate them with \
#[specta::specta] + add to collect_commands!, or add them to \
UNTYPEABLE with the reason: {unexplained:?}"
);
let stale: Vec<&str> =
UNTYPEABLE.iter().copied().filter(|c| !uncollected.contains(c)).collect();
assert!(
stale.is_empty(),
"these commands are in the UNTYPEABLE allowlist but are no longer \
uncollected (they got collected, or left generate_handler!) — remove \
them from UNTYPEABLE: {stale:?}"
);
}
/// Extract the last `::`-segment identifier of every entry inside a
/// `<macro>![ ... ]` invocation. Mirrors the Python worklist parser: one
/// command per line, skips `//` comments and `#[..]` attribute lines. Picks
/// the real invocation (a `[` follows the macro name), not a doc-comment
/// mention of the macro.
fn command_names(src: &str, macro_call: &str) -> std::collections::HashSet<String> {
let mut out = std::collections::HashSet::new();
let open = src.match_indices(macro_call).find_map(|(idx, _)| {
let rest = &src[idx + macro_call.len()..];
let trimmed = rest.trim_start();
trimmed
.starts_with('[')
.then(|| idx + macro_call.len() + (rest.len() - trimmed.len()))
});
let Some(open) = open else {
return out;
};
let bytes = src.as_bytes();
let (mut i, mut depth, mut close) = (open, 0i32, open);
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
close = i;
break;
}
}
_ => {}
}
i += 1;
}
for raw in src[open + 1..close].lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
continue;
}
let code = line.split("//").next().unwrap_or("").trim().trim_end_matches(',');
let seg = code.rsplit("::").next().unwrap_or("").trim();
let ok = seg.starts_with(|c: char| c.is_ascii_lowercase())
&& seg.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if ok {
out.insert(seg.to_string());
}
}
out
}
// ── Reverse anti-drift guard ────────────────────────────────────────────
// The forward guard above catches a command that lands in `generate_handler!`
// without being collected. It cannot see a command that leaves the handler
// ENTIRELY — exactly how `download_track_local` silently became unregistered
// (offline caching went dead) when the specta annotation pass dropped it from
// `generate_handler!` and its >10-arg signature kept it out of
// `collect_commands!` too, so it fell through both lists. This test scans every
// `#[tauri::command]` fn in the workspace sources and asserts each is wired into
// `generate_handler!`, so a command can never fall out of the live IPC surface
// unnoticed.
#[test]
fn every_command_is_registered_in_the_handler() {
// Commands intentionally NOT in `generate_handler!` (invoked in-process,
// never over IPC). Empty by default — add one only with the reason it is
// internal-only; the stale-check below drops any entry that got registered.
const HANDLER_EXEMPT: &[&str] = &[];
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut command_fns = Vec::new();
for root in ["src", "crates"] {
collect_command_fns(&manifest.join(root), &mut command_fns);
}
assert!(
command_fns.len() > 200,
"command-fn scanner found too few #[tauri::command] fns ({}) — the \
attribute/fn layout probably changed; fix `collect_command_fns`",
command_fns.len()
);
// This guard compares handler↔command by BARE fn name, so two commands
// sharing a bare name across crates would be indistinguishable — one could
// drop out of the handler while its namesake keeps the check green. Fail
// loudly if that ever happens rather than silently trusting the name.
let mut counts = std::collections::HashMap::new();
for name in &command_fns {
*counts.entry(name.as_str()).or_insert(0) += 1;
}
let mut collisions: Vec<&str> =
counts.iter().filter(|(_, n)| **n > 1).map(|(name, _)| *name).collect();
collisions.sort_unstable();
assert!(
collisions.is_empty(),
"two #[tauri::command] fns share a bare name {collisions:?} — this guard \
can't tell them apart; disambiguate before the name-based check is trusted"
);
let handler = command_names(include_str!("lib.rs"), "generate_handler!");
let mut missing: Vec<&str> = command_fns
.iter()
.map(String::as_str)
.filter(|c| !handler.contains(*c) && !HANDLER_EXEMPT.contains(c))
.collect();
missing.sort_unstable();
missing.dedup();
assert!(
missing.is_empty(),
"these #[tauri::command] fns are defined but NOT registered in \
generate_handler! — they are dead over IPC (add them to the handler, \
or to HANDLER_EXEMPT if intentionally internal-only): {missing:?}"
);
let stale: Vec<&str> = HANDLER_EXEMPT
.iter()
.copied()
.filter(|c| command_fns.iter().any(|f| f == c) && handler.contains(*c))
.collect();
assert!(
stale.is_empty(),
"these commands are in HANDLER_EXEMPT but are actually registered in \
generate_handler! — remove them from HANDLER_EXEMPT: {stale:?}"
);
}
/// Recursively collect the name of every `#[tauri::command]` fn under `dir`.
/// Matches only a real attribute line (`#[tauri::command...`), never a
/// doc-comment *mention* of the macro (those start with `///`), then reads the
/// fn name from the next `fn` declaration, skipping intervening `#[..]`
/// attribute lines.
fn collect_command_fns(dir: &std::path::Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().and_then(|n| n.to_str()) == Some("target") {
continue;
}
collect_command_fns(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let lines: Vec<&str> = src.lines().collect();
for (i, raw) in lines.iter().enumerate() {
if !raw.trim_start().starts_with("#[tauri::command") {
continue;
}
for line in &lines[i + 1..] {
let t = line.trim_start();
if t.starts_with("#[") || t.starts_with("//") || t.is_empty() {
continue;
}
if let Some(name) = fn_name(t) {
out.push(name);
break;
}
// A non-attribute, non-comment, non-blank line that is not a
// fn decl is an attribute *continuation* (a multi-line
// `#[tauri::command(\n rename_all = ...\n)]`) — keep scanning
// to the fn instead of giving up and dropping the command.
}
}
}
}
}
/// Extract `name` from a `... fn name(...)` declaration line.
fn fn_name(line: &str) -> Option<String> {
let after = line.split(" fn ").nth(1).or_else(|| line.strip_prefix("fn "))?;
let name: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
}
@@ -21,6 +21,7 @@ struct FullBackupPayload {
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn backup_export_library_db(
app: AppHandle,
destination_path: String,
@@ -64,6 +65,7 @@ fn backup_export_library_db_blocking(app: &AppHandle, destination_path: String)
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn backup_import_library_db(app: AppHandle, source_path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
backup_import_library_db_blocking(&app, source_path)
+9 -2
View File
@@ -11,6 +11,7 @@ pub(crate) fn greet(name: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn exit_app(app_handle: tauri::AppHandle) {
if let Some(cache) = app_handle.try_state::<analysis_cache::AnalysisCache>() {
let _ = cache.checkpoint_wal("exit");
@@ -21,28 +22,31 @@ pub(crate) fn exit_app(app_handle: tauri::AppHandle) {
#[tauri::command]
#[specta::specta]
pub(crate) fn set_logging_mode(mode: String) -> Result<(), String> {
crate::logging::set_logging_mode_from_str(&mode)
}
#[tauri::command]
#[specta::specta]
pub(crate) fn get_logging_mode() -> String {
crate::logging::current_mode_str().to_string()
}
#[tauri::command]
#[specta::specta]
pub(crate) fn export_runtime_logs(path: String) -> Result<usize, String> {
crate::logging::export_logs_to_file(&path)
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogLineDto {
pub seq: u64,
pub text: String,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogTailDto {
pub lines: Vec<LogLineDto>,
@@ -54,6 +58,7 @@ pub(crate) struct LogTailDto {
/// `after_seq` is the highest seq the UI already has (omit for
/// the initial fetch of the most recent `max` lines).
#[tauri::command]
#[specta::specta]
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
let tail = crate::logging::tail_logs(after_seq, max.unwrap_or(2000));
LogTailDto {
@@ -68,12 +73,14 @@ pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> L
}
#[tauri::command]
#[specta::specta]
pub(crate) fn frontend_debug_log(scope: String, message: String) -> Result<(), String> {
crate::app_deprintln!("[frontend][{}] {}", scope, message);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub(crate) fn set_subsonic_wire_user_agent(
user_agent: String,
window_label: String,
@@ -4,6 +4,7 @@ use tauri::Emitter;
use crate::{MprisControls, ShortcutMap};
#[tauri::command]
#[specta::specta]
pub(crate) fn register_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
@@ -49,6 +50,7 @@ pub(crate) fn register_global_shortcut(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn unregister_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
@@ -70,6 +72,7 @@ pub(crate) fn unregister_global_shortcut(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn mpris_set_metadata(
controls: tauri::State<MprisControls>,
title: Option<String>,
@@ -146,6 +149,7 @@ fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn mpris_set_playback(
controls: tauri::State<MprisControls>,
playing: bool,
@@ -168,6 +172,7 @@ pub(crate) fn mpris_set_playback(
/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend).
#[tauri::command]
#[specta::specta]
pub(crate) fn check_dir_accessible(path: String) -> bool {
std::path::Path::new(&path).is_dir()
}
@@ -27,14 +27,14 @@ fn migration_lock() -> &'static Mutex<()> {
LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ServerIndexMapping {
pub legacy_id: String,
pub index_key: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationScopeInspect {
pub total_legacy_rows: u64,
@@ -42,7 +42,7 @@ pub struct MigrationScopeInspect {
pub tables: HashMap<String, u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationInspectReport {
pub needs_migration: bool,
@@ -64,7 +64,7 @@ pub struct MigrationProgressEvent {
pub total: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationRunScope {
pub imported_rows: u64,
@@ -72,7 +72,7 @@ pub struct MigrationRunScope {
pub skipped_unknown_server_rows: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationRunResult {
pub library: MigrationRunScope,
@@ -83,6 +83,7 @@ pub struct MigrationRunResult {
}
#[tauri::command]
#[specta::specta]
pub fn migration_inspect(
app: AppHandle,
mappings: Vec<ServerIndexMapping>,
@@ -91,6 +92,7 @@ pub fn migration_inspect(
}
#[tauri::command]
#[specta::specta]
pub fn migration_run(
app: AppHandle,
mappings: Vec<ServerIndexMapping>,
+5 -5
View File
@@ -1,12 +1,12 @@
mod backup;
pub(crate) mod backup;
mod cli_bridge;
// `pub(crate)` so tauri-specta's `collect_commands!` can reach the `#[specta::specta]`
// helper macro by full path (a `pub use` of the fn does not carry the macro).
pub(crate) mod core;
mod integration;
mod migration;
mod network;
mod perf;
pub(crate) mod integration;
pub(crate) mod migration;
pub(crate) mod network;
pub(crate) mod perf;
pub(crate) mod platform;
// Tauri commands re-exported for the lib.rs invoke_handler.
@@ -21,6 +21,7 @@ use tokio::net::lookup_host;
/// Returns an empty vec on lookup failure (the UI then shows no hint, by
/// design: a transient DNS hiccup shouldn't block save).
#[tauri::command]
#[specta::specta]
pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<String>, String> {
let trimmed = hostname.trim();
if trimmed.is_empty() {
@@ -64,6 +65,7 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_sync(
registry: State<'_, Arc<ServerHttpRegistry>>,
wire: ServerHttpContextSyncWire,
@@ -73,6 +75,7 @@ pub(crate) fn server_http_context_sync(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_sync_all(
registry: State<'_, Arc<ServerHttpRegistry>>,
entries: Vec<ServerHttpContextSyncWire>,
@@ -82,6 +85,7 @@ pub(crate) fn server_http_context_sync_all(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_clear(
registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
+4 -3
View File
@@ -14,20 +14,20 @@ use std::fs;
#[cfg(any(target_os = "linux", target_os = "macos"))]
const CHILD_RESCAN_EVERY: u8 = 8;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerfProcessMemory {
pub label: String,
pub rss_kb: u64,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerfThreadCpuGroup {
pub label: String,
pub thread_count: u32,
pub jiffies: u64,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerformanceCpuSnapshot {
pub supported: bool,
pub total_jiffies: u64,
@@ -503,6 +503,7 @@ mod macos {
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn performance_cpu_snapshot(
include_thread_groups: Option<bool>,
) -> Result<PerformanceCpuSnapshot, String> {
@@ -166,6 +166,7 @@ pub(crate) fn linux_webkit_disable_media_session(win: &tauri::WebviewWindow) ->
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
#[specta::specta]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
@@ -185,6 +186,7 @@ pub(crate) fn theme_animation_risk() -> bool {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.set_decorations(enabled);
@@ -210,6 +212,7 @@ pub(crate) fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, en
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
@@ -232,6 +235,7 @@ pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri
/// True when [`linux_webkit_apply_wayland_gpu_font_tuning`] would change WebKit settings
/// (Wayland + GPU compositing, user has not set `PSYSONIC_SKIP_WAYLAND_FONT_TUNING`).
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_wayland_gpu_font_tuning_active() -> bool {
#[cfg(target_os = "linux")]
{
@@ -284,6 +288,7 @@ pub(crate) fn linux_webkit_apply_wayland_text_render_profile(
/// Persist the Wayland text profile for the next app start and for new mini-player webviews.
/// Does **not** touch WebKit on existing windows (avoids WebKitGTK hangs when toggling policy live).
#[tauri::command]
#[specta::specta]
pub(crate) fn set_linux_wayland_text_render_profile(
profile: String,
app_handle: tauri::AppHandle,
@@ -303,6 +308,7 @@ pub(crate) fn set_linux_wayland_text_render_profile(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_wayland_text_render_settings_available() -> bool {
#[cfg(target_os = "linux")]
{
+1 -1
View File
@@ -1,4 +1,4 @@
mod tray;
pub(crate) mod tray;
pub(crate) use tray::{
is_tiling_wm_cmd, linux_xdg_session_type, no_compositing_mode, set_tray_menu_labels, set_tray_tooltip,
+6
View File
@@ -259,6 +259,7 @@ pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option<TrayIcon> {
/// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator
/// extension) show it; pure-GNOME without the extension does not.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_tray_tooltip(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
@@ -323,6 +324,7 @@ pub(crate) fn set_tray_tooltip(
/// immediately to live menu items via `set_text` (no tray rebuild required)
/// and cached so the labels survive a tray hide/show cycle.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub(crate) fn set_tray_menu_labels(
app: tauri::AppHandle,
@@ -385,6 +387,7 @@ pub(crate) fn set_tray_menu_labels(
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
#[specta::specta]
pub(crate) fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
@@ -453,6 +456,7 @@ pub(crate) fn is_tiling_wm() -> bool {
/// The frontend uses this to apply a CSS class that swaps out GPU-only effects
/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents.
#[tauri::command]
#[specta::specta]
pub(crate) fn no_compositing_mode() -> bool {
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
@@ -462,6 +466,7 @@ pub(crate) fn no_compositing_mode() -> bool {
/// Tauri command: `XDG_SESSION_TYPE` from the host environment (e.g. `wayland`, `x11`).
/// Used for Linux-only UI tweaks such as font rasterisation hints; empty string when unset.
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_xdg_session_type() -> String {
#[cfg(target_os = "linux")]
{
@@ -481,6 +486,7 @@ pub(crate) fn is_tiling_wm() -> bool {
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]
#[specta::specta]
pub(crate) fn is_tiling_wm_cmd() -> bool {
is_tiling_wm()
}
+8
View File
@@ -271,6 +271,7 @@ pub(crate) fn build_mini_player_window(
/// workaround; this command is used by Linux/macOS when the user opts into
/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists.
#[tauri::command]
#[specta::specta]
pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if app.get_webview_window("mini").is_some() {
return Ok(());
@@ -284,6 +285,7 @@ pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// Opening the mini player minimizes the main window; hiding the mini
/// player restores the main window.
#[tauri::command]
#[specta::specta]
pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let win = match app.get_webview_window("mini") {
Some(w) => w,
@@ -332,6 +334,7 @@ pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// Hide the mini player window if it exists and restore the main window.
/// Does not destroy the mini window so its state is preserved for next open.
#[tauri::command]
#[specta::specta]
pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
let _ = win.eval(PAUSE_RENDERING_JS);
@@ -350,6 +353,7 @@ pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// window's JS is paused while minimized on WebKitGTK. Also hides the mini
/// window so the two don't sit on screen at the same time.
#[tauri::command]
#[specta::specta]
pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.eval(PAUSE_RENDERING_JS);
@@ -366,6 +370,7 @@ pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`).
#[tauri::command]
#[specta::specta]
pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string())
}
@@ -373,6 +378,7 @@ pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String
/// Resume rendering work in the current webview. Called when the window
/// becomes visible again.
#[tauri::command]
#[specta::specta]
pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())
}
@@ -385,6 +391,7 @@ pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), Strin
/// re-shown, or focus was lost and the WM dropped the constraint. We
/// always force a `false → true` cycle so the WM re-evaluates the layer.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
if on_top {
@@ -401,6 +408,7 @@ pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool)
/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when
/// the queue panel is open).
#[tauri::command]
#[specta::specta]
pub(crate) fn resize_mini_player(
app: tauri::AppHandle,
width: f64,
+1 -1
View File
@@ -1,4 +1,4 @@
mod mini;
pub(crate) mod mini;
pub(crate) use mini::{
close_mini_player, open_mini_player, pause_rendering, persist_mini_pos_throttled,
@@ -20,6 +20,7 @@ pub fn init_library_analysis_backfill(app: &AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call (like cover configure + workers).
pub async fn library_analysis_backfill_configure(
app: AppHandle,
+2 -1
View File
@@ -21,13 +21,14 @@ const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
#[derive(Serialize, specta::Type)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
#[specta::specta]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file