discordipc 0.1.1

A Rust crate that enables connection and interaction with Discord's IPC, allowing you to set custom activities for your project.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0

use crate::{
    packet::{Opcode, Packet},
    Error, Result,
};
use serde_json::Value;
use std::io::{Read, Write};

#[cfg(windows)]
use std::fs::{File, OpenOptions};
#[cfg(unix)]
use std::{net::Shutdown, os::unix::net::UnixStream};
#[cfg(windows)]
pub struct Pipe(pub Option<File>);
#[cfg(unix)]
pub struct Pipe(pub Option<UnixStream>);

impl Pipe {
    /// Attempts to open a Discord's IPC pipe.
    ///
    /// The implementation is different depending on the platform and the installation method
    ///
    /// ## Errors
    /// - **NoAvailablePipes**: No valid IPC pipe or socket was found.
    pub fn open(&mut self) -> Result<()> {
        #[cfg(windows)]
        {
            self.0 = (0..10).find_map(|i| {
                let pipe = format!(r"\\.\pipe\discord-ipc-{}", i);
                OpenOptions::new().read(true).write(true).open(&pipe).ok()
            });
        }

        #[cfg(unix)]
        {
            let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
                .or(std::env::var_os("TMPDIR"))
                .map(std::path::PathBuf::from)
                .unwrap_or(std::env::temp_dir());

            // IPC path depends on the installation method
            let possible_dirs = [
                runtime_dir.clone(),
                runtime_dir.join("app").join("com.discordapp.Discord"),
                runtime_dir.join("snap.discord"),
            ];

            self.0 = (0..10).find_map(|i| {
                possible_dirs.iter().find_map(|dir| {
                    let socket_path = dir.join(format!("discord-ipc-{}", i));
                    UnixStream::connect(socket_path).ok().and_then(|stream| {
                        stream.set_nonblocking(true).ok()?;
                        Some(stream)
                    })
                })
            });
        }

        self.0.as_ref().ok_or(Error::NoAvailablePipes)?;

        Ok(())
    }

    /// Closes the Discord IPC pipe.
    ///
    /// ## Errors
    /// - **NoPipe**: No active IPC pipe exists.
    /// - **IoError**: An I/O error occurred while flushing or shutting down the pipe.
    pub fn close(&mut self) -> Result<()> {
        let mut pipe = self.0.take().ok_or(Error::NoPipe)?;

        pipe.flush()?;
        #[cfg(unix)]
        pipe.shutdown(Shutdown::Both)?;

        Ok(())
    }

    /// Sends a packet to the active IPC pipe.
    ///
    /// ## Errors
    /// - **NoPipe**: No active IPC pipe exists.
    /// - **IoError**: An I/O error occurred while writing to the pipe.
    pub fn send(&mut self, packet: Packet) -> Result<()> {
        let pipe = self.0.as_mut().ok_or(Error::NoPipe)?;

        let payload = packet.payload.to_string();
        let payload_len = payload.len() as u32;

        let opcode = packet.opcode as u32;

        let mut header = [0u8; 8];
        header[..4].copy_from_slice(&opcode.to_le_bytes());
        header[4..8].copy_from_slice(&payload_len.to_le_bytes());

        pipe.write_all(&header)?;
        pipe.write_all(payload.as_bytes())?;
        pipe.flush()?;

        Ok(())
    }

    /// Receives and decodes a packet from the pipe.
    ///
    /// ## Errors
    /// - **NoPipe**: No active IPC pipe exists.
    /// - **IoError**: An I/O error occurred while reading from the pipe.
    ///     - **WouldBlock**: No data available to read at the moment.
    /// - **DecodeError**: The response is malformed, incomplete, or cannot be decoded.
    pub fn try_receive(&mut self) -> Result<Packet> {
        let mut header = [0u8; 8];
        self.try_read_exact(&mut header)?;

        let (opcode, payload_len) = Packet::decode_header(&header)?;

        let mut payload = vec![0u8; payload_len as usize];
        self.try_read_exact(&mut payload)?;

        let payload_str = String::from_utf8(payload)
            .map_err(|e| Error::DecodeError(format!("Invalid UTF-8 received: {}", e)))?;

        let payload = serde_json::from_str::<Value>(&payload_str)
            .map_err(|e| Error::DecodeError(format!("Invalid JSON received: {}", e)))?;

        Ok(Packet::new(Opcode::try_from(opcode)?, payload))
    }

    /// Attempts to read exactly the specified number of bytes from the IPC pipe.
    ///
    /// This function reads data from the pipe into the provided buffer until the entire buffer is filled.
    /// If there is not enough data or if the pipe is closed, it will return an error.
    ///
    /// ## Errors
    /// This function returns an error in the following cases:
    /// - **NoPipe**: No active IPC pipe exists.
    /// - **IoError**: An I/O error occurred while reading from the pipe.
    ///     - **WouldBlock**: No data available to read at the moment.
    fn try_read_exact(&mut self, buf: &mut [u8]) -> Result<()> {
        let pipe = self.0.as_mut().ok_or(Error::NoPipe)?;

        #[cfg(windows)] // Avoids blocking while waiting for data on Windows.
        // On unix, the pipe is already opened in `non_blocking` mode.
        if pipe.metadata()?.len() == 0 {
            return Err(Error::IoError(std::io::Error::new(
                std::io::ErrorKind::WouldBlock,
                "No data available",
            )));
        };

        pipe.read_exact(buf)?;

        Ok(())
    }
}