feat(blacklist): add artist blacklist module with O(1) lookups
Phase 1 of artist blacklist implementation: - Add artist_blacklist module with HashSet-based service for O(1) lookups - Add SQLite persistence with WAL mode - Add global enable/disable feature flag - Add Tauri commands for CRUD operations - Register BlacklistState in app state cc
This commit is contained in:
1 parent
a742c22c8f
commit
26c5a1b0a3
6 files changed
+562
No files matched your search
@@ -0,0 +1,122 @@
|
||||
//! Artist blacklist module
|
||||
//!
|
||||
//! Provides local artist blacklist functionality with:
|
||||
//! - O(1) lookup performance via in-memory HashSet
|
||||
//! - SQLite persistence
|
||||
//! - Global enable/disable toggle (feature flag)
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! The blacklist service is initialized at app startup and registered
|
||||
//! as a Tauri managed state. Use the `is_blacklisted()` method to check
|
||||
//! if an artist should be filtered.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! if state.blacklist.is_blacklisted(artist_id) {
|
||||
//! // Skip this artist
|
||||
//! continue;
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod models;
|
||||
pub mod service;
|
||||
|
||||
pub use models::{BlacklistSettings, BlacklistedArtist};
|
||||
pub use service::BlacklistService;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Thread-safe state wrapper for the blacklist service
|
||||
pub struct BlacklistState {
|
||||
pub service: Mutex<BlacklistService>,
|
||||
}
|
||||
|
||||
impl BlacklistState {
|
||||
/// Create a new blacklist state
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("artist_blacklist.db");
|
||||
let service = BlacklistService::new(&db_path)?;
|
||||
|
||||
Ok(Self {
|
||||
service: Mutex::new(service),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an artist is blacklisted - thread-safe O(1) operation
|
||||
#[inline]
|
||||
pub fn is_blacklisted(&self, artist_id: u64) -> bool {
|
||||
self.service
|
||||
.lock()
|
||||
.map(|s| s.is_blacklisted(artist_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the feature is enabled
|
||||
#[inline]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.service
|
||||
.lock()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Add an artist to the blacklist
|
||||
pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire lock")?
|
||||
.add(artist_id, artist_name, notes)
|
||||
}
|
||||
|
||||
/// Remove an artist from the blacklist
|
||||
pub fn remove(&self, artist_id: u64) -> Result<(), String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire lock")?
|
||||
.remove(artist_id)
|
||||
}
|
||||
|
||||
/// Get all blacklisted artists
|
||||
pub fn get_all(&self) -> Result<Vec<BlacklistedArtist>, String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire lock")?
|
||||
.get_all()
|
||||
}
|
||||
|
||||
/// Set enabled state
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire lock")?
|
||||
.set_enabled(enabled)
|
||||
}
|
||||
|
||||
/// Get blacklist settings
|
||||
pub fn get_settings(&self) -> Result<BlacklistSettings, String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map(|s| s.get_settings())
|
||||
.map_err(|_| "Failed to acquire lock".to_string())
|
||||
}
|
||||
|
||||
/// Get count of blacklisted artists
|
||||
pub fn count(&self) -> usize {
|
||||
self.service.lock().map(|s| s.count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Clear all blacklisted artists
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
self.service
|
||||
.lock()
|
||||
.map_err(|_| "Failed to acquire lock")?
|
||||
.clear_all()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Artist blacklist data models
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A blacklisted artist entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlacklistedArtist {
|
||||
pub artist_id: u64,
|
||||
pub artist_name: String,
|
||||
pub added_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Blacklist settings (enable/disable toggle)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlacklistSettings {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for BlacklistSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Artist blacklist service
|
||||
//!
|
||||
//! Provides O(1) artist blacklist checks using an in-memory HashSet
|
||||
//! with SQLite persistence.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::models::{BlacklistSettings, BlacklistedArtist};
|
||||
|
||||
/// Artist blacklist service with O(1) lookup performance
|
||||
pub struct BlacklistService {
|
||||
conn: Connection,
|
||||
/// In-memory set for O(1) lookups
|
||||
blacklisted_ids: RwLock<HashSet<u64>>,
|
||||
/// Feature flag - when false, is_blacklisted() always returns false
|
||||
enabled: AtomicBool,
|
||||
}
|
||||
|
||||
impl BlacklistService {
|
||||
/// Create a new blacklist service, opening or creating the database
|
||||
pub fn new(db_path: &Path) -> Result<Self, String> {
|
||||
log::info!("[Blacklist] Opening database at: {}", db_path.display());
|
||||
|
||||
let conn = Connection::open(db_path)
|
||||
.map_err(|e| format!("Failed to open blacklist database: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
|
||||
|
||||
let service = Self {
|
||||
conn,
|
||||
blacklisted_ids: RwLock::new(HashSet::new()),
|
||||
enabled: AtomicBool::new(true),
|
||||
};
|
||||
|
||||
service.init_schema()?;
|
||||
service.load_from_db()?;
|
||||
service.load_settings()?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Initialize database schema
|
||||
fn init_schema(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
-- Artist blacklist entries
|
||||
CREATE TABLE IF NOT EXISTS artist_blacklist (
|
||||
artist_id INTEGER PRIMARY KEY,
|
||||
artist_name TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
-- Index for name search in UI
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_blacklist_name
|
||||
ON artist_blacklist(artist_name COLLATE NOCASE);
|
||||
|
||||
-- Settings table (single row)
|
||||
CREATE TABLE IF NOT EXISTS blacklist_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Insert default settings if not present
|
||||
INSERT OR IGNORE INTO blacklist_settings (id, enabled) VALUES (1, 1);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize blacklist schema: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all blacklisted IDs from database into memory
|
||||
fn load_from_db(&self) -> Result<(), String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT artist_id FROM artist_blacklist")
|
||||
.map_err(|e| format!("Failed to prepare blacklist query: {}", e))?;
|
||||
|
||||
let ids: Vec<u64> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to query blacklist: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let count = ids.len();
|
||||
let mut set = self
|
||||
.blacklisted_ids
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire write lock")?;
|
||||
*set = ids.into_iter().collect();
|
||||
|
||||
log::info!("[Blacklist] Loaded {} blacklisted artists into memory", count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load enabled setting from database
|
||||
fn load_settings(&self) -> Result<(), String> {
|
||||
let enabled: bool = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT enabled FROM blacklist_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let val: i32 = row.get(0)?;
|
||||
Ok(val != 0)
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to load blacklist settings: {}", e))?;
|
||||
|
||||
self.enabled.store(enabled, Ordering::Relaxed);
|
||||
log::info!("[Blacklist] Feature enabled: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an artist is blacklisted - O(1) operation
|
||||
///
|
||||
/// Returns false if the feature is disabled.
|
||||
#[inline]
|
||||
pub fn is_blacklisted(&self, artist_id: u64) -> bool {
|
||||
// Fast path: if feature is disabled, always return false
|
||||
if !self.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// O(1) HashSet lookup
|
||||
self.blacklisted_ids
|
||||
.read()
|
||||
.map(|set| set.contains(&artist_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Add an artist to the blacklist
|
||||
pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO artist_blacklist (artist_id, artist_name, added_at, notes)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![artist_id as i64, artist_name, now, notes],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add artist to blacklist: {}", e))?;
|
||||
|
||||
// Update in-memory set
|
||||
if let Ok(mut set) = self.blacklisted_ids.write() {
|
||||
set.insert(artist_id);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[Blacklist] Added artist: {} (id={})",
|
||||
artist_name,
|
||||
artist_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an artist from the blacklist
|
||||
pub fn remove(&self, artist_id: u64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM artist_blacklist WHERE artist_id = ?1",
|
||||
params![artist_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove artist from blacklist: {}", e))?;
|
||||
|
||||
// Update in-memory set
|
||||
if let Ok(mut set) = self.blacklisted_ids.write() {
|
||||
set.remove(&artist_id);
|
||||
}
|
||||
|
||||
log::info!("[Blacklist] Removed artist id={}", artist_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all blacklisted artists
|
||||
pub fn get_all(&self) -> Result<Vec<BlacklistedArtist>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT artist_id, artist_name, added_at, notes
|
||||
FROM artist_blacklist
|
||||
ORDER BY artist_name COLLATE NOCASE",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let artists = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(BlacklistedArtist {
|
||||
artist_id: row.get::<_, i64>(0)? as u64,
|
||||
artist_name: row.get(1)?,
|
||||
added_at: row.get(2)?,
|
||||
notes: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query blacklist: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(artists)
|
||||
}
|
||||
|
||||
/// Get count of blacklisted artists
|
||||
pub fn count(&self) -> usize {
|
||||
self.blacklisted_ids
|
||||
.read()
|
||||
.map(|set| set.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Set the enabled state
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE blacklist_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![if enabled { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update enabled setting: {}", e))?;
|
||||
|
||||
self.enabled.store(enabled, Ordering::Relaxed);
|
||||
log::info!("[Blacklist] Feature enabled set to: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the feature is enabled
|
||||
#[inline]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Get current settings
|
||||
pub fn get_settings(&self) -> BlacklistSettings {
|
||||
BlacklistSettings {
|
||||
enabled: self.is_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all blacklisted artists
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM artist_blacklist", [])
|
||||
.map_err(|e| format!("Failed to clear blacklist: {}", e))?;
|
||||
|
||||
if let Ok(mut set) = self.blacklisted_ids.write() {
|
||||
set.clear();
|
||||
}
|
||||
|
||||
log::info!("[Blacklist] Cleared all entries");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_test_service() -> BlacklistService {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test_blacklist.db");
|
||||
BlacklistService::new(&db_path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_check() {
|
||||
let service = create_test_service();
|
||||
|
||||
assert!(!service.is_blacklisted(123));
|
||||
|
||||
service.add(123, "Test Artist", None).unwrap();
|
||||
|
||||
assert!(service.is_blacklisted(123));
|
||||
assert!(!service.is_blacklisted(456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let service = create_test_service();
|
||||
|
||||
service.add(123, "Test Artist", None).unwrap();
|
||||
assert!(service.is_blacklisted(123));
|
||||
|
||||
service.remove(123).unwrap();
|
||||
assert!(!service.is_blacklisted(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enabled_toggle() {
|
||||
let service = create_test_service();
|
||||
|
||||
service.add(123, "Test Artist", None).unwrap();
|
||||
|
||||
// Enabled by default
|
||||
assert!(service.is_blacklisted(123));
|
||||
|
||||
// Disable feature
|
||||
service.set_enabled(false).unwrap();
|
||||
assert!(!service.is_blacklisted(123));
|
||||
|
||||
// Re-enable
|
||||
service.set_enabled(true).unwrap();
|
||||
assert!(service.is_blacklisted(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all() {
|
||||
let service = create_test_service();
|
||||
|
||||
service.add(1, "Artist A", None).unwrap();
|
||||
service.add(2, "Artist B", Some("test note")).unwrap();
|
||||
|
||||
let all = service.get_all().unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
// Should be sorted by name
|
||||
assert_eq!(all[0].artist_name, "Artist A");
|
||||
assert_eq!(all[1].artist_name, "Artist B");
|
||||
assert_eq!(all[1].notes, Some("test note".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Tauri commands for artist blacklist management
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::artist_blacklist::{BlacklistSettings, BlacklistState, BlacklistedArtist};
|
||||
|
||||
/// Get all blacklisted artists
|
||||
#[tauri::command]
|
||||
pub fn get_artist_blacklist(state: State<BlacklistState>) -> Result<Vec<BlacklistedArtist>, String> {
|
||||
state.get_all()
|
||||
}
|
||||
|
||||
/// Add an artist to the blacklist
|
||||
#[tauri::command]
|
||||
pub fn add_to_artist_blacklist(
|
||||
artist_id: u64,
|
||||
artist_name: String,
|
||||
notes: Option<String>,
|
||||
state: State<BlacklistState>,
|
||||
) -> Result<(), String> {
|
||||
state.add(artist_id, &artist_name, notes.as_deref())
|
||||
}
|
||||
|
||||
/// Remove an artist from the blacklist
|
||||
#[tauri::command]
|
||||
pub fn remove_from_artist_blacklist(
|
||||
artist_id: u64,
|
||||
state: State<BlacklistState>,
|
||||
) -> Result<(), String> {
|
||||
state.remove(artist_id)
|
||||
}
|
||||
|
||||
/// Check if an artist is blacklisted
|
||||
#[tauri::command]
|
||||
pub fn is_artist_blacklisted(artist_id: u64, state: State<BlacklistState>) -> Result<bool, String> {
|
||||
Ok(state.is_blacklisted(artist_id))
|
||||
}
|
||||
|
||||
/// Set the blacklist enabled state
|
||||
#[tauri::command]
|
||||
pub fn set_blacklist_enabled(enabled: bool, state: State<BlacklistState>) -> Result<(), String> {
|
||||
state.set_enabled(enabled)
|
||||
}
|
||||
|
||||
/// Check if the blacklist feature is enabled
|
||||
#[tauri::command]
|
||||
pub fn is_blacklist_enabled(state: State<BlacklistState>) -> Result<bool, String> {
|
||||
Ok(state.is_enabled())
|
||||
}
|
||||
|
||||
/// Get blacklist settings
|
||||
#[tauri::command]
|
||||
pub fn get_blacklist_settings(state: State<BlacklistState>) -> Result<BlacklistSettings, String> {
|
||||
state.get_settings()
|
||||
}
|
||||
|
||||
/// Get the count of blacklisted artists
|
||||
#[tauri::command]
|
||||
pub fn get_blacklist_count(state: State<BlacklistState>) -> Result<usize, String> {
|
||||
Ok(state.count())
|
||||
}
|
||||
|
||||
/// Clear all blacklisted artists
|
||||
#[tauri::command]
|
||||
pub fn clear_artist_blacklist(state: State<BlacklistState>) -> Result<(), String> {
|
||||
state.clear_all()
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Exposes backend functionality to the frontend via IPC
|
||||
|
||||
pub mod artist_blacklist;
|
||||
pub mod audio_backends;
|
||||
pub mod audio_diagnostics;
|
||||
pub mod auth;
|
||||
@@ -26,6 +27,7 @@ pub mod smart_playlists;
|
||||
pub mod remote_metadata;
|
||||
pub mod visualizer;
|
||||
|
||||
pub use artist_blacklist::*;
|
||||
pub use audio_backends::*;
|
||||
pub use audio_diagnostics::*;
|
||||
pub use auth::*;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod api_cache;
|
||||
pub mod artist_blacklist;
|
||||
pub mod artist_vectors;
|
||||
pub mod audio;
|
||||
pub mod cache;
|
||||
@@ -223,6 +224,9 @@ pub fn run() {
|
||||
// Initialize artist vector store for playlist suggestions
|
||||
let artist_vectors_state = artist_vectors::ArtistVectorStoreState::new()
|
||||
.expect("Failed to initialize artist vector store");
|
||||
// Initialize artist blacklist state
|
||||
let blacklist_state = artist_blacklist::BlacklistState::new()
|
||||
.expect("Failed to initialize artist blacklist");
|
||||
// Initialize ListenBrainz integration state
|
||||
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new()
|
||||
.expect("Failed to initialize ListenBrainz state");
|
||||
@@ -472,6 +476,7 @@ pub fn run() {
|
||||
.manage(listenbrainz_state)
|
||||
.manage(remote_metadata_state)
|
||||
.manage(artist_vectors_state)
|
||||
.manage(blacklist_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Auth commands
|
||||
commands::init_client,
|
||||
@@ -917,6 +922,16 @@ pub fn run() {
|
||||
// Visualizer commands
|
||||
commands::set_visualizer_enabled,
|
||||
commands::is_visualizer_enabled,
|
||||
// Artist blacklist commands
|
||||
commands::get_artist_blacklist,
|
||||
commands::add_to_artist_blacklist,
|
||||
commands::remove_from_artist_blacklist,
|
||||
commands::is_artist_blacklisted,
|
||||
commands::set_blacklist_enabled,
|
||||
commands::is_blacklist_enabled,
|
||||
commands::get_blacklist_settings,
|
||||
commands::get_blacklist_count,
|
||||
commands::clear_artist_blacklist,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in new issue
Block a user