backend for reporting and commands #22

This commit is contained in:
Novattz
2026-03-28 15:07:37 +01:00
parent f949ecf2f3
commit 1571e9d87d
2 changed files with 257 additions and 0 deletions

View File

@@ -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::LocalReport> {
reporting::load_local_reports()
}
#[tauri::command]
async fn get_game_votes(game_id: String) -> Result<Vec<reporting::VoteResult>, 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::<Vec<reporting::VoteResult>>()
.await
.map_err(|e| format!("Failed to parse votes: {}", e))
}
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
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");

177
src-tauri/src/reporting.rs Normal file
View File

@@ -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<std::path::PathBuf, String> {
Ok(get_cache_dir()?.join("reports.json"))
}
/// Load all locally recorded votes.
pub fn load_local_reports() -> Vec<LocalReport> {
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<String, String> {
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<String, String> {
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()))
}
}