diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c3ef2e7..66783f5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ )] mod cache; +mod reporting; mod utils; mod dlc_manager; mod installer; @@ -613,6 +614,81 @@ async fn resolve_platform_conflict( Ok(updated_game) } +#[tauri::command] +fn set_reporting_opt_in(opted_in: bool) -> Result<(), String> { + config::update_config(|cfg| { + cfg.reporting_opted_in = opted_in; + cfg.reporting_has_seen_prompt = true; + })?; + + if opted_in { + // Ensure a salt exists so future hashes work immediately + reporting::delete_salt().ok(); // clear any stale one first + // re-create via generate_user_hash is fine; salt is lazy-created there + } else { + reporting::delete_salt()?; + } + + info!("Reporting opt-in set to: {}", opted_in); + Ok(()) +} + +#[tauri::command] +async fn submit_report( + game_id: String, + unlocker: String, + worked: bool, + steam_path: String, +) -> Result<(), String> { + let user_hash = reporting::generate_user_hash(&steam_path)?; + + reporting::post_report(reporting::ReportPayload { + user_hash, + game_id: game_id.clone(), + unlocker: unlocker.clone(), + worked, + }) + .await?; + + // Always save locally so the UI can reflect the vote immediately, + // regardless of opt-in status (the local file is only used client-side). + reporting::save_local_report(reporting::LocalReport { + game_id, + unlocker, + worked, + })?; + + Ok(()) +} + +#[tauri::command] +fn get_local_reports() -> Vec { + reporting::load_local_reports() +} + +#[tauri::command] +async fn get_game_votes(game_id: String) -> Result, String> { + let url = format!("https://api.shibe.fun/v1/votes/{}", game_id); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .map_err(|e| format!("Failed to fetch votes: {}", e))?; + + if !response.status().is_success() { + // Non-critical - return empty rather than surfacing an error to the UI + return Ok(Vec::new()); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse votes: {}", e)) +} + fn setup_logging() -> Result<(), Box> { use log::LevelFilter; use log4rs::append::file::FileAppender; @@ -676,6 +752,10 @@ fn main() { resolve_platform_conflict, load_config, update_config, + set_reporting_opt_in, + submit_report, + get_local_reports, + get_game_votes, ]) .setup(|app| { info!("Tauri application setup"); diff --git a/src-tauri/src/reporting.rs b/src-tauri/src/reporting.rs new file mode 100644 index 0000000..a2348ae --- /dev/null +++ b/src-tauri/src/reporting.rs @@ -0,0 +1,177 @@ +use crate::cache::get_cache_dir; +use crate::config; +use log::{info, warn}; +use rand::distr::Alphanumeric; +use rand::Rng; +use reqwest::Client; +use serde::{Serialize, Deserialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::time::Duration; + +const API_BASE: &str = "https://api.shibe.fun/v1"; +const SALT_LENGTH: usize = 32; + +// Report payload + +#[derive(Serialize, Debug)] +pub struct ReportPayload { + pub user_hash: String, + pub game_id: String, + /// "creamlinux" | "smokeapi" + pub unlocker: String, + /// true = worked, false = didn't work + pub worked: bool, +} + +/// Mirrors the JSON returned by GET /v1/votes/:game_id +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct VoteResult { + pub unlocker: String, + pub success: u32, + pub fail: u32, +} + +// Local report record + +/// One entry in the local reports.json cache. +/// Tracks what the user has already voted so we can disable buttons in the UI. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct LocalReport { + pub game_id: String, + pub unlocker: String, // "creamlinux" | "smokeapi" + pub worked: bool, +} + +// reports.json helpers + +fn reports_cache_path() -> Result { + Ok(get_cache_dir()?.join("reports.json")) +} + +/// Load all locally recorded votes. +pub fn load_local_reports() -> Vec { + match reports_cache_path() { + Ok(path) if path.exists() => { + fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + _ => Vec::new(), + } +} + +/// Save a new vote to reports.json (or overwrite an existing one for the same +/// game_id + unlocker combo). +pub fn save_local_report(report: LocalReport) -> Result<(), String> { + let path = reports_cache_path()?; + let mut reports = load_local_reports(); + + // Upsert: replace existing entry for the same game + unlocker, otherwise push + let pos = reports + .iter() + .position(|r| r.game_id == report.game_id && r.unlocker == report.unlocker); + + match pos { + Some(i) => reports[i] = report, + None => reports.push(report), + } + + let json = serde_json::to_string_pretty(&reports) + .map_err(|e| format!("Failed to serialize reports cache: {}", e))?; + fs::write(&path, json) + .map_err(|e| format!("Failed to write reports cache: {}", e))?; + + Ok(()) +} + +// Salt management + +fn get_or_create_salt() -> Result { + let salt_path = get_cache_dir()?.join("salt"); + + if salt_path.exists() { + let salt = fs::read_to_string(&salt_path) + .map_err(|e| format!("Failed to read salt file: {}", e))?; + let salt = salt.trim().to_string(); + + if salt.len() == SALT_LENGTH { + return Ok(salt); + } + + warn!("Salt file has invalid data, regenerating..."); + } + + let salt: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(SALT_LENGTH) + .map(char::from) + .collect(); + + fs::write(&salt_path, &salt) + .map_err(|e| format!("Failed to write salt file: {}", e))?; + + info!("Generated new reporting salt"); + Ok(salt) +} + +pub fn delete_salt() -> Result<(), String> { + let salt_path = get_cache_dir()?.join("salt"); + + if salt_path.exists() { + fs::remove_file(&salt_path) + .map_err(|e| format!("Failed to delete salt: {}", e))?; + info!("Deleted reporting salt (user opted out)"); + } + Ok(()) +} + +// Hash generation + +pub fn generate_user_hash(steam_path: &str) -> Result { + let machine_id = fs::read_to_string("/etc/machine-id") + .map_err(|e| format!("Failed to read machine-id: {}", e))?; + let machine_id = machine_id.trim(); + + let salt = get_or_create_salt()?; + let combined = format!("{}{}{}", machine_id, steam_path, salt); + + let mut hasher = Sha256::new(); + hasher.update(combined.as_bytes()); + Ok(format!("{:x}", hasher.finalize())) +} + +// HTTP + +pub async fn post_report(payload: ReportPayload) -> Result<(), String> { + let cfg = config::load_config()?; + + if !cfg.reporting_opted_in { + info!("Reporting disabled - skipping report for game {}", payload.game_id); + return Ok(()); + } + + let client = Client::new(); + let url = format!("{}/report", API_BASE); + + info!( + "Submitting report: game={}, unlocker={}, worked={}", + payload.game_id, payload.unlocker, payload.worked + ); + + let response = client + .post(&url) + .json(&payload) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Failed to send report: {}", e))?; + + if response.status().is_success() { + info!("Report submitted successfully"); + Ok(()) + } else { + Err(format!("Report submission failed: HTTP {}", response.status())) + } +} \ No newline at end of file