diff --git a/.github/workflows/basic-ci.yml b/.github/workflows/basic-ci.yml index 40b2550..396a299 100644 --- a/.github/workflows/basic-ci.yml +++ b/.github/workflows/basic-ci.yml @@ -1,5 +1,6 @@ name: CI + on: push: branches: @@ -8,10 +9,6 @@ on: branches: - main workflow_dispatch: - -permissions: - contents: read - env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index 98ce708..6d05d3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,7 +728,7 @@ dependencies = [ [[package]] name = "presenceforge" -version = "0.0.0" +version = "0.1.0-dev" dependencies = [ "async-std", "blocking", diff --git a/Cargo.toml b/Cargo.toml index d02e51e..122f165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "presenceforge" -version = "0.0.0" -edition = "2021" +version = "0.1.0-dev" +edition = "2024" authors = ["Sreehari Anil "] description = "A library for Discord Rich Presence (IPC) integration" readme = "README.md" diff --git a/README.md b/README.md index db4782b..3ae053e 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ A Rust library for Discord Rich Presence that actually works without the headach [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](https://github.com/Sreehari425/presenceforge#license) [![Rust](https://img.shields.io/badge/rust-1.70+-blue.svg)](https://www.rust-lang.org) +![Crates.io Version](https://img.shields.io/crates/v/presenceforge) -> **Note**: This is currently in early development (v0.0.0). Things might break +> **Note**: This is currently in development (v0.1.0-dev). Things might break > This is a learning/hobby project. -> -> ⚠️ FINAL WARNING: PresenceForge is a learning/hobby project. If you need production-ready Discord Rich Presence, use a mature library like pypresence, discord-rpc, or CraftPresence. +> Features and APIs may change in future versions. + ## Documentation @@ -26,7 +27,7 @@ A Rust library for Discord Rich Presence that actually works without the headach ## What Works - [x] Linux and macOS (Unix domain sockets) -- [x] Windows support (named pipes) - needs testing +- [x] Windows support (named pipes) - [x] Flatpak Discord support (automatic detection) - [x] Basic Rich Presence activities - [x] Activity builder pattern @@ -34,8 +35,6 @@ A Rust library for Discord Rich Presence that actually works without the headach - [x] Async support with runtime-agnostic design - [x] Support for tokio, async-std, and smol - [x] Flexible pipe/socket selection -- [ ] Error handling could be better -- [ ] Party/lobby features (partial implementation only) ## Quick Start @@ -43,27 +42,26 @@ Add PresenceForge to your `Cargo.toml`: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge" } +presenceforge = "0.1.0-dev" ``` For async support, add one of the runtime features: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } # OR -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["async-std-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["async-std-runtime"] } # OR -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["smol-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["smol-runtime"] } ``` -> **Note**: Not published to crates.io yet. Use the git dependency for now. ### Basic Usage (Synchronous) ```rust -use presenceforge::{DiscordIpcClient, ActivityBuilder}; - +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; fn main() -> Result<(), Box> { let mut client = DiscordIpcClient::new("your_client_id")?; client.connect()?; @@ -173,8 +171,8 @@ fn main() -> Result { ### Game Integration ```rust -use presenceforge::{ActivityBuilder, DiscordIpcClient}; - +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; let activity = ActivityBuilder::new() .state("Forest Level") .details("Fighting goblins") @@ -276,7 +274,7 @@ ActivityBuilder::new() .small_image("image_key") // Small image asset .small_text("Hover text") // Small image hover text .button("Label", "https://url") // Clickable button (max 2) - .party_size(1, 4) // Party size (current, max) + .party("id",1, 4) // Party size (current, max) .build() ``` @@ -341,8 +339,8 @@ cargo run --example async_tokio --features tokio-runtime PresenceForge uses the `Result` type for error handling: ```rust -use presenceforge::{DiscordIpcClient, DiscordIpcError}; - +use presenceforge::DiscordIpcError; +use presenceforge::sync::DiscordIpcClient; match client.connect() { Ok(_) => println!("Connected successfully!"), Err(DiscordIpcError::ConnectionFailed) => { @@ -358,7 +356,7 @@ match client.connect() { - [ ] Party/lobby functionality (partial implementation) - [x] Async support (tokio, async-std, and smol) - [x] More comprehensive examples -- [ ] Publish to crates.io +- [x] Publish to crates.io - [ ] CI/CD pipeline - [x] Proper documentation - [x] Connection retry logic with exponential backoff diff --git a/changelogs/0.1.0-dev.md b/changelogs/0.1.0-dev.md new file mode 100644 index 0000000..f01f23c --- /dev/null +++ b/changelogs/0.1.0-dev.md @@ -0,0 +1,34 @@ +# Unreleased + +## [0.1.0-dev] - dev-release + + +### Added + +#### Core Features + +- Cross-platform Discord IPC support (Unix sockets for Linux/macOS, named pipes for Windows) +- Automatic discovery of Discord IPC pipes/sockets +- Flatpak Discord support with automatic detection +- Synchronous client API (`DiscordIpcClient`) +- Unified async API (`AsyncDiscordIpcClient`) with runtime-agnostic design +- Support for Tokio, async-std, and smol runtimes via feature flags +- Activity builder pattern for creating Rich Presence activities +- Full Discord Rich Presence field support (state, details, timestamps, assets, buttons, party) +- Basic input validation for all Discord field length limits +- Pipe discovery and custom path selection +- Connection timeout configuration +- Retry logic with exponential backoff (sync and async) +- Comprehensive error handling with categorization +- UUID v4-based cryptographic nonces for request tracking + +#### Testing + +- Unit tests for core functionality +- Integration tests for activity builder, serialization, error handling, IPC protocol, and retry logic + +### Feature Flags + +- `tokio-runtime` - Tokio async runtime support +- `async-std-runtime` - async-std runtime support +- `smol-runtime` - smol runtime support diff --git a/docs/ACTIVITY_BUILDER_REFERENCE.md b/docs/ACTIVITY_BUILDER_REFERENCE.md index bcb7766..9abc6af 100644 --- a/docs/ACTIVITY_BUILDER_REFERENCE.md +++ b/docs/ACTIVITY_BUILDER_REFERENCE.md @@ -95,7 +95,7 @@ S = Small Image (overlays large image in bottom-right corner) #### Start Timestamp (Elapsed Time) ```rust -.start_timestamp_now().expect("timestamp") // Start counting from now +.start_timestamp_now()? // Start counting from now (returns Result) // or .start_timestamp(1234567890) // Unix timestamp (u64) ``` @@ -134,15 +134,15 @@ let end_time = now + 300; ### 6. **Party** (`party()`) -#### Note: Partialy tested feature +#### Note: Partially tested feature ```rust -.party("unique-party-id", 2, 4) // party_id, current_size, max_size +.party("unique-party-id", 2, 4) // id, current_size, max_size ``` - **Appears as:** "2 of 4" below the state text - **Parameters:** - - `party_id`: Unique identifier for the party (string) + - `id`: Unique identifier for the party (string) - `current_size`: Current number of players (u32) - `max_size`: Maximum number of players (u32) - **Use for:** Multiplayer games, voice channels, collaborative work @@ -174,7 +174,7 @@ let end_time = now + 300; ### 8. **Secrets** (For "Ask to Join" and Spectate features) > **⚠️ Feature Flag Required:** These methods require the `secrets` feature flag to be enabled. -> Add to your `Cargo.toml`: `presenceforge = { git = "...", features = ["secrets"] }` +> Add to your `Cargo.toml`: `presenceforge = { version = "0.1.0-dev", features = ["secrets"] }` #### Note: untested feature @@ -238,7 +238,8 @@ let end_time = now + 300; Here's an activity using all fields: ```rust -use presenceforge::{ActivityBuilder, DiscordIpcClient}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::ActivityBuilder; let activity = ActivityBuilder::new() // Text @@ -252,7 +253,7 @@ let activity = ActivityBuilder::new() .small_text("Level 42 Warrior") // Time - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? // Party .party("party-12345", 3, 4) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 0b38f8c..9b21881 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -2,7 +2,8 @@ API reference for PresenceForge (work-in-progress; APIs may change). -> ⚠️ **NOTE:** This feature is experimental/untested. Use at your own risk. +> **Note:** PresenceForge v0.1.0-dev is an early development release. +> It’s functional, but features may change or be incomplete. ## Table of Contents @@ -27,7 +28,7 @@ The synchronous Discord IPC client for managing Rich Presence. Creates a new Discord IPC client with automatic pipe discovery. ```rust -use presenceforge::DiscordIpcClient; +use presenceforge::sync::DiscordIpcClient; let client = DiscordIpcClient::new("your_client_id")?; ``` @@ -49,7 +50,8 @@ let client = DiscordIpcClient::new("your_client_id")?; Creates a new Discord IPC client with custom pipe configuration. ```rust -use presenceforge::{DiscordIpcClient, PipeConfig}; +use presenceforge::PipeConfig; +use presenceforge::sync::DiscordIpcClient; // Auto-discovery (equivalent to ::new()) let client = DiscordIpcClient::new_with_config("client_id", None)?; @@ -310,28 +312,19 @@ Adds a button to the Rich Presence (max 2 buttons). ### Party Methods -#### `party_id(self, id: impl Into) -> Self` - -Sets the party ID (for grouping players). - -```rust -.party_id("party_12345") -``` - ---- - -#### `party_size(self, current: i32, max: i32) -> Self` +#### `party(self, id: impl Into, current_size: u32, max_size: u32) -> Self` -Sets the party size display. +Sets the party information (ID and size) in a single method. ```rust -.party_size(2, 4) // Shows "2 of 4" +.party("party_12345", 2, 4) // Shows "2 of 4" ``` **Parameters:** -- `current` - Current number of players -- `max` - Maximum number of players +- `id` - Unique party identifier for grouping players +- `current_size` - Current number of players +- `max_size` - Maximum number of players --- diff --git a/docs/ASYNC_RUNTIMES.md b/docs/ASYNC_RUNTIMES.md index f9eba25..739f5b5 100644 --- a/docs/ASYNC_RUNTIMES.md +++ b/docs/ASYNC_RUNTIMES.md @@ -1,6 +1,7 @@ # Async Runtimes Guide -> ⚠️ **NOTE:** This feature is experimental/untested. Use at your own risk. +> **Note:** PresenceForge v0.1.0-dev is an early development release. +> It’s functional, but features may change or be incomplete. ## Table of Contents @@ -62,13 +63,13 @@ Add to your `Cargo.toml` with **one** of these feature flags: ```toml [dependencies] # For Tokio -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } # For async-std -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["async-std-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["async-std-runtime"] } # For smol -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["smol-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["smol-runtime"] } ``` **This exact code works with all three runtimes:** @@ -89,7 +90,7 @@ async fn setup_presence() -> Result { let activity = ActivityBuilder::new() .state("Playing async") .details("Runtime-agnostic!") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); // Set activity @@ -139,7 +140,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } ``` @@ -162,7 +163,7 @@ async fn main() -> Result { let activity = ActivityBuilder::new() .state("Playing async") .details("Using Tokio") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); // Set activity @@ -211,7 +212,7 @@ async fn main() -> Result { let activity = ActivityBuilder::new() .state(format!("Update #{}", counter)) .details("Tokio Background Task") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); let mut client = presence_client.lock().await; @@ -319,7 +320,7 @@ async-std provides an async API similar to the standard library. ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["async-std-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["async-std-runtime"] } async-std = { version = "1", features = ["attributes"] } ``` @@ -418,7 +419,7 @@ smol is a small and fast async runtime. ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["smol-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["smol-runtime"] } smol = "2" ``` @@ -441,7 +442,7 @@ fn main() -> Result { let activity = ActivityBuilder::new() .state("Playing async") .details("Using smol") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); client.set_activity(&activity).await?; @@ -639,7 +640,8 @@ async fn main() -> Result { ### Sync Code ```rust -use presenceforge::{DiscordIpcClient, ActivityBuilder}; +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; fn main() -> Result<(), Box> { let mut client = DiscordIpcClient::new("client_id")?; diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 965110d..fa8cb78 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -2,7 +2,6 @@ A comprehensive guide to handling errors and implementing retry logic in PresenceForge. -> ⚠️ **NOTE:** This feature is experimental/untested. Use at your own risk. ## Table of Contents @@ -23,7 +22,8 @@ PresenceForge uses the `DiscordIpcError` enum for all error cases. All fallible ### Basic Error Handling ```rust -use presenceforge::{DiscordIpcClient, Result}; +use presenceforge::Result; +use presenceforge::sync::DiscordIpcClient; fn main() -> Result<(), Box> { let mut client = DiscordIpcClient::new("your_client_id")?; @@ -46,12 +46,14 @@ Below are the most common variants in `DiscordIpcError` and when they occur. See When the library fails to open/connect the IPC socket/pipe. Common causes: + - Discord is not running - No available IPC pipes/sockets - Permission denied accessing pipe/socket ```rust -use presenceforge::{DiscordIpcClient, DiscordIpcError}; +use presenceforge::DiscordIpcError; +use presenceforge::sync::DiscordIpcClient; match DiscordIpcClient::new("client_id") { Ok(mut client) => { @@ -202,7 +204,8 @@ if error.is_recoverable() { **Problem:** Connection fails because Discord isn't running. ```rust -use presenceforge::{DiscordIpcClient, DiscordIpcError}; +use presenceforge:: DiscordIpcError; +use presenceforge::sync::DiscordIpcClient; fn connect_to_discord(client_id: &str) -> Result> { match DiscordIpcClient::new(client_id) { @@ -228,14 +231,15 @@ fn connect_to_discord(client_id: &str) -> Result Result<(), Box> { let activity = ActivityBuilder::new() .state("Running") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); loop { @@ -272,7 +276,8 @@ PresenceForge provides built-in support for connection retry and reconnection to The `reconnect()` method closes the existing connection and establishes a new one: ```rust -use presenceforge::{DiscordIpcClient, ActivityBuilder}; +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; use std::time::Duration; fn main() -> Result<(), Box> { @@ -306,7 +311,7 @@ For initial connection, use the `with_retry` function with automatic exponential ```rust use presenceforge::retry::{with_retry, RetryConfig}; -use presenceforge::DiscordIpcClient; +use presenceforge::sync::DiscordIpcClient; fn main() -> Result<(), Box> { // Default: 3 attempts, 1s initial delay, exponential backoff @@ -432,7 +437,8 @@ fn main() -> Result<(), Box> { **Problem:** Environment or configuration issues prevent connection. ```rust -use presenceforge::{DiscordIpcClient, DiscordIpcError}; +use presenceforge::DiscordIpcError; +use presenceforge::sync::DiscordIpcClient; use std::env; fn setup_client() -> Result> { @@ -551,7 +557,8 @@ PresenceForge includes built-in retry utilities with exponential backoff: ```rust use presenceforge::retry::{with_retry, RetryConfig}; -use presenceforge::DiscordIpcClient; + +use presenceforge::sync::DiscordIpcClient; fn connect_with_retry(client_id: &str) -> Result> { // Use default retry config (3 attempts, 1s initial delay, exponential backoff) @@ -584,14 +591,13 @@ let mut client = with_retry(&config, || { })?; ``` - - --- ### 5. Clean Up on Errors ```rust -use presenceforge::{DiscordIpcClient, ActivityBuilder}; +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; fn run_presence() -> Result<(), Box> { let mut client = DiscordIpcClient::new("client_id")?; @@ -613,7 +619,7 @@ fn run_presence() -> Result<(), Box> { result } -```` +``` --- diff --git a/docs/FAQ.md b/docs/FAQ.md index b516656..f4aa201 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -2,8 +2,6 @@ Frequently asked questions and solutions to common problems. -> ⚠️ **WARNING:** PresenceForge is an experimental, hobby project (v0.0.0). Features are partially tested, may break, and should **not** be used in production. - ## Table of Contents - [General Questions](#general-questions) @@ -52,11 +50,13 @@ git ls-remote https://github.com/Sreehari425/presenceforge.git git ls-remote git@github.com:Sreehari425/presenceforge.git ``` -If SSH works, use this in `Cargo.toml`: +If SSH works but you still have issues, please report them on GitHub Issues. + +For now, use version `0.1.0-dev` instead: ```toml [dependencies] -presenceforge = { git = "ssh://git@github.com/Sreehari425/presenceforge.git" } +presenceforge = "0.1.0-dev" ``` --- @@ -74,7 +74,7 @@ The feature is called `tokio-runtime`, not `tokio`: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } ``` Valid features: `tokio-runtime`, `async-std-runtime`, `smol-runtime` @@ -94,7 +94,7 @@ Make sure you're using compatible versions: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } tokio = { version = "1", features = ["full"] } # Use version 1.x ``` @@ -160,8 +160,8 @@ Error: ProtocolError("Handshake failed") 4. **Try a different pipe:** ```rust - use presenceforge::{IpcConnection, PipeConfig, DiscordIpcClient}; - + use presenceforge::{IpcConnection, PipeConfig}; + use presenceforge::DiscordIpcClient; let pipes = IpcConnection::discover_pipes(); for pipe in pipes { println!("Trying pipe: {}", pipe.path); @@ -461,8 +461,7 @@ Yes! Use party methods: ```rust let activity = ActivityBuilder::new() .state("In a Party") - .party_size(2, 4) // 2 of 4 players - .party_id("party123") // Unique party ID + .party("party123", 2, 4) // party_id, current (2 of 4 players), max .build(); ``` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 2d0709d..0bf0ff5 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -2,7 +2,8 @@ Welcome to PresenceForge! This guide will help you get started with integrating Discord Rich Presence into your Rust application. -> ⚠️ **WARNING:** PresenceForge is an experimental, hobby project (v0.0.0). Features are partially tested, may break, and should **not** be used in production. +> **Note:** PresenceForge v0.1.0-dev is an early development release. +> It’s functional, but features may change or be incomplete. ## What is Discord Rich Presence? @@ -35,11 +36,9 @@ Add PresenceForge to your `Cargo.toml`: ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge" } +presenceforge = "0.1.0-dev" ``` -> **Note**: PresenceForge is not yet published to crates.io. Use the git dependency for now. - ### With Async Support If you need async support, add one of the runtime features: @@ -47,13 +46,13 @@ If you need async support, add one of the runtime features: ```toml [dependencies] # For Tokio users -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["tokio-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } # For async-std users -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["async-std-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["async-std-runtime"] } # For smol users -presenceforge = { git = "https://github.com/Sreehari425/presenceforge", features = ["smol-runtime"] } +presenceforge = { version = "0.1.0-dev", features = ["smol-runtime"] } ``` ## Your First Rich Presence @@ -71,7 +70,7 @@ cd my-discord-presence ```toml [dependencies] -presenceforge = { git = "https://github.com/Sreehari425/presenceforge" } +presenceforge = "0.1.0-dev" ``` ### Step 3: Write your first presence @@ -79,7 +78,8 @@ presenceforge = { git = "https://github.com/Sreehari425/presenceforge" } Edit `src/main.rs`: ```rust -use presenceforge::{DiscordIpcClient, ActivityBuilder}; +use presenceforge::ActivityBuilder; +use presenceforge::sync::DiscordIpcClient; use std::thread; use std::time::Duration; @@ -97,7 +97,7 @@ fn main() -> Result<(), Box> { let activity = ActivityBuilder::new() .state("Hello, Discord!") .details("Using PresenceForge") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build(); // Set the activity @@ -215,7 +215,7 @@ thread::sleep(Duration::from_secs(5)); client.set_activity(&ActivityBuilder::new() .state("In Match") .details("Competitive Mode") - .start_timestamp_now().expect("timestamp") + .start_timestamp_now()? .build())?; ``` @@ -246,7 +246,7 @@ fn main() { } fn run() -> Result<(), Box> { - let mut client = DiscordIpcClient::new("your_client_id")?; + let mut client = presenceforge::sync::DiscordIpcClient::new("your_client_id")?; client.connect()?; let activity = ActivityBuilder::new() diff --git a/docs/PIPE_SELECTION.md b/docs/PIPE_SELECTION.md index 864875a..1ea8b1f 100644 --- a/docs/PIPE_SELECTION.md +++ b/docs/PIPE_SELECTION.md @@ -300,8 +300,9 @@ let client = DiscordIpcClient::new_with_config( Connection errors are handled through the standard `DiscordIpcError` enum: ```rust -use presenceforge::{DiscordIpcError, PipeConfig, DiscordIpcClient}; +use presenceforge::{DiscordIpcError, PipeConfig}; +use presenceforge::sync::DiscordIpcClient; match DiscordIpcClient::new_with_config( "client_id", Some(PipeConfig::CustomPath("/invalid/path".to_string())) diff --git a/examples/README.md b/examples/README.md index aaab409..20b71ba 100644 --- a/examples/README.md +++ b/examples/README.md @@ -72,6 +72,9 @@ cargo run --example connection_retry -- --client-id YOUR_CLIENT_ID # Async Tokio reconnect - Connection retry with Tokio async runtime cargo run --example async_tokio_reconnect --features tokio-runtime -- --client-id YOUR_CLIENT_ID +# Update activity with Tokio - Update state/details without resetting the timer +cargo run --example update_activity_tokio --features tokio-runtime -- --client-id YOUR_CLIENT_ID + # Flatpak Discord - Connect to Flatpak Discord using custom path configuration cargo run --example flatpak_discord -- --client-id YOUR_CLIENT_ID ``` @@ -183,7 +186,7 @@ Advanced pipe discovery example showing: Error handling and recovery example (synchronous) showing: -- Basic retry with `with_retry()` function +- Basic retry with `with_retry()` functions - Manual reconnection using `reconnect()` method - Custom retry configuration (max attempts, delays, backoff) - Handling recoverable vs non-recoverable errors @@ -199,6 +202,18 @@ Async error handling with Tokio showing: - Resilient connection loop with exponential backoff - Async error recovery patterns +### `update_activity_tokio.rs` + +Activity state updates without timer reset (Tokio async) showing: + +- **Updating activity state/details while keeping the same timer** +- Using a consistent `start_timestamp` across all updates +- Demonstrating multiple state changes (main menu → in game → multiplayer → loading → back to menu) +- Perfect for games or apps that need to update status without resetting elapsed time +- Shows how to maintain session continuity across state changes + +**Key Feature:** The elapsed time on Discord continues uninterrupted when you update the activity while maintaining the original timestamp! + ### `flatpak_discord.rs` Flatpak Discord example showing: diff --git a/examples/async_tokio_reconnect.rs b/examples/async_tokio_reconnect.rs index c4cced2..5a10e4b 100644 --- a/examples/async_tokio_reconnect.rs +++ b/examples/async_tokio_reconnect.rs @@ -1,9 +1,9 @@ #[cfg(feature = "tokio-runtime")] mod tokio_example { use clap::Parser; - use presenceforge::retry::{with_retry_async, RetryConfig}; + use presenceforge::retry::{RetryConfig, with_retry_async}; use presenceforge::{ActivityBuilder, AsyncDiscordIpcClient, Result}; - use tokio::time::{sleep, Duration}; + use tokio::time::{Duration, sleep}; /// Discord Rich Presence Async Tokio Reconnection Example #[derive(Parser, Debug)] diff --git a/examples/basic.rs b/examples/basic.rs index cfb1f92..9d35f3a 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,5 +1,6 @@ use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, Result}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, Result}; use std::time::Duration; /// Discord Rich Presence Basic Example diff --git a/examples/basic_flatpak.rs b/examples/basic_flatpak.rs index 0041a12..61af550 100644 --- a/examples/basic_flatpak.rs +++ b/examples/basic_flatpak.rs @@ -5,7 +5,8 @@ // If Flatpak Discord is not active, it will fallback to standard Discord. use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, IpcConnection, PipeConfig, Result}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, IpcConnection, PipeConfig, Result}; use std::time::Duration; /// Discord Rich Presence Flatpak Example diff --git a/examples/builder_all.rs b/examples/builder_all.rs index e94ac9d..8387efe 100644 --- a/examples/builder_all.rs +++ b/examples/builder_all.rs @@ -4,7 +4,8 @@ // of what each field does and how it appears in Discord. use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, Result}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, Result}; use std::time::Duration; /// Discord Rich Presence Complete Builder Example @@ -118,6 +119,7 @@ fn main() -> Result { .build(); // Set the activity + // println!("{activity:?}"); client.set_activity(&activity)?; println!("✓ Activity set successfully!"); println!("\n📱 Check your Discord profile to see the activity!"); diff --git a/examples/coding_status.rs b/examples/coding_status.rs index 7f8a8dc..3e57e44 100644 --- a/examples/coding_status.rs +++ b/examples/coding_status.rs @@ -1,5 +1,6 @@ use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, Result}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, Result}; use std::time::Duration; /// Discord Rich Presence Coding Status Example diff --git a/examples/connection_retry.rs b/examples/connection_retry.rs index 25058ed..ec08f36 100644 --- a/examples/connection_retry.rs +++ b/examples/connection_retry.rs @@ -1,6 +1,7 @@ use clap::Parser; -use presenceforge::retry::{with_retry, RetryConfig}; -use presenceforge::{ActivityBuilder, DiscordIpcClient, Result}; +use presenceforge::retry::{RetryConfig, with_retry}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, Result}; use std::time::Duration; /// Discord Rich Presence Connection Retry Example diff --git a/examples/game_demo.rs b/examples/game_demo.rs index e25d091..c1dc759 100644 --- a/examples/game_demo.rs +++ b/examples/game_demo.rs @@ -1,8 +1,8 @@ use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, Result}; +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, Result}; use std::thread; use std::time::Duration; - /// Discord Rich Presence Game Demo Example #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] diff --git a/examples/pipe_selection.rs b/examples/pipe_selection.rs index f090086..0f74ce2 100644 --- a/examples/pipe_selection.rs +++ b/examples/pipe_selection.rs @@ -1,8 +1,8 @@ // Example demonstrating pipe selection and discovery features use clap::Parser; -use presenceforge::{ActivityBuilder, DiscordIpcClient, IpcConnection, PipeConfig}; - +use presenceforge::sync::DiscordIpcClient; +use presenceforge::{ActivityBuilder, IpcConnection, PipeConfig}; /// Discord IPC Pipe Selection Example #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] diff --git a/examples/update_activity_tokio.rs b/examples/update_activity_tokio.rs new file mode 100644 index 0000000..ebb2caf --- /dev/null +++ b/examples/update_activity_tokio.rs @@ -0,0 +1,176 @@ +use clap::Parser; +#[cfg(feature = "tokio-runtime")] +use presenceforge::{ActivityBuilder, AsyncDiscordIpcClient, Result}; +#[cfg(feature = "tokio-runtime")] +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Discord Rich Presence - Update Activity Without Resetting Timer +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Discord Application Client ID + #[arg(short, long)] + client_id: Option, +} +#[cfg(feature = "tokio-runtime")] +#[tokio::main] +async fn main() -> Result { + // Load .env file if it exists (optional) + let _ = dotenvy::dotenv(); + + let args = Args::parse(); + + let client_id = args + .client_id + .or_else(|| std::env::var("DISCORD_CLIENT_ID").ok()) + .unwrap_or_else(|| { + eprintln!("Error: DISCORD_CLIENT_ID is required!"); + eprintln!("Provide it via:"); + eprintln!(" - Command line: cargo run --example update_activity_tokio --features tokio-runtime -- --client-id YOUR_ID"); + eprintln!(" - Environment: DISCORD_CLIENT_ID=YOUR_ID cargo run --example update_activity_tokio --features tokio-runtime"); + eprintln!(" - .env file: Create .env from .env.example and set DISCORD_CLIENT_ID"); + std::process::exit(1); + }); + + let mut client = AsyncDiscordIpcClient::new(&client_id).await?; + + // Perform handshake + client.connect().await?; + println!("✓ Connected to Discord!"); + + // Get the initial timestamp - this will stay consistent across all updates + let start_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + println!("\n=== Demonstrating Activity Updates Without Timer Reset ===\n"); + println!("The timer will remain consistent throughout all state changes!\n"); + + // Initial activity - Main Menu + println!(" State 1: Main Menu"); + let activity = ActivityBuilder::new() + .state("Browsing menus") + .details("In main menu") + .start_timestamp(start_timestamp) + .large_image("game") + .large_text("Game Icon") + .small_image("idle") + .small_text("Idle") + .button(" Play Game", "https://example.com") + .build(); + + client.set_activity(&activity).await?; + println!("✓ Activity set: In main menu"); + println!(" Timer started at: {}", start_timestamp); + + // Wait 5 seconds + println!("\n Waiting 5 seconds...\n"); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + // Update to In Game - SAME TIMESTAMP + println!(" State 2: In Game"); + let activity = ActivityBuilder::new() + .state("Playing Solo") + .details("In game - Level 5") + .start_timestamp(start_timestamp) // SAME timestamp = timer continues! + .large_image("game") + .large_text("Game Icon") + .small_image("playing") + .small_text("Playing") + .button("🎮 Play Game", "https://example.com") + .build(); + + client.set_activity(&activity).await?; + println!("✓ Activity updated: In game"); + println!(" Timer continues from: {}", start_timestamp); + println!(" Notice: The elapsed time on Discord continues!"); + + // Wait 5 seconds + println!("\n Waiting 5 seconds...\n"); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + // Update to Multiplayer - SAME TIMESTAMP + println!(" State 3: Multiplayer"); + let activity = ActivityBuilder::new() + .state("In Multiplayer Match") + .details("Team Deathmatch - 2/8 players") + .start_timestamp(start_timestamp) // SAME timestamp = timer continues! + .large_image("game") + .large_text("Game Icon") + .small_image("multiplayer") + .small_text("Online") + .button(" Join Game", "https://example.com") + .build(); + + client.set_activity(&activity).await?; + println!("✓ Activity updated: Multiplayer"); + println!(" Timer continues from: {}", start_timestamp); + println!(" Notice: The elapsed time keeps going!"); + + // Wait 5 seconds + println!("\n Waiting 5 seconds...\n"); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + // Update to Loading - SAME TIMESTAMP + println!(" State 4: Loading"); + let activity = ActivityBuilder::new() + .state("Loading next map...") + .details("Please wait") + .start_timestamp(start_timestamp) // SAME timestamp = timer continues! + .large_image("game") + .large_text("Game Icon") + .small_image("loading") + .small_text("Loading") + .button("🎮 Play Game", "https://example.com") + .build(); + + client.set_activity(&activity).await?; + println!("✓ Activity updated: Loading"); + println!(" Timer continues from: {}", start_timestamp); + + // Wait 5 seconds + println!("\n Waiting 5 seconds...\n"); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + + // Back to Main Menu - SAME TIMESTAMP + println!(" State 5: Back to Main Menu"); + let activity = ActivityBuilder::new() + .state("Browsing menus") + .details("In main menu") + .start_timestamp(start_timestamp) // SAME timestamp = timer continues! + .large_image("game") + .large_text("Game Icon") + .small_image("idle") + .small_text("Idle") + .button(" Play Game", "https://example.com") + .build(); + + client.set_activity(&activity).await?; + println!("✓ Activity updated: Back to main menu"); + println!(" Timer continues from: {}", start_timestamp); + println!(" Total elapsed time: ~20 seconds (5s × 4 intervals)"); + + // Keep showing for a bit longer + println!("\n Keeping presence active for 10 more seconds..."); + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + + println!("\n=== Summary ==="); + println!("✓ Changed activity 5 times"); + println!("✓ Timer remained consistent throughout all changes"); + println!("✓ Total session time: ~30 seconds"); + println!("\nKey takeaway: By using the same start_timestamp across all"); + println!("activity updates, the elapsed time continues without resetting!"); + + // Clear the activity + println!("\n Clearing activity..."); + client.clear_activity().await?; + println!("✓ Activity cleared!"); + + // Connection is automatically closed when client is dropped + Ok(()) +} +#[cfg(not(feature = "tokio-runtime"))] +fn main() { + eprintln!("This example requires the `tokio-runtime` feature."); +} diff --git a/scripts/clippy_god_mode.sh b/scripts/clippy_god_mode.sh new file mode 100755 index 0000000..771f5aa --- /dev/null +++ b/scripts/clippy_god_mode.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +cd .. # assuming you are running from scripts directory +cargo clippy --all-targets --all-features -- -W clippy::pedantic -W clippy::nursery diff --git a/scripts/project_tree.sh b/scripts/project_tree.sh new file mode 100755 index 0000000..d43fbcc --- /dev/null +++ b/scripts/project_tree.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +cd .. # assuming you are running from scripts directory +tree -a -I '.git' --gitignore diff --git a/src/activity/types.rs b/src/activity/types.rs index 61af9a6..6be7dda 100644 --- a/src/activity/types.rs +++ b/src/activity/types.rs @@ -1,3 +1,5 @@ +#![allow(clippy::collapsible_if)] + use serde::{Deserialize, Serialize}; /// Rich Presence Activity @@ -100,13 +102,9 @@ impl Activity { } // Validate party size - if let Some(party) = &self.party { - if let Some(size) = &party.size { - if size[0] > size[1] { - return Err( - "Current party size cannot be greater than max party size".to_string() - ); - } + if let Some(size) = self.party.as_ref().and_then(|n| n.size) { + if size[0] > size[1] { + return Err("Current party size cannot be greater than max party size".to_string()); } } diff --git a/src/async_io/async_std/mod.rs b/src/async_io/async_std/mod.rs index 5479fab..3012ee4 100644 --- a/src/async_io/async_std/mod.rs +++ b/src/async_io/async_std/mod.rs @@ -21,7 +21,7 @@ use std::sync::{Arc, Mutex}; use crate::async_io::traits::{AsyncRead, AsyncWrite}; use crate::debug_println; use crate::error::{DiscordIpcError, Result}; -use crate::ipc::{constants, PipeConfig}; +use crate::ipc::{PipeConfig, constants}; /// A Discord IPC connection using async-std pub(crate) enum AsyncStdConnection { @@ -142,7 +142,7 @@ impl AsyncStdConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC socket. Check file permissions." + "Permission denied when connecting to Discord IPC socket. Check file permissions.", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -224,7 +224,7 @@ impl AsyncStdConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?" + "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -487,18 +487,13 @@ pub mod client { } impl AsyncStdClientExt for AsyncDiscordIpcClient { - fn connect_with_timeout( - &mut self, - timeout_duration: Duration, - ) -> impl std::future::Future> + Send { - async move { - match async_std::future::timeout(timeout_duration, self.connect()).await { - Ok(result) => result, - Err(_) => Err(DiscordIpcError::connection_timeout( - timeout_duration.as_millis() as u64, - None, - )), - } + async fn connect_with_timeout(&mut self, timeout_duration: Duration) -> Result { + match async_std::future::timeout(timeout_duration, self.connect()).await { + Ok(result) => result, + Err(_) => Err(DiscordIpcError::connection_timeout( + timeout_duration.as_millis() as u64, + None, + )), } } } diff --git a/src/async_io/client.rs b/src/async_io/client.rs index 402d155..0fd99bd 100644 --- a/src/async_io/client.rs +++ b/src/async_io/client.rs @@ -1,18 +1,20 @@ //! Async Discord IPC Client implementation +#![allow(clippy::collapsible_if)] + use bytes::{BufMut, BytesMut}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::VecDeque; use std::process; use std::time::{Duration, Instant}; use super::traits::ipc_utils::read_u32_le; -use super::traits::{read_exact, write_all, AsyncRead, AsyncWrite}; +use super::traits::{AsyncRead, AsyncWrite, read_exact, write_all}; use crate::activity::Activity; use crate::debug_println; use crate::error::{DiscordIpcError, Result}; -use crate::ipc::{constants, Command, HandshakePayload, IpcMessage, Opcode}; -use crate::utils::generate_nonce; +use crate::ipc::{Command, HandshakePayload, IpcMessage, Opcode, constants}; +use crate::nonce::generate_nonce; /// Async implementation of Discord IPC client pub struct AsyncDiscordIpcClient diff --git a/src/async_io/smol/mod.rs b/src/async_io/smol/mod.rs index a7c8e9c..0cbb9e7 100644 --- a/src/async_io/smol/mod.rs +++ b/src/async_io/smol/mod.rs @@ -19,7 +19,7 @@ use std::sync::{Arc, Mutex}; use crate::async_io::traits::{AsyncRead, AsyncWrite}; use crate::debug_println; use crate::error::{DiscordIpcError, Result}; -use crate::ipc::{constants, PipeConfig}; +use crate::ipc::{PipeConfig, constants}; /// A Discord IPC connection using smol pub(crate) enum SmolConnection { @@ -149,7 +149,7 @@ impl SmolConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC socket. Check file permissions." + "Permission denied when connecting to Discord IPC socket. Check file permissions.", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -232,7 +232,7 @@ impl SmolConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?" + "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -447,8 +447,8 @@ pub mod client { /// Performs handshake with Discord with a timeout pub async fn connect_with_timeout(&mut self, timeout_duration: Duration) -> Result { - use smol::future::or; use smol::Timer; + use smol::future::or; match or( async move { @@ -509,8 +509,8 @@ pub mod client { &mut self, timeout_duration: Duration, ) -> impl std::future::Future> + Send { - use smol::future::or; use smol::Timer; + use smol::future::or; async move { match or( diff --git a/src/async_io/tokio/mod.rs b/src/async_io/tokio/mod.rs index 82972af..e848fc6 100644 --- a/src/async_io/tokio/mod.rs +++ b/src/async_io/tokio/mod.rs @@ -14,7 +14,7 @@ use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; use crate::async_io::traits::{AsyncRead, AsyncWrite}; use crate::error::{DiscordIpcError, Result}; -use crate::ipc::{constants, PipeConfig}; +use crate::ipc::{PipeConfig, constants}; /// A Discord IPC connection using Tokio pub(crate) enum TokioConnection { @@ -46,7 +46,7 @@ impl TokioConnection { config: Option, timeout_ms: u64, ) -> Result { - use tokio::time::{timeout, Duration}; + use tokio::time::{Duration, timeout}; let timeout_duration = Duration::from_millis(timeout_ms); @@ -122,7 +122,7 @@ impl TokioConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC socket. Check file permissions." + "Permission denied when connecting to Discord IPC socket. Check file permissions.", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -174,7 +174,7 @@ impl TokioConnection { if err.kind() == io::ErrorKind::PermissionDenied { Err(DiscordIpcError::ConnectionFailed(io::Error::new( io::ErrorKind::PermissionDenied, - "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?" + "Permission denied when connecting to Discord IPC pipe. Is Discord running with the right permissions?", ))) } else { Err(DiscordIpcError::ConnectionFailed(err)) @@ -423,18 +423,13 @@ pub mod client { } impl TokioClientExt for AsyncDiscordIpcClient { - fn connect_with_timeout( - &mut self, - timeout_duration: Duration, - ) -> impl std::future::Future> + Send { - async move { - match timeout(timeout_duration, self.connect()).await { - Ok(result) => result, - Err(_) => Err(DiscordIpcError::connection_timeout( - timeout_duration.as_millis() as u64, - None, - )), - } + async fn connect_with_timeout(&mut self, timeout_duration: Duration) -> Result { + match timeout(timeout_duration, self.connect()).await { + Ok(result) => result, + Err(_) => Err(DiscordIpcError::connection_timeout( + timeout_duration.as_millis() as u64, + None, + )), } } } diff --git a/src/async_io/traits.rs b/src/async_io/traits.rs index d63db7a..57eed7b 100644 --- a/src/async_io/traits.rs +++ b/src/async_io/traits.rs @@ -41,7 +41,7 @@ pub async fn read_exact( return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "failed to fill buffer", - )) + )); } Ok(n) => buf = &mut buf[n..], Err(e) => return Err(e), @@ -91,7 +91,7 @@ pub async fn write_all( return Err(io::Error::new( io::ErrorKind::WriteZero, "failed to write whole buffer", - )) + )); } Ok(n) => buf = &buf[n..], Err(e) => return Err(e), diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 8f3be1b..0000000 --- a/src/client.rs +++ /dev/null @@ -1,439 +0,0 @@ -use serde_json::{json, Value}; -use std::collections::VecDeque; -use std::process; -use std::time::{Duration, Instant}; - -use crate::activity::Activity; -use crate::debug_println; -use crate::error::{DiscordIpcError, Result}; -use crate::ipc::{ - constants, Command, HandshakePayload, IpcConnection, IpcMessage, Opcode, PipeConfig, -}; -use crate::utils::generate_nonce; - -/// Discord IPC Client -pub struct DiscordIpcClient { - client_id: String, - connection: IpcConnection, - pending_messages: VecDeque, -} - -impl DiscordIpcClient { - /// Create a new Discord IPC client (uses auto-discovery) - pub fn new>(client_id: S) -> Result { - Self::new_with_config(client_id, None) - } - - /// Create a new Discord IPC client with pipe configuration - /// - /// # Arguments - /// - /// * `client_id` - The Discord application client ID - /// * `config` - Optional pipe configuration. If `None`, auto-discovery is used. - /// - /// # Examples - /// - /// ```no_run - /// use presenceforge::{DiscordIpcClient, PipeConfig}; - /// - /// // Auto-discovery (default) - /// let client = DiscordIpcClient::new_with_config("client_id", None)?; - /// - /// // Connect to custom path - /// let client = DiscordIpcClient::new_with_config( - /// "client_id", - /// Some(PipeConfig::CustomPath("/tmp/discord-ipc-0".to_string())) - /// )?; - /// # Ok::<(), presenceforge::DiscordIpcError>(()) - /// ``` - pub fn new_with_config>( - client_id: S, - config: Option, - ) -> Result { - let client_id = client_id.into(); - let connection = IpcConnection::new_with_config(config)?; - - Ok(Self { - client_id, - connection, - pending_messages: VecDeque::new(), - }) - } - - /// Create a new Discord IPC client with a connection timeout (uses auto-discovery) - /// - /// # Arguments - /// - /// * `client_id` - The Discord application client ID - /// * `timeout_ms` - Connection timeout in milliseconds - /// - /// # Returns - /// - /// A new Discord IPC client - /// - /// # Errors - /// - /// Returns a `DiscordIpcError::ConnectionTimeout` if the connection times out - pub fn new_with_timeout>(client_id: S, timeout_ms: u64) -> Result { - Self::new_with_config_and_timeout(client_id, None, timeout_ms) - } - - /// Create a new Discord IPC client with pipe configuration and timeout - /// - /// # Arguments - /// - /// * `client_id` - The Discord application client ID - /// * `config` - Optional pipe configuration. If `None`, auto-discovery is used. - /// * `timeout_ms` - Connection timeout in milliseconds - /// - /// # Examples - /// - /// ```no_run - /// use presenceforge::{DiscordIpcClient, PipeConfig}; - /// - /// // Auto-discovery with timeout - /// let client = DiscordIpcClient::new_with_config_and_timeout("client_id", None, 5000)?; - /// - /// // Custom pipe path with timeout - /// let client = DiscordIpcClient::new_with_config_and_timeout( - /// "client_id", - /// Some(PipeConfig::CustomPath("/tmp/discord-ipc-0".to_string())), - /// 5000 - /// )?; - /// # Ok::<(), presenceforge::DiscordIpcError>(()) - /// ``` - pub fn new_with_config_and_timeout>( - client_id: S, - config: Option, - timeout_ms: u64, - ) -> Result { - let client_id = client_id.into(); - let connection = IpcConnection::new_with_config_and_timeout(config, timeout_ms)?; - - Ok(Self { - client_id, - connection, - pending_messages: VecDeque::new(), - }) - } - - /// Perform handshake with Discord - /// - /// # Returns - /// - /// The Discord handshake response as a JSON Value - /// - /// # Errors - /// - /// Returns a `DiscordIpcError::HandshakeFailed` if the handshake fails - pub fn connect(&mut self) -> Result { - self.pending_messages.clear(); - - let handshake = HandshakePayload { - v: constants::IPC_VERSION, - client_id: self.client_id.clone(), - }; - - let payload = - serde_json::to_value(handshake).map_err(DiscordIpcError::SerializationFailed)?; - - self.connection.send(Opcode::Handshake, &payload)?; - - let (opcode, response) = self.connection.recv()?; - debug_println!("Handshake response: {}", response); - - // Check for error in the response - if let Some(err) = response.get("error") { - if let (Some(code), Some(message)) = ( - err.get("code").and_then(|c| c.as_i64()), - err.get("message").and_then(|m| m.as_str()), - ) { - return Err(DiscordIpcError::discord_error(code as i32, message)); - } else { - return Err(DiscordIpcError::HandshakeFailed(format!( - "Invalid error format: {}", - err - ))); - } - } - - // Verify opcode is correct for handshake response - if !opcode.is_handshake_response() { - return Err(DiscordIpcError::HandshakeFailed(format!( - "Expected handshake response opcode, got {:?}", - opcode - ))); - } - - Ok(response) - } - - /// Set Discord Rich Presence activity - /// - /// # Arguments - /// - /// * `activity` - The activity to set - /// - /// # Errors - /// - /// Returns a `DiscordIpcError` if serialization fails or if Discord returns an error - pub fn set_activity(&mut self, activity: &Activity) -> Result { - // Validate the activity first - if let Err(reason) = activity.validate() { - return Err(DiscordIpcError::InvalidActivity(reason)); - } - - // Generate a cryptographically secure unique nonce for this request - let nonce = generate_nonce("set-activity"); - - let message = IpcMessage { - cmd: Command::SetActivity, - args: json!({ - "pid": process::id(), - "activity": activity - }), - nonce: nonce.clone(), - }; - - let payload = serde_json::to_value(message)?; - self.connection.send(Opcode::Frame, &payload)?; - - // Receive the response to check for errors - let (opcode, response) = self.recv_for_nonce(&nonce)?; - - // Check if we got the correct response type - if !opcode.is_frame_response() { - return Err(DiscordIpcError::InvalidResponse(format!( - "Expected frame response, got {:?}", - opcode - ))); - } - - // Check for error in the response - if let Some(err) = response.get("error") { - if let (Some(code), Some(message)) = ( - err.get("code").and_then(|c| c.as_i64()), - err.get("message").and_then(|m| m.as_str()), - ) { - return Err(DiscordIpcError::discord_error(code as i32, message)); - } else { - return Err(DiscordIpcError::InvalidResponse(format!( - "Invalid error format in response: {}", - err - ))); - } - } - - // Verify nonce matches to ensure we got the right response - if let Some(resp_nonce) = response.get("nonce").and_then(|n| n.as_str()) { - if resp_nonce != nonce { - return Err(DiscordIpcError::InvalidResponse(format!( - "Nonce mismatch: expected {}, got {}", - nonce, resp_nonce - ))); - } - } - - Ok(()) - } - - /// Clear Discord Rich Presence activity - /// - /// # Returns - /// - /// The response from Discord as a JSON Value - /// - /// # Errors - /// - /// Returns a `DiscordIpcError` if communication fails or if Discord returns an error - pub fn clear_activity(&mut self) -> Result { - // Generate a cryptographically secure unique nonce - let nonce = generate_nonce("clear-activity"); - - let message = IpcMessage { - cmd: Command::SetActivity, - args: json!({ - "pid": process::id(), - "activity": Value::Null - }), - nonce: nonce.clone(), - }; - - let payload = serde_json::to_value(message)?; - self.connection.send(Opcode::Frame, &payload)?; - - let (opcode, response) = self.recv_for_nonce(&nonce)?; - debug_println!("Clear Activity response: {}", response); - - // Check if we got the correct response type - if !opcode.is_frame_response() { - return Err(DiscordIpcError::InvalidResponse(format!( - "Expected frame response, got {:?}", - opcode - ))); - } - - // Check for error in the response - if let Some(err) = response.get("error") { - if let (Some(code), Some(message)) = ( - err.get("code").and_then(|c| c.as_i64()), - err.get("message").and_then(|m| m.as_str()), - ) { - return Err(DiscordIpcError::discord_error(code as i32, message)); - } else { - return Err(DiscordIpcError::InvalidResponse(format!( - "Invalid error format in response: {}", - err - ))); - } - } - - // Verify nonce matches to ensure we got the right response - if let Some(resp_nonce) = response.get("nonce").and_then(|n| n.as_str()) { - if resp_nonce != nonce { - return Err(DiscordIpcError::InvalidResponse(format!( - "Nonce mismatch: expected {}, got {}", - nonce, resp_nonce - ))); - } - } - - Ok(response) - } - - /// Send a raw IPC message - pub fn send_message(&mut self, opcode: Opcode, payload: &Value) -> Result { - self.connection.send(opcode, payload) - } - - /// Receive a raw IPC message - pub fn recv_message(&mut self) -> Result<(Opcode, Value)> { - self.next_message() - } - - /// Remove pending responses older than the provided `max_age` and return how many were dropped. - pub fn cleanup_pending(&mut self, max_age: Duration) -> usize { - if max_age.is_zero() { - let dropped = self.pending_messages.len(); - self.pending_messages.clear(); - return dropped; - } - - let now = Instant::now(); - let original_len = self.pending_messages.len(); - self.pending_messages - .retain(|message| now.saturating_duration_since(message.received_at) <= max_age); - original_len - self.pending_messages.len() - } - - /// Close the connection - pub fn close(&mut self) { - self.connection.close(); - self.pending_messages.clear(); - } -} - -impl Drop for DiscordIpcClient { - fn drop(&mut self) { - self.close(); - } -} - -impl DiscordIpcClient { - fn next_message(&mut self) -> Result<(Opcode, Value)> { - if let Some(message) = self.pending_messages.pop_front() { - let PendingMessage { - opcode, payload, .. - } = message; - return Ok((opcode, payload)); - } - - self.connection.recv() - } - - fn recv_for_nonce(&mut self, expected_nonce: &str) -> Result<(Opcode, Value)> { - if let Some(message) = self.take_pending_by_nonce(expected_nonce) { - return Ok(message); - } - - loop { - let (opcode, response) = self.connection.recv()?; - if Self::value_has_nonce(&response, expected_nonce) { - return Ok((opcode, response)); - } - - self.pending_messages - .push_back(PendingMessage::new(opcode, response)); - } - } - - fn take_pending_by_nonce(&mut self, expected_nonce: &str) -> Option<(Opcode, Value)> { - let position = self - .pending_messages - .iter() - .position(|message| Self::value_has_nonce(&message.payload, expected_nonce)); - - position.and_then(|index| { - self.pending_messages.remove(index).map(|message| { - let PendingMessage { - opcode, payload, .. - } = message; - (opcode, payload) - }) - }) - } - - fn value_has_nonce(value: &Value, expected_nonce: &str) -> bool { - value - .get("nonce") - .and_then(|n| n.as_str()) - .map(|actual| actual == expected_nonce) - .unwrap_or(false) - } -} - -#[derive(Debug)] -struct PendingMessage { - opcode: Opcode, - payload: Value, - received_at: Instant, -} - -impl PendingMessage { - fn new(opcode: Opcode, payload: Value) -> Self { - Self { - opcode, - payload, - received_at: Instant::now(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn value_has_nonce_detects_match() { - let payload = serde_json::json!({ - "nonce": "abc", - "data": {} - }); - - assert!(DiscordIpcClient::value_has_nonce(&payload, "abc")); - assert!(!DiscordIpcClient::value_has_nonce(&payload, "def")); - } - - #[test] - fn value_has_nonce_handles_missing_field() { - let payload = serde_json::json!({ "data": 1 }); - assert!(!DiscordIpcClient::value_has_nonce(&payload, "anything")); - } - - #[test] - fn pending_message_records_creation_time() { - let message = PendingMessage::new(Opcode::Frame, serde_json::json!({"nonce": "1"})); - let elapsed = Instant::now().saturating_duration_since(message.received_at); - assert!(elapsed.as_secs() < 1); - } -} diff --git a/src/error.rs b/src/error.rs index a89ac85..dc86180 100644 --- a/src/error.rs +++ b/src/error.rs @@ -77,8 +77,8 @@ impl Display for ErrorCategory { /// /// Basic error handling: /// ```rust -/// use presenceforge::{DiscordIpcClient, DiscordIpcError}; -/// +/// use presenceforge::DiscordIpcError; +/// use presenceforge::sync::DiscordIpcClient; /// fn main() -> Result<(), Box> { /// let mut client = match DiscordIpcClient::new("your-client-id") { /// Ok(client) => client, @@ -97,7 +97,8 @@ impl Display for ErrorCategory { /// /// Using utility functions for recoverable errors: /// ```rust -/// use presenceforge::{DiscordIpcClient, DiscordIpcError, Activity}; +/// use presenceforge::{DiscordIpcError, Activity}; +/// use presenceforge::sync::DiscordIpcClient; /// use std::time::Duration; /// /// fn connect_with_retry(client_id: &str, max_attempts: u32) -> Result { diff --git a/src/ipc/connection.rs b/src/ipc/connection.rs index 21639d6..1eb7eb9 100644 --- a/src/ipc/connection.rs +++ b/src/ipc/connection.rs @@ -12,7 +12,7 @@ use std::fs::OpenOptions; use std::io::{BufReader, BufWriter}; use crate::error::{DiscordIpcError, ProtocolContext, Result}; -use crate::ipc::protocol::{constants, Opcode}; +use crate::ipc::protocol::{Opcode, constants}; /// Configuration for selecting which Discord IPC pipe to connect to #[derive(Debug, Clone, Default)] @@ -84,35 +84,53 @@ impl IpcConnection { } } + // Returns the current users UID on unix based systems #[cfg(unix)] - fn discover_pipes_unix() -> Vec { - let mut pipes = Vec::new(); - - // Try environment variables in order of preference - let env_keys = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]; + /// Returns the current user's UID on Unix-based systems. + /// + /// Safety: Calling `libc::getuid()` is always safe per POSIX. + fn current_uid() -> u32 { + unsafe { libc::getuid() } + } + /// Discovers potential base directories where IPC sockets may exist + /// Check environment variables + /// - `XDG_RUNTIME_DIR` + /// - `TMPDIR` + /// - `TMP` + /// - `TEMP` + /// - `tmp` + /// - `XDG_RUNTIME_DIR/app/com.discordapp.Discord` (Flatpak specific) + /// - `/run/user/{UID}` (if `XDG_RUNTIME_DIR` is not set) + /// - `/run/user/{UID}/app/com.discordapp.Discord` (Flatpak fallback) + #[cfg(unix)] + fn candidate_ipc_dir() -> Vec { + let env_keys = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP", "tmp"]; let mut directories = Vec::new(); - - for env_key in &env_keys { - if let Ok(dir) = std::env::var(env_key) { + for key in &env_keys { + if let Ok(dir) = std::env::var(key) { directories.push(dir.clone()); // Also check Flatpak Discord path if XDG_RUNTIME_DIR is set - if env_key == &"XDG_RUNTIME_DIR" { + if key == &"XDG_RUNTIME_DIR" { directories.push(format!("{}/app/com.discordapp.Discord", dir)); } } } - - // Fallback to /run/user/{uid} if no env vars found if directories.is_empty() { - let uid = unsafe { libc::getuid() }; + let uid = Self::current_uid(); directories.push(format!("/run/user/{}", uid)); // Also try Flatpak path as fallback directories.push(format!("/run/user/{}/app/com.discordapp.Discord", uid)); } + directories + } + #[cfg(unix)] + fn discover_pipes_unix() -> Vec { + let mut pipes = Vec::new(); + // Try each directory with each socket number - for dir in &directories { + for dir in Self::candidate_ipc_dir() { for i in 0..constants::MAX_IPC_SOCKETS { let socket_path = format!("{}/{}{}", dir, constants::IPC_SOCKET_PREFIX, i); @@ -284,34 +302,11 @@ impl IpcConnection { #[cfg(unix)] /// Connect to Discord IPC socket using auto-discovery fn connect_to_discord_unix_auto() -> Result { - // Try environment variables in order of preference - let env_keys = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]; - let mut directories = Vec::new(); - - for env_key in &env_keys { - if let Ok(dir) = std::env::var(env_key) { - directories.push(dir.clone()); - - // Also check Flatpak Discord path if XDG_RUNTIME_DIR is set - if env_key == &"XDG_RUNTIME_DIR" { - directories.push(format!("{}/app/com.discordapp.Discord", dir)); - } - } - } - - // Fallback to /run/user/{uid} if no env vars found - if directories.is_empty() { - let uid = unsafe { libc::getuid() }; - directories.push(format!("/run/user/{}", uid)); - // Also try Flatpak path as fallback - directories.push(format!("/run/user/{}/app/com.discordapp.Discord", uid)); - } - // Try each directory with each socket number let mut last_error = None; let mut attempted_paths = Vec::new(); - for dir in &directories { + for dir in Self::candidate_ipc_dir() { for i in 0..constants::MAX_IPC_SOCKETS { let socket_path = format!("{}/{}{}", dir, constants::IPC_SOCKET_PREFIX, i); attempted_paths.push(socket_path.clone()); @@ -373,8 +368,8 @@ impl IpcConnection { #[cfg(windows)] /// Connect to Discord IPC named pipe on Windows using auto-discovery - fn connect_to_discord_windows_auto( - ) -> Result<(BufReader, BufWriter)> { + fn connect_to_discord_windows_auto() + -> Result<(BufReader, BufWriter)> { let mut last_error = None; let mut attempted_paths = Vec::new(); @@ -419,7 +414,6 @@ impl IpcConnection { /// Send data with opcode pub fn send(&mut self, opcode: Opcode, payload: &Value) -> Result<()> { let raw = serde_json::to_vec(payload)?; - // Clear and prepare write buffer self.write_buf.clear(); self.write_buf.reserve(8 + raw.len()); diff --git a/src/lib.rs b/src/lib.rs index 25b64be..862b118 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,9 +15,9 @@ //! //! ## Synchronous Example //! -//! ```rust -//! use presenceforge::{DiscordIpcClient, ActivityBuilder}; -//! +//! ```rust no_run +//! use presenceforge::ActivityBuilder; +//! use presenceforge::sync::DiscordIpcClient; //! # fn main() -> Result<(), Box> { //! let mut client = DiscordIpcClient::new("your_client_id")?; //! client.connect()?; @@ -52,7 +52,7 @@ //! //! ```toml //! [dependencies] -//! presenceforge = { version = "0.0.0", features = ["tokio-runtime"] } +//! presenceforge = { version = "0.1.0-dev", features = ["tokio-runtime"] } //! tokio = { version = "1", features = ["rt-multi-thread", "macros"] } //! ``` //! @@ -87,7 +87,7 @@ //! //! ```toml //! [dependencies] -//! presenceforge = { version = "0.0.0", features = ["async-std-runtime"] } +//! presenceforge = { version = "0.1.0-dev", features = ["async-std-runtime"] } //! async-std = { version = "1", features = ["attributes"] } //! ``` //! @@ -122,7 +122,7 @@ //! //! ```toml //! [dependencies] -//! presenceforge = { version = "0.0.0", features = ["smol-runtime"] } +//! presenceforge = { version = "0.1.0-dev", features = ["smol-runtime"] } //! smol = "2" //! ``` //! @@ -173,13 +173,11 @@ pub mod activity; pub mod async_io; -pub mod client; pub mod error; pub mod ipc; pub mod macros; +pub mod nonce; pub mod retry; -pub mod utils; - // Re-export the main public API #[cfg(feature = "secrets")] pub use activity::ActivitySecrets; diff --git a/src/macros.rs b/src/macros.rs index 470b17e..5a6fd1f 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,12 +1,16 @@ /// Global debug flag to control debug output /// Set to `true` to enable debug prints or use PRESENCEFORGE_DEBUG=1 environment variable -#[doc(hidden)] +use std::sync::OnceLock; + +static DEBUG_ENABLED: OnceLock = OnceLock::new(); + pub fn is_debug_enabled() -> bool { - std::env::var("PRESENCEFORGE_DEBUG") - .map(|val| val == "1") - .unwrap_or(false) + *DEBUG_ENABLED.get_or_init(|| { + std::env::var("PRESENCEFORGE_DEBUG") + .map(|val| val == "1" || val.eq_ignore_ascii_case("true")) + .unwrap_or(false) + }) } - /// Macro for conditional debug printing #[macro_export] macro_rules! debug_println { diff --git a/src/utils.rs b/src/nonce.rs similarity index 86% rename from src/utils.rs rename to src/nonce.rs index d76fc7c..aeed27c 100644 --- a/src/utils.rs +++ b/src/nonce.rs @@ -17,7 +17,7 @@ use uuid::Uuid; /// # Examples /// /// ``` -/// # use presenceforge::utils::generate_nonce; +/// # use presenceforge::nonce::generate_nonce; /// let nonce = generate_nonce("set-activity"); /// assert!(nonce.starts_with("set-activity-")); /// ``` @@ -25,8 +25,7 @@ use uuid::Uuid; /// # Security /// /// UUID v4 provides 122 bits of randomness, making collisions extremely unlikely -/// (probability of collision is approximately 1 in 2^61 after generating 1 billion UUIDs). -/// This is far superior to timestamp-based nonces which can collide during rapid operations. +/// probability of collision is approximately 1 in 2^61 after generating 1 billion UUIDs. pub fn generate_nonce(prefix: &str) -> String { format!("{}-{}", prefix, Uuid::new_v4()) } diff --git a/src/retry.rs b/src/retry.rs index 5baf52c..9ded736 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -79,8 +79,8 @@ impl RetryConfig { /// # Example /// /// ```no_run -/// use presenceforge::{DiscordIpcClient, retry::{with_retry, RetryConfig}}; -/// +/// use presenceforge::retry::{with_retry, RetryConfig}; +/// use presenceforge::sync::DiscordIpcClient; /// let config = RetryConfig::with_max_attempts(5); /// /// let client = with_retry(&config, || { @@ -114,9 +114,9 @@ where })) } -/// Retry an async operation with exponential backoff +/// Retry an async operation with exponential backoff (Tokio runtime) /// -/// This is the async version of `with_retry`. +/// This function uses `tokio::time::sleep` for delays between retry attempts. /// /// # Arguments /// @@ -127,20 +127,25 @@ where /// /// ```no_run /// use presenceforge::async_io::tokio::TokioDiscordIpcClient; -/// use presenceforge::retry::{with_retry_async, RetryConfig}; +/// use presenceforge::retry::{with_retry_async_tokio, RetryConfig}; /// /// # #[tokio::main] /// # async fn main() -> Result<(), presenceforge::DiscordIpcError> { /// let config = RetryConfig::with_max_attempts(5); /// -/// let mut client = with_retry_async(&config, || { +/// let mut client = with_retry_async_tokio(&config, || { /// Box::pin(async { TokioDiscordIpcClient::new("your-client-id").await }) /// }).await?; /// # Ok(()) /// # } /// ``` +/// +/// # Note +/// +/// When the `tokio-runtime` feature is enabled, this function is also exported as +/// [`with_retry_async`] for convenience (with priority over other runtimes). #[cfg(feature = "tokio-runtime")] -pub async fn with_retry_async(config: &RetryConfig, mut operation: F) -> Result +pub async fn with_retry_async_tokio(config: &RetryConfig, mut operation: F) -> Result where F: FnMut() -> Fut, Fut: std::future::Future>, @@ -162,14 +167,39 @@ where } Err(last_error.unwrap_or_else(|| { - DiscordIpcError::ConnectionFailed(std::io::Error::new( - std::io::ErrorKind::Other, - "Retry attempts exhausted", - )) + DiscordIpcError::ConnectionFailed(std::io::Error::other("Retry attempts exhausted")) })) } -/// Retry an async operation with exponential backoff (async-std version) +/// Retry an async operation with exponential backoff (async-std runtime) +/// +/// This function uses `async_std::task::sleep` for delays between retry attempts. +/// +/// # Arguments +/// +/// * `config` - Retry configuration +/// * `operation` - The async operation to retry +/// +/// # Example +/// +/// ```no_run +/// use presenceforge::async_io::async_std::AsyncStdDiscordIpcClient; +/// use presenceforge::retry::{with_retry_async_std, RetryConfig}; +/// +/// # async_std::task::block_on(async { +/// let config = RetryConfig::with_max_attempts(5); +/// +/// let mut client = with_retry_async_std(&config, || { +/// Box::pin(async { AsyncStdDiscordIpcClient::new("your-client-id").await }) +/// }).await?; +/// # Ok::<(), presenceforge::DiscordIpcError>(()) +/// # }); +/// ``` +/// +/// # Note +/// +/// When the `async-std-runtime` feature is enabled (and `tokio-runtime` is not), +/// this function is also exported as [`with_retry_async`] for convenience. #[cfg(feature = "async-std-runtime")] pub async fn with_retry_async_std(config: &RetryConfig, mut operation: F) -> Result where @@ -193,14 +223,40 @@ where } Err(last_error.unwrap_or_else(|| { - DiscordIpcError::ConnectionFailed(std::io::Error::new( - std::io::ErrorKind::Other, - "Retry attempts exhausted", - )) + DiscordIpcError::ConnectionFailed(std::io::Error::other("Retry attempts exhausted")) })) } -/// Retry an async operation with exponential backoff (smol version) +/// Retry an async operation with exponential backoff (smol runtime) +/// +/// This function uses `smol::Timer::after` for delays between retry attempts. +/// +/// # Arguments +/// +/// * `config` - Retry configuration +/// * `operation` - The async operation to retry +/// +/// # Example +/// +/// ```no_run +/// use presenceforge::async_io::smol::SmolDiscordIpcClient; +/// use presenceforge::retry::{with_retry_async_smol, RetryConfig}; +/// +/// # smol::block_on(async { +/// let config = RetryConfig::with_max_attempts(5); +/// +/// let mut client = with_retry_async_smol(&config, || { +/// Box::pin(async { SmolDiscordIpcClient::new("your-client-id").await }) +/// }).await?; +/// # Ok::<(), presenceforge::DiscordIpcError>(()) +/// # }); +/// ``` +/// +/// # Note +/// +/// When the `smol-runtime` feature is enabled (and neither `tokio-runtime` nor +/// `async-std-runtime` is enabled), this function is also exported as +/// [`with_retry_async`] for convenience. #[cfg(feature = "smol-runtime")] pub async fn with_retry_async_smol(config: &RetryConfig, mut operation: F) -> Result where @@ -224,12 +280,182 @@ where } Err(last_error.unwrap_or_else(|| { - DiscordIpcError::ConnectionFailed(std::io::Error::new( - std::io::ErrorKind::Other, - "Retry attempts exhausted", - )) + DiscordIpcError::ConnectionFailed(std::io::Error::other("Retry attempts exhausted")) })) } +// Unified async retry API +// +// The `with_retry_async` function is an alias to the appropriate runtime-specific +// retry function based on which feature is enabled: +// - tokio-runtime -> with_retry_async_tokio +// - async-std-runtime -> with_retry_async_std (if tokio not enabled) +// - smol-runtime -> with_retry_async_smol (if others not enabled) + +/// Retry an async operation with exponential backoff (unified API) +/// +/// This is a convenience alias that automatically uses the correct retry implementation +/// based on your enabled async runtime feature. It provides a unified API regardless of +/// which runtime you're using. +/// +/// # Runtime Selection +/// +/// - **tokio-runtime**: Uses [`with_retry_async_tokio`] (priority if multiple features enabled) +/// - **async-std-runtime**: Uses [`with_retry_async_std`] (if tokio not enabled) +/// - **smol-runtime**: Uses [`with_retry_async_smol`] (if others not enabled) +/// +/// # Arguments +/// +/// * `config` - Retry configuration +/// * `operation` - The async operation to retry +/// +/// # Examples +/// +/// With Tokio: +/// ```no_run +/// use presenceforge::async_io::tokio::TokioDiscordIpcClient; +/// use presenceforge::retry::{with_retry_async, RetryConfig}; +/// +/// # #[tokio::main] +/// # async fn main() -> Result<(), presenceforge::DiscordIpcError> { +/// let config = RetryConfig::with_max_attempts(5); +/// let client = with_retry_async(&config, || { +/// Box::pin(async { TokioDiscordIpcClient::new("your-client-id").await }) +/// }).await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// With async-std: +/// ```no_run +/// use presenceforge::async_io::async_std::AsyncStdDiscordIpcClient; +/// use presenceforge::retry::{with_retry_async, RetryConfig}; +/// +/// # async_std::task::block_on(async { +/// let config = RetryConfig::with_max_attempts(5); +/// let client = with_retry_async(&config, || { +/// Box::pin(async { AsyncStdDiscordIpcClient::new("your-client-id").await }) +/// }).await?; +/// # Ok::<(), presenceforge::DiscordIpcError>(()) +/// # }); +/// ``` +#[cfg(all( + feature = "tokio-runtime", + not(all(feature = "async-std-runtime", not(feature = "tokio-runtime"))), + not(all(feature = "smol-runtime", not(feature = "tokio-runtime"))) +))] +pub use with_retry_async_tokio as with_retry_async; + +/// Retry an async operation with exponential backoff (unified API) +/// +/// This is a convenience alias to [`with_retry_async_std`] when using the async-std runtime. +/// See the [`with_retry_async`](with_retry_async_tokio) documentation for more details. +#[cfg(all(feature = "async-std-runtime", not(feature = "tokio-runtime")))] +pub use with_retry_async_std as with_retry_async; + +/// Retry an async operation with exponential backoff (unified API) +/// +/// This is a convenience alias to [`with_retry_async_smol`] when using the smol runtime. +/// See the [`with_retry_async`](with_retry_async_tokio) documentation for more details. +#[cfg(all( + feature = "smol-runtime", + not(feature = "tokio-runtime"), + not(feature = "async-std-runtime") +))] +pub use with_retry_async_smol as with_retry_async; + #[cfg(test)] -mod tests; +#[test] +fn test_retry_config_creation() { + let config = RetryConfig::default(); + assert_eq!(config.max_attempts, 3); + assert_eq!(config.initial_delay_ms, 1000); + + let custom = RetryConfig::with_max_attempts(5); + assert_eq!(custom.max_attempts, 5); +} + +#[test] +fn test_retry_config_delay_calculation() { + let config = RetryConfig::new(5, 1000, 10000, 2.0); + + // Test exponential backoff + let delay0 = config.delay_for_attempt(0); + let delay1 = config.delay_for_attempt(1); + let delay2 = config.delay_for_attempt(2); + + assert_eq!(delay0.as_millis(), 1000); + assert_eq!(delay1.as_millis(), 2000); + assert_eq!(delay2.as_millis(), 4000); +} + +#[test] +fn test_retry_config_max_delay() { + let config = RetryConfig::new(10, 1000, 5000, 2.0); + + // Delay should cap at max_delay_ms + let delay10 = config.delay_for_attempt(10); + assert_eq!(delay10.as_millis(), 5000); +} + +#[test] +fn test_retry_exhausts_attempts() { + let config = RetryConfig::with_max_attempts(3); + + let mut attempt_count = 0; + let result: std::result::Result<(), DiscordIpcError> = with_retry(&config, || { + attempt_count += 1; + // SocketClosed is recoverable + Err(DiscordIpcError::SocketClosed) + }); + + assert!(result.is_err()); + assert_eq!(attempt_count, 3); +} + +#[test] +fn test_non_recoverable_error_no_retry() { + let config = RetryConfig::with_max_attempts(3); + + let mut attempt_count = 0; + let result: std::result::Result<(), DiscordIpcError> = with_retry(&config, || { + attempt_count += 1; + // ConnectionFailed is NOT recoverable + Err(DiscordIpcError::ConnectionFailed(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "test", + ))) + }); + + assert!(result.is_err()); + assert_eq!(attempt_count, 1); // Should NOT retry +} + +#[test] +fn test_retry_succeeds_on_first_attempt() { + let config = RetryConfig::with_max_attempts(3); + + let mut attempt_count = 0; + let result = with_retry(&config, || { + attempt_count += 1; + Ok::<_, DiscordIpcError>(42) + }); + + assert_eq!(result.unwrap(), 42); + assert_eq!(attempt_count, 1); +} + +#[test] +fn test_retry_stops_on_non_recoverable_error() { + let config = RetryConfig::with_max_attempts(5); + + let mut attempt_count = 0; + let result: std::result::Result<(), DiscordIpcError> = with_retry(&config, || { + attempt_count += 1; + // InvalidActivity is not recoverable + Err(DiscordIpcError::InvalidActivity("test".to_string())) + }); + + assert!(result.is_err()); + assert_eq!(attempt_count, 1); // Should fail immediately +} diff --git a/src/retry/tests.rs b/src/retry/tests.rs deleted file mode 100644 index e7f1c00..0000000 --- a/src/retry/tests.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crate::error::DiscordIpcError; -/// Smoke test for connection retry functionality -/// This test verifies that the retry module compiles and the API works correctly -use crate::retry::{with_retry, RetryConfig}; - -#[test] -fn test_retry_config_creation() { - let config = RetryConfig::default(); - assert_eq!(config.max_attempts, 3); - assert_eq!(config.initial_delay_ms, 1000); - - let custom = RetryConfig::with_max_attempts(5); - assert_eq!(custom.max_attempts, 5); -} - -#[test] -fn test_retry_config_delay_calculation() { - let config = RetryConfig::new(5, 1000, 10000, 2.0); - - // Test exponential backoff - let delay0 = config.delay_for_attempt(0); - let delay1 = config.delay_for_attempt(1); - let delay2 = config.delay_for_attempt(2); - - assert_eq!(delay0.as_millis(), 1000); - assert_eq!(delay1.as_millis(), 2000); - assert_eq!(delay2.as_millis(), 4000); -} - -#[test] -fn test_retry_config_max_delay() { - let config = RetryConfig::new(10, 1000, 5000, 2.0); - - // Delay should cap at max_delay_ms - let delay10 = config.delay_for_attempt(10); - assert_eq!(delay10.as_millis(), 5000); -} - -#[test] -fn test_retry_exhausts_attempts() { - let config = RetryConfig::with_max_attempts(3); - - let mut attempt_count = 0; - let result: Result<(), DiscordIpcError> = with_retry(&config, || { - attempt_count += 1; - // SocketClosed is recoverable - Err(DiscordIpcError::SocketClosed) - }); - - assert!(result.is_err()); - assert_eq!(attempt_count, 3); -} - -#[test] -fn test_non_recoverable_error_no_retry() { - let config = RetryConfig::with_max_attempts(3); - - let mut attempt_count = 0; - let result: Result<(), DiscordIpcError> = with_retry(&config, || { - attempt_count += 1; - // ConnectionFailed is NOT recoverable - Err(DiscordIpcError::ConnectionFailed(std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - "test", - ))) - }); - - assert!(result.is_err()); - assert_eq!(attempt_count, 1); // Should NOT retry -} - -#[test] -fn test_retry_succeeds_on_first_attempt() { - let config = RetryConfig::with_max_attempts(3); - - let mut attempt_count = 0; - let result = with_retry(&config, || { - attempt_count += 1; - Ok::<_, DiscordIpcError>(42) - }); - - assert_eq!(result.unwrap(), 42); - assert_eq!(attempt_count, 1); -} - -#[test] -fn test_retry_stops_on_non_recoverable_error() { - let config = RetryConfig::with_max_attempts(5); - - let mut attempt_count = 0; - let result: Result<(), DiscordIpcError> = with_retry(&config, || { - attempt_count += 1; - // InvalidActivity is not recoverable - Err(DiscordIpcError::InvalidActivity("test".to_string())) - }); - - assert!(result.is_err()); - assert_eq!(attempt_count, 1); // Should fail immediately -} diff --git a/src/sync/client.rs b/src/sync/client.rs index f995ac4..dbcc8fb 100644 --- a/src/sync/client.rs +++ b/src/sync/client.rs @@ -1,4 +1,6 @@ -use serde_json::{json, Value}; +#![allow(clippy::collapsible_if)] + +use serde_json::{Value, json}; use std::collections::VecDeque; use std::process; use std::time::{Duration, Instant}; @@ -7,9 +9,9 @@ use crate::activity::Activity; use crate::debug_println; use crate::error::{DiscordIpcError, Result}; use crate::ipc::{ - constants, Command, HandshakePayload, IpcConnection, IpcMessage, Opcode, PipeConfig, + Command, HandshakePayload, IpcConnection, IpcMessage, Opcode, PipeConfig, constants, }; -use crate::utils::generate_nonce; +use crate::nonce::generate_nonce; /// Discord IPC Client pub struct DiscordIpcClient { @@ -36,8 +38,8 @@ impl DiscordIpcClient { /// # Examples /// /// ```no_run - /// use presenceforge::{DiscordIpcClient, PipeConfig}; - /// + /// use presenceforge::PipeConfig; + /// use presenceforge::sync::DiscordIpcClient; /// // Auto-discovery (default) /// let client = DiscordIpcClient::new_with_config("client_id", None)?; /// @@ -97,8 +99,8 @@ impl DiscordIpcClient { /// # Examples /// /// ```no_run - /// use presenceforge::{DiscordIpcClient, PipeConfig}; - /// + /// use presenceforge::PipeConfig; + /// use presenceforge::sync::DiscordIpcClient; /// // Auto-discovery with timeout /// let client = DiscordIpcClient::new_with_config_and_timeout("client_id", None, 5000)?; /// @@ -206,6 +208,9 @@ impl DiscordIpcClient { }; let payload = serde_json::to_value(message)?; + #[cfg(debug_assertions)] + // Intentional: Print payload for debugging in debug builds only. + debug_println!("[PAYLOAD]: {:?} ", payload); self.connection.send(Opcode::Frame, &payload)?; // Receive the response to check for errors @@ -359,7 +364,7 @@ impl DiscordIpcClient { /// # Examples /// /// ```no_run - /// use presenceforge::DiscordIpcClient; + /// use presenceforge::sync::DiscordIpcClient; /// use presenceforge::ActivityBuilder; /// /// let mut client = DiscordIpcClient::new("client_id")?; diff --git a/tests/integration_retry.rs b/tests/integration_retry.rs index 5bc70e8..fa0cd44 100644 --- a/tests/integration_retry.rs +++ b/tests/integration_retry.rs @@ -1,7 +1,7 @@ use presenceforge::retry::with_retry; -use presenceforge::{retry::RetryConfig, DiscordIpcError}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use presenceforge::{DiscordIpcError, retry::RetryConfig}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; fn quick_retry_config(max_attempts: u32) -> RetryConfig { RetryConfig::new(max_attempts, 5, 20, 2.0)