From a72f2afaff2c0bd398ae60ee80973a3c6666c442 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 5 Feb 2026 16:09:40 -0800 Subject: [PATCH] feat: add discord rich presence (#105) ### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/105 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- src-tauri/Cargo.lock | 37 +++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 36 ++++ src-tauri/src/config.rs | 10 + src-tauri/src/discord_rpc.rs | 218 ++++++++++++++++++++ src-tauri/src/lib.rs | 12 ++ src/lib/commands/slashCommands.test.ts | 10 +- src/lib/commands/slashCommands.ts | 26 ++- src/lib/components/ConfigSidebar.svelte | 25 +++ src/lib/components/InputBar.svelte | 14 +- src/lib/components/PermissionModal.svelte | 14 ++ src/lib/components/StatusBar.svelte | 12 ++ src/lib/components/UserQuestionModal.svelte | 14 ++ src/lib/stores/config.test.ts | 2 + src/lib/stores/config.ts | 3 + src/lib/stores/conversations.ts | 2 + src/lib/stores/stats.ts | 8 +- src/lib/tauri.ts | 70 +++++++ src/routes/+page.svelte | 30 ++- 19 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/discord_rpc.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfcea77..c5dfd73 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -437,7 +437,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid", + "uuid 1.19.0", ] [[package]] @@ -788,6 +788,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "discord-rich-presence" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "uuid 0.8.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1602,9 +1615,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hikari-desktop" -version = "1.1.1" +version = "1.2.0" dependencies = [ "chrono", + "discord-rich-presence", "parking_lot", "semver", "serde", @@ -1622,7 +1636,7 @@ dependencies = [ "tauri-plugin-store", "tempfile", "tokio", - "uuid", + "uuid 1.19.0", "windows 0.62.2", ] @@ -3578,7 +3592,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid", + "uuid 1.19.0", ] [[package]] @@ -4261,7 +4275,7 @@ dependencies = [ "thiserror 2.0.17", "time", "url", - "uuid", + "uuid 1.19.0", "walkdir", ] @@ -4557,7 +4571,7 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "url", "urlpattern", - "uuid", + "uuid 1.19.0", "walkdir", ] @@ -5099,6 +5113,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "uuid" version = "1.19.0" @@ -6127,7 +6150,7 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "uuid", + "uuid 1.19.0", "windows-sys 0.61.2", "winnow 0.7.14", "zbus_macros", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1077f5..47628b2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri-plugin-fs = "2" tempfile = "3" semver = "1" chrono = { version = "0.4.43", features = ["serde"] } +discord-rich-presence = "0.2" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a63a374..fa70da4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -647,6 +647,42 @@ async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::Cost Ok(()) } +#[tauri::command] +pub async fn init_discord_rpc( + discord_rpc: State<'_, std::sync::Arc>, + session_name: String, + model: String, + started_at: i64, +) -> Result<(), String> { + discord_rpc.init(session_name, model, started_at) +} + +#[tauri::command] +pub async fn update_discord_rpc( + discord_rpc: State<'_, std::sync::Arc>, + session_name: String, + model: String, + started_at: i64, +) -> Result<(), String> { + discord_rpc.update(session_name, model, started_at) +} + +#[tauri::command] +pub async fn stop_discord_rpc( + discord_rpc: State<'_, std::sync::Arc>, +) -> Result<(), String> { + discord_rpc.stop() +} + +#[tauri::command] +pub async fn log_discord_rpc( + discord_rpc: State<'_, std::sync::Arc>, + message: String, +) -> Result<(), String> { + discord_rpc.log(&message); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 20ff574..b8fe217 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -112,6 +112,9 @@ pub struct HikariConfig { #[serde(default = "default_budget_warning_threshold")] pub budget_warning_threshold: f32, + + #[serde(default = "default_discord_rpc_enabled")] + pub discord_rpc_enabled: bool, } impl Default for HikariConfig { @@ -144,6 +147,7 @@ impl Default for HikariConfig { session_cost_budget: None, budget_action: BudgetAction::Warn, budget_warning_threshold: 0.8, + discord_rpc_enabled: true, } } } @@ -176,6 +180,10 @@ fn default_budget_warning_threshold() -> f32 { 0.8 } +fn default_discord_rpc_enabled() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum BudgetAction { @@ -247,6 +255,7 @@ mod tests { assert!(config.session_cost_budget.is_none()); assert_eq!(config.budget_action, BudgetAction::Warn); assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON); + assert!(config.discord_rpc_enabled); } #[test] @@ -279,6 +288,7 @@ mod tests { session_cost_budget: Some(1.50), budget_action: BudgetAction::Block, budget_warning_threshold: 0.75, + discord_rpc_enabled: true, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/discord_rpc.rs b/src-tauri/src/discord_rpc.rs new file mode 100644 index 0000000..a9e61fa --- /dev/null +++ b/src-tauri/src/discord_rpc.rs @@ -0,0 +1,218 @@ +use discord_rich_presence::activity::{Activity, Assets, Timestamps}; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; +use parking_lot::RwLock; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; + +pub struct DiscordRpcManager { + client: Arc>>, + session_name: Arc>, + model: Arc>, + started_at: Arc>, + log_path: Arc>>, +} + +impl DiscordRpcManager { + pub fn new() -> Self { + Self { + client: Arc::new(RwLock::new(None)), + session_name: Arc::new(RwLock::new(String::new())), + model: Arc::new(RwLock::new(String::new())), + started_at: Arc::new(RwLock::new(0)), + log_path: Arc::new(RwLock::new(None)), + } + } + + pub fn set_app_handle(&self, app_handle: &AppHandle) { + if let Ok(app_data_dir) = app_handle.path().app_data_dir() { + // Ensure the directory exists + if let Err(e) = std::fs::create_dir_all(&app_data_dir) { + eprintln!("Failed to create app data directory: {}", e); + return; + } + let log_path = app_data_dir.join("hikari_discord_rpc.log"); + *self.log_path.write() = Some(log_path.clone()); + self.log(&format!( + "Log file initialised at: {}", + log_path.display() + )); + } + } + + pub fn log(&self, message: &str) { + let log_path_guard = self.log_path.read(); + let path = match log_path_guard.as_ref() { + Some(p) => p.clone(), + None => PathBuf::from("hikari_discord_rpc.log"), + }; + drop(log_path_guard); + + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let _ = writeln!(file, "[{}] {}", timestamp, message); + } + } + + pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> { + self.log("Attempting to initialize Discord RPC..."); + self.log("DEBUG: Application ID: 1391117878182281316"); + self.log(&format!("DEBUG: Initial session: '{}', model: '{}', timestamp: {}", + initial_session_name, initial_model, started_at)); + + let mut client = DiscordIpcClient::new("1391117878182281316") + .map_err(|e| { + let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + + self.log("DEBUG: DiscordIpcClient created successfully"); + + client + .connect() + .map_err(|e| { + let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + + self.log("DEBUG: Connected to Discord IPC socket"); + + // Set initial activity immediately after connecting + self.log("DEBUG: Building initial activity..."); + let state_text = format!("Model: {}", initial_model); + let assets = Assets::new() + .large_image("hikari") + .large_text("Hikari - Claude Code Assistant"); + + self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'"); + + let timestamps = Timestamps::new() + .start(started_at); + + self.log(&format!("DEBUG: Timestamps created - start: {}", started_at)); + + let activity = Activity::new() + .details(initial_session_name.as_str()) + .state(state_text.as_str()) + .assets(assets) + .timestamps(timestamps); + + self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'", + initial_session_name, state_text)); + + self.log("DEBUG: Attempting to set initial activity..."); + client + .set_activity(activity) + .map_err(|e| { + let error_msg = format!("Failed to set initial Discord RPC activity: {}", e); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + + self.log("DEBUG: Initial activity set successfully!"); + + // Store the client and initial state + *self.client.write() = Some(client); + *self.session_name.write() = initial_session_name.clone(); + *self.model.write() = initial_model.clone(); + *self.started_at.write() = started_at; + + self.log(&format!("Discord RPC connected successfully with initial activity: session='{}', model='{}'", + initial_session_name, initial_model)); + Ok(()) + } + + pub fn update( + &self, + session_name: String, + model: String, + started_at: i64, + ) -> Result<(), String> { + self.log(&format!("DEBUG: update() called with session='{}', model='{}', timestamp={}", + session_name, model, started_at)); + + *self.session_name.write() = session_name.clone(); + *self.model.write() = model.clone(); + *self.started_at.write() = started_at; + + self.log("DEBUG: State variables updated"); + + let mut client_guard = self.client.write(); + let client = client_guard + .as_mut() + .ok_or_else(|| { + let error_msg = "Discord RPC client not initialized".to_string(); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + + self.log("DEBUG: Client lock acquired"); + + let state_text = format!("Model: {}", model); + let assets = Assets::new() + .large_image("hikari") + .large_text("Hikari - Claude Code Assistant"); + + self.log("DEBUG: Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'"); + + let timestamps = Timestamps::new() + .start(started_at); + + self.log(&format!("DEBUG: Timestamps created - start: {}", started_at)); + + let activity = Activity::new() + .details(session_name.as_str()) + .state(state_text.as_str()) + .assets(assets) + .timestamps(timestamps); + + self.log(&format!("DEBUG: Activity created - details: '{}', state: '{}'", + session_name, state_text)); + + self.log("DEBUG: Attempting to set activity..."); + client + .set_activity(activity) + .map_err(|e| { + let error_msg = format!("Failed to update Discord RPC: {}", e); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + + self.log(&format!("Updated Discord RPC: session='{}', model='{}'", session_name, model)); + Ok(()) + } + + pub fn stop(&self) -> Result<(), String> { + self.log("DEBUG: stop() called"); + + let mut client_guard = self.client.write(); + if let Some(mut client) = client_guard.take() { + self.log("DEBUG: Client found, attempting to close..."); + client + .close() + .map_err(|e| { + let error_msg = format!("Failed to close Discord RPC: {}", e); + self.log(&format!("ERROR: {}", error_msg)); + error_msg + })?; + self.log("Discord RPC stopped successfully"); + } else { + self.log("DEBUG: No client to stop (already stopped or never initialized)"); + } + Ok(()) + } +} + +impl Default for DiscordRpcManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f5c82c..be8656d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod clipboard; mod commands; mod config; mod cost_tracking; +mod discord_rpc; mod git; mod notifications; mod quick_actions; @@ -23,11 +24,13 @@ use bridge_manager::create_shared_bridge_manager; use clipboard::*; use commands::load_saved_achievements; use commands::*; +use discord_rpc::DiscordRpcManager; use git::*; use notifications::*; use quick_actions::*; use sessions::*; use snippets::*; +use std::sync::Arc; use tauri::Manager; use temp_manager::create_shared_temp_manager; use tray::{setup_tray, should_minimize_to_tray}; @@ -39,6 +42,7 @@ use wsl_notifications::*; pub fn run() { let bridge_manager = create_shared_bridge_manager(); let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager"); + let discord_rpc = Arc::new(DiscordRpcManager::new()); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -52,10 +56,14 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .manage(bridge_manager.clone()) .manage(temp_manager.clone()) + .manage(discord_rpc.clone()) .setup(move |app| { // Initialize the app handle in the bridge manager bridge_manager.lock().set_app_handle(app.handle().clone()); + // Initialize the app handle in the Discord RPC manager for logging + discord_rpc.set_app_handle(app.handle()); + // Clean up any orphaned temp files from previous sessions if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() { if count > 0 { @@ -169,6 +177,10 @@ pub fn run() { get_today_cost, get_week_cost, get_month_cost, + init_discord_rpc, + update_discord_rpc, + stop_discord_rpc, + log_discord_rpc, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts index 8acf2f7..74c7de5 100644 --- a/src/lib/commands/slashCommands.test.ts +++ b/src/lib/commands/slashCommands.test.ts @@ -8,9 +8,13 @@ import { } from "./slashCommands"; // Mock all external dependencies -vi.mock("svelte/store", () => ({ - get: vi.fn(), -})); +vi.mock("svelte/store", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get: vi.fn(), + }; +}); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 04bdd97..90271a0 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -2,8 +2,10 @@ import { get } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; -import { setSkipNextGreeting } from "$lib/tauri"; +import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri"; import { searchState } from "$lib/stores/search"; +import { conversationsStore } from "$lib/stores/conversations"; +import { configStore } from "$lib/stores/config"; export interface SlashCommand { name: string; @@ -51,6 +53,17 @@ async function changeDirectory(path: string): Promise { }, }); + // Update Discord RPC when reconnecting after directory change + const config = configStore.getConfig(); + const activeConversation = get(conversationsStore.activeConversation); + if (activeConversation) { + await updateDiscordRpc( + activeConversation.name, + config.model || "claude", + activeConversation.startedAt + ); + } + // Wait for connection to establish await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -105,6 +118,17 @@ async function startNewConversation(): Promise { }, }); + // Update Discord RPC when starting new conversation + const config = configStore.getConfig(); + const activeConversation = get(conversationsStore.activeConversation); + if (activeConversation) { + await updateDiscordRpc( + activeConversation.name, + config.model || "claude", + activeConversation.startedAt + ); + } + claudeStore.addLine("system", "New conversation started!"); characterState.setState("idle"); } catch (error) { diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index a8d7f0b..9ea2183 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -51,6 +51,7 @@ session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, + discord_rpc_enabled: true, }); let showCustomThemeEditor = $state(false); @@ -967,6 +968,30 @@ + +
+

+ 🎮 + Discord Rich Presence +

+ + +
+ +
+ +
+ Display your current conversation session name and model in Discord when enabled. +
+
+