diff --git a/Cargo.lock b/Cargo.lock index 35767ce..62d07db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "deadlock-api-ingest" -version = "0.2.3" +version = "0.2.4" dependencies = [ "dirs", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 2e26fdb..6a008bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deadlock-api-ingest" -version = "0.2.3" +version = "0.2.4" description = "Deadlock API Ingest" repository = "https://github.com/deadlock-api/deadlock-api-ingest" license = "MIT" diff --git a/install-linux.sh b/install-linux.sh index d24439a..0ce2734 100755 --- a/install-linux.sh +++ b/install-linux.sh @@ -234,10 +234,16 @@ manage_service() { ;; "create") local executable_path="$2" + local service_extra_args="${3:-}" # Create systemd user directory if it doesn't exist mkdir -p "$SYSTEMD_USER_DIR" + local exec_start="$executable_path" + if [[ -n "$service_extra_args" ]]; then + exec_start="$executable_path $service_extra_args" + fi + cat > "$SYSTEMD_SERVICE_FILE" << EOF [Unit] Description=Deadlock API Ingest - Monitors Steam cache for match replays @@ -245,7 +251,7 @@ Documentation=https://github.com/deadlock-api/deadlock-api-ingest [Service] Type=simple -ExecStart=$executable_path +ExecStart=$exec_start Restart=on-failure RestartSec=10 StandardOutput=journal @@ -331,6 +337,50 @@ prompt_for_autostart() { return 0 } +# Function to prompt user for Statlocker integration +prompt_for_statlocker() { + # Check if we're running in an interactive terminal + if [[ ! -t 0 ]] || [[ ! -t 1 ]]; then + log "INFO" "Non-interactive mode detected. Enabling Statlocker integration by default." + return 0 + fi + + log "INFO" "Statlocker integration sends match IDs to statlocker.gg after each ingestion." + log "INFO" "This helps track community match statistics. No personal data is sent." + echo >&2 + + local attempts=0 + local max_attempts=2 + + while [[ $attempts -lt $max_attempts ]]; do + echo -n "Would you like to enable Statlocker integration? (y/n): " >&2 + + local response + if read -t 10 -r response; then + case "${response,,}" in + y|yes) + return 0 + ;; + n|no) + return 1 + ;; + *) + attempts=$((attempts + 1)) + if [[ $attempts -lt $max_attempts ]]; then + echo "Invalid response. Please enter 'y' for yes or 'n' for no." >&2 + fi + ;; + esac + else + log "INFO" "No response received within 10 seconds. Enabling Statlocker integration by default." + return 0 + fi + done + + log "INFO" "Maximum attempts reached. Enabling Statlocker integration by default." + return 0 +} + # --- Main Installation Logic --- main() { log "INFO" "Starting Deadlock API Ingest installation..." @@ -395,8 +445,17 @@ main() { log "INFO" "You can manually download it from: $uninstall_script_url" fi + # Prompt user for Statlocker integration + local extra_args="" + if ! prompt_for_statlocker; then + extra_args="--no-statlocker" + log "INFO" "Statlocker integration disabled." + else + log "SUCCESS" "Statlocker integration enabled." + fi + # Create the main service (but don't enable/start it yet) - manage_service "create" "$final_executable_path" + manage_service "create" "$final_executable_path" "$extra_args" # Prompt user for auto-start setup if prompt_for_autostart; then @@ -458,12 +517,16 @@ main() { local main_created=false local once_created=false - if create_desktop_shortcut "$final_executable_path" "" "Deadlock API Ingest" "Monitors Steam cache for Deadlock match replays"; then + if create_desktop_shortcut "$final_executable_path" "$extra_args" "Deadlock API Ingest" "Monitors Steam cache for Deadlock match replays"; then main_created=true fi # Create "once" shortcut for initial cache ingest only - if create_desktop_shortcut "$final_executable_path" "--once" "Deadlock API Ingest (Once)" "Scan existing Steam cache once and exit"; then + local once_args="--once" + if [[ -n "$extra_args" ]]; then + once_args="--once $extra_args" + fi + if create_desktop_shortcut "$final_executable_path" "$once_args" "Deadlock API Ingest (Once)" "Scan existing Steam cache once and exit"; then once_created=true fi diff --git a/install-windows.ps1 b/install-windows.ps1 index 3cce61b..820a9a8 100755 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -254,12 +254,20 @@ function Set-StartupTask { $vbsWrapperPath = Join-Path -Path $InstallDir -ChildPath "run-hidden.vbs" $vbsContent = @" Set WshShell = CreateObject("WScript.Shell") -WshShell.Run """$ExecutablePath""", 0, False +args = "" +For Each arg In WScript.Arguments + args = args & " " & arg +Next +WshShell.Run """$ExecutablePath""" & args, 0, False "@ Set-Content -Path $vbsWrapperPath -Value $vbsContent -Force - # Define the action (run the VBS wrapper with wscript.exe to hide the window) - $taskAction = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsWrapperPath`"" -WorkingDirectory $InstallDir + # Define the action (run the VBS wrapper with wscript.exe, forwarding extra args to the exe) + $vbsArgs = "`"$vbsWrapperPath`"" + if ($extraArgs -ne "") { + $vbsArgs = "`"$vbsWrapperPath`" $extraArgs" + } + $taskAction = New-ScheduledTaskAction -Execute "wscript.exe" -Argument $vbsArgs -WorkingDirectory $InstallDir # Define the trigger (when to run it - at user logon) $taskTrigger = New-ScheduledTaskTrigger -AtLogOn @@ -394,6 +402,84 @@ try { Write-InstallLog -Level 'INFO' "Installing application..." + # Check if running in interactive mode (used by all prompts below) + $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected + + # Ask user if they want Statlocker integration + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " STATLOCKER INTEGRATION (OPTIONAL) " -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + Write-Host "Statlocker integration sends match IDs to statlocker.gg after each ingestion." -ForegroundColor White + Write-Host "This helps track community match statistics. No personal data is sent." -ForegroundColor White + Write-Host "" + Write-Host "Enable Statlocker integration? (Y/N): " -ForegroundColor Yellow -NoNewline + + $enableStatlocker = $false + $statlockerAttempts = 0 + $maxStatlockerAttempts = 2 + + if (-not $isInteractive) { + Write-Host "Y (default in non-interactive mode)" -ForegroundColor Cyan + Write-InstallLog -Level 'INFO' "Non-interactive mode detected. Enabling Statlocker integration by default." + $enableStatlocker = $true + } else { + $timeoutSeconds = 10 + $startTime = Get-Date + $keyPressed = $false + + while ($statlockerAttempts -lt $maxStatlockerAttempts -and -not $keyPressed) { + if ([Console]::KeyAvailable) { + $response = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + $key = $response.Character.ToString().ToUpper() + + if ($key -eq "Y" -or $key -eq "N") { + Write-Host $key -ForegroundColor Cyan + if ($key -eq "Y") { + $enableStatlocker = $true + } + $keyPressed = $true + break + } else { + $statlockerAttempts++ + if ($statlockerAttempts -lt $maxStatlockerAttempts) { + Write-Host "" + Write-Host "Invalid response. Please enter 'Y' for yes or 'N' for no." -ForegroundColor Yellow + Write-Host "Enable Statlocker integration? (Y/N): " -ForegroundColor Yellow -NoNewline + } + } + } + + # Check timeout + if (((Get-Date) - $startTime).TotalSeconds -ge $timeoutSeconds -and -not $keyPressed) { + Write-Host "Y (timeout - defaulting to yes)" -ForegroundColor Cyan + Write-InstallLog -Level 'INFO' "No response received within $timeoutSeconds seconds. Enabling Statlocker integration by default." + $enableStatlocker = $true + $keyPressed = $true + break + } + + Start-Sleep -Milliseconds 100 + } + + if (-not $keyPressed) { + Write-Host "Y (max attempts reached - defaulting to yes)" -ForegroundColor Cyan + Write-InstallLog -Level 'INFO' "Maximum attempts reached. Enabling Statlocker integration by default." + $enableStatlocker = $true + } + } + + Write-Host "" + + $extraArgs = "" + if ($enableStatlocker) { + Write-InstallLog -Level 'SUCCESS' "Statlocker integration enabled." + } else { + $extraArgs = "--no-statlocker" + Write-InstallLog -Level 'INFO' "Statlocker integration disabled." + } + # Ask user if they want auto-start Write-Host "" Write-Host "========================================" -ForegroundColor Cyan @@ -410,9 +496,6 @@ try { $autoStartAttempts = 0 $maxAutoStartAttempts = 2 - # Check if running in interactive mode - $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected - if (-not $isInteractive) { Write-Host "Y (default in non-interactive mode)" -ForegroundColor Cyan Write-InstallLog -Level 'INFO' "Non-interactive mode detected. Enabling auto-start by default." @@ -551,11 +634,15 @@ try { if ($createShortcut) { # Create main shortcut - New-DesktopShortcut -ExecutablePath $downloadPath + New-DesktopShortcut -ExecutablePath $downloadPath -Arguments $extraArgs # Create "once" shortcut for initial cache ingest only + $onceArgs = "--once" + if ($extraArgs -ne "") { + $onceArgs = "--once $extraArgs" + } New-DesktopShortcut -ExecutablePath $downloadPath ` - -Arguments "--once" ` + -Arguments $onceArgs ` -ShortcutName "$AppName (Once)" ` -Description "Deadlock API Ingest - Scan existing Steam cache once and exit" diff --git a/module.nix b/module.nix index a77a934..3d88ff2 100644 --- a/module.nix +++ b/module.nix @@ -36,6 +36,12 @@ in { description = "Group under which deadlock-api-ingest runs"; }; + statlocker.enable = mkOption { + type = types.bool; + default = true; + description = "Whether to enable Statlocker integration (sends match IDs to statlocker.gg after ingestion)"; + }; + steamUser = mkOption { type = types.nullOr types.str; default = cfg.user; @@ -78,7 +84,7 @@ in { Type = "simple"; User = cfg.user; Group = cfg.group; - ExecStart = "${cfg.package}/bin/deadlock-api-ingest"; + ExecStart = "${cfg.package}/bin/deadlock-api-ingest${lib.optionalString (!cfg.statlocker.enable) " --no-statlocker"}"; Restart = "on-failure"; RestartSec = "10s"; diff --git a/src/main.rs b/src/main.rs index 2e8c3ad..ee88162 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod error; mod ingestion_cache; mod scan_cache; +mod statlocker; mod utils; fn init_tracing() { @@ -34,6 +35,10 @@ fn init_tracing() { fn main() { init_tracing(); + if std::env::args().any(|arg| arg == "--no-statlocker") { + statlocker::disable(); + } + let Ok(steam_dir) = steamlocate::SteamDir::locate() else { error!("Could not find Steam directory. Waiting 30s before exiting."); std::thread::sleep(core::time::Duration::from_secs(30)); diff --git a/src/scan_cache.rs b/src/scan_cache.rs index 8205735..791b30f 100644 --- a/src/scan_cache.rs +++ b/src/scan_cache.rs @@ -1,4 +1,5 @@ use crate::ingestion_cache; +use crate::statlocker; use crate::utils::Salts; use memchr::{memchr, memmem}; use notify::event::{CreateKind, ModifyKind}; @@ -102,6 +103,8 @@ pub(super) fn initial_cache_dir_ingest(cache_dir: &Path) { for salt in &salts { ingestion_cache::mark_ingested(salt); } + let match_ids: Vec = salts.iter().map(|s| s.match_id).collect(); + statlocker::notify_many(&match_ids); } Err(e) => warn!("Failed to ingest salts: {e:?}"), } @@ -141,6 +144,7 @@ pub(super) fn watch_cache_dir(cache_dir: &Path) -> notify::Result<()> { Ok(..) => { info!("Ingested salts: {salts:?}"); ingestion_cache::mark_ingested(&salts); + statlocker::notify(salts.match_id); } Err(e) => warn!("Failed to ingest salts: {e:?}"), } diff --git a/src/statlocker.rs b/src/statlocker.rs new file mode 100644 index 0000000..0f96e39 --- /dev/null +++ b/src/statlocker.rs @@ -0,0 +1,71 @@ +use core::sync::atomic::{AtomicBool, Ordering}; +use core::time::Duration; +use std::sync::{OnceLock, mpsc}; +use tracing::{debug, warn}; + +static STATLOCKER_ENABLED: AtomicBool = AtomicBool::new(true); +static HTTP_CLIENT: OnceLock = OnceLock::new(); +static SENDER: OnceLock> = OnceLock::new(); + +fn client() -> &'static ureq::Agent { + HTTP_CLIENT.get_or_init(|| { + ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .build() + .new_agent() + }) +} + +fn sender() -> &'static mpsc::SyncSender { + SENDER.get_or_init(|| { + let (tx, rx) = mpsc::sync_channel::(1000); + std::thread::Builder::new() + .name("statlocker".into()) + .spawn(move || { + for match_id in rx { + let url = format!("https://statlocker.gg/api/match/{match_id}/populate"); + debug!("Notifying Statlocker for match {match_id}"); + + match client().get(&url).call() { + Ok(resp) if resp.status().is_success() => { + debug!("Statlocker notified successfully for match {match_id}"); + } + Ok(resp) => { + warn!( + "Statlocker returned status {} for match {match_id}", + resp.status() + ); + } + Err(e) => { + warn!("Statlocker request failed for match {match_id}: {e}"); + } + } + } + }) + .expect("failed to spawn statlocker thread"); + tx + }) +} + +pub(crate) fn disable() { + STATLOCKER_ENABLED.store(false, Ordering::Relaxed); +} + +pub(crate) fn notify(match_id: u64) { + if !STATLOCKER_ENABLED.load(Ordering::Relaxed) { + return; + } + + if let Err(e) = sender().try_send(match_id) { + warn!("Failed to enqueue Statlocker notification for match {match_id}: {e}"); + } +} + +pub(crate) fn notify_many(match_ids: &[u64]) { + let mut ids = match_ids.to_vec(); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + notify(id); + } +}