From 43dab27968fdaf0f00f90860557ebc9c61d587e7 Mon Sep 17 00:00:00 2001 From: Virt <41426325+VirtCode@users.noreply.github.com> Date: Thu, 17 Jul 2025 01:54:54 +0200 Subject: [PATCH 1/2] feat: first fully working version --- Cargo.lock | 389 +----------------------------------------------- Cargo.toml | 7 +- src/discord.rs | 32 ++-- src/hyprland.rs | 130 ++++++++++++++++ src/main.rs | 173 +++++++++++++++++---- 5 files changed, 300 insertions(+), 431 deletions(-) create mode 100644 src/hyprland.rs diff --git a/Cargo.lock b/Cargo.lock index f57de8a..93ba5c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,34 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -75,7 +47,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -86,7 +58,7 @@ checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -95,61 +67,6 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - [[package]] name = "cfg-if" version = "1.0.1" @@ -162,27 +79,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "unicode-xid", -] - [[package]] name = "discord-rich-presence" version = "0.2.5" @@ -197,12 +93,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "env_filter" version = "0.1.3" @@ -226,22 +116,6 @@ dependencies = [ "log", ] -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-lite" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "getrandom" version = "0.2.16" @@ -253,12 +127,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "hl-zed-dc-rpc" version = "0.1.0" @@ -266,52 +134,10 @@ dependencies = [ "anyhow", "discord-rich-presence", "env_logger", - "hyprland", "log", "regex", - "serde_json", -] - -[[package]] -name = "hyprland" -version = "0.4.0-beta.2" -source = "git+https://github.com/hyprland-community/hyprland-rs#fce63060619422f52563514b474398e380e5fc51" -dependencies = [ - "ahash", - "async-stream", - "derive_more", - "either", - "futures-lite", - "hyprland-macros", - "num-traits", - "once_cell", - "paste", - "phf", "serde", "serde_json", - "serde_repr", - "tokio", -] - -[[package]] -name = "hyprland-macros" -version = "0.4.0-beta.2" -source = "git+https://github.com/hyprland-community/hyprland-rs#fce63060619422f52563514b474398e380e5fc51" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags", - "cfg-if", - "libc", ] [[package]] @@ -368,110 +194,12 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.59.0", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - [[package]] name = "once_cell_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - [[package]] name = "portable-atomic" version = "1.11.1" @@ -505,21 +233,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "regex" version = "1.11.1" @@ -549,12 +262,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "rustc-demangle" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" - [[package]] name = "ryu" version = "1.0.20" @@ -604,28 +311,6 @@ dependencies = [ "syn", ] -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "syn" version = "2.0.104" @@ -657,47 +342,12 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio" -version = "1.46.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "pin-project-lite", - "slab", - "socket2", - "tokio-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -713,27 +363,12 @@ dependencies = [ "getrandom", ] -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -806,23 +441,3 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "zerocopy" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 26da3f0..9957494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,12 @@ edition = "2024" [dependencies] anyhow = "1.0.98" -discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" } # git cause errors env_logger = "0.11.8" -hyprland = { git = "https://github.com/hyprland-community/hyprland-rs" } log = "0.4.27" + regex = "1.11.1" + +serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" + +discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" } # git cause errors diff --git a/src/discord.rs b/src/discord.rs index 174dc0a..fa1a7d9 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,5 +1,7 @@ /* - * This file WAS ONCE is part of discord-presence. Extension for Zed that adds support for Discord Rich Presence using LSP. It is heavily modified to be used here. + * This file WAS ONCE is part of discord-presence. Extension for Zed that + * adds support for Discord Rich Presence using LSP. It is heavily modified + * to be used here. * * Copyright (c) 2025 Steinhübl, Virt */ @@ -16,15 +18,11 @@ use log::{debug, error, info}; #[derive(Debug)] pub struct Discord { client: DiscordIpcClient, - start_timestamp: Duration, } impl Discord { pub fn new(id: &str) -> Result { - let since_epoch = - SystemTime::now().duration_since(UNIX_EPOCH).expect("we are after 1970 right??"); - - Ok(Self { client: DiscordIpcClient::new(id), start_timestamp: since_epoch }) + Ok(Self { client: DiscordIpcClient::new(id) }) } pub fn connect(&mut self) -> Result<()> { @@ -58,15 +56,21 @@ impl Discord { small_image: Option, small_text: Option, git_remote_url: Option, + timestamp: SystemTime, ) -> Result<()> { - let activity = Activity::new() - .timestamps(Timestamps::new().start(self.start_timestamp.as_millis() as i64)) - .buttons( - git_remote_url - .as_ref() - .map(|url| vec![Button::new("View Repository", url)]) - .unwrap_or_default(), - ); + let activity = + Activity::new() + .timestamps( + Timestamps::new() + .start(timestamp.duration_since(UNIX_EPOCH).expect("1970!!").as_millis() + as i64), + ) + .buttons( + git_remote_url + .as_ref() + .map(|url| vec![Button::new("View Repository", url)]) + .unwrap_or_default(), + ); let activity = set_optional_field(activity, state.as_deref(), Activity::state); let activity = set_optional_field(activity, details.as_deref(), Activity::details); diff --git a/src/hyprland.rs b/src/hyprland.rs new file mode 100644 index 0000000..b8519b7 --- /dev/null +++ b/src/hyprland.rs @@ -0,0 +1,130 @@ +use std::{ + io::{self, Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use log::debug; +use serde::Deserialize; + +pub type Address = String; + +#[derive(Deserialize)] +pub struct HyprlandClient { + pub address: Address, + pub class: String, + pub title: String, +} + +#[derive(Debug)] +pub enum HyprlandEvent { + Open { address: Address, workspace: String, title: String, class: String }, + Active { address: Address }, + Close { address: Address }, + Title { address: Address, title: String }, +} + +pub struct Hyprland { + path: PathBuf, + stream: Option, +} + +impl Hyprland { + /// creates a new instance based on the environment variables + pub fn env() -> Result { + let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .context("unable to read HYPRLAND_INSTANCE_SIGNATURE env")?; + + let runtime_dir = + std::env::var("XDG_RUNTIME_DIR").context("unable to read XDG_RUNTIME_DIR env")?; + + Ok(Self { path: PathBuf::from(format!("{runtime_dir}/hypr/{instance}")), stream: None }) + } + + pub fn connect(&mut self, timeout: Duration) -> Result<()> { + debug!("connecting to hyprland socket 2"); + + let stream = UnixStream::connect(self.path.join(".socket2.sock")) + .context("failed to connect to hyprland ipc")?; + stream.set_read_timeout(Some(timeout)).context("failed to set socket timeout")?; + + self.stream = Some(stream); + Ok(()) + } + + pub fn read_events(&mut self) -> Result>> { + let Some(stream) = self.stream.as_mut() else { + return Err(anyhow!("not yet connected to the ipc")); + }; + + let mut buf = [0; 4096]; + let len = match stream.read(&mut buf) { + Ok(len) => len, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None), + Err(e) => return Err(e).context("failed to read from hyprland socket"), + }; + + let string = + String::from_utf8(buf[..len].to_vec()).context("read data from stream is not utf8")?; + + Ok(Some( + string + .split('\n') + .filter_map(|split| { + let things = split.split(">>").collect::>(); + if things.len() != 2 { + return None; + }; + + let event = things[0]; + let args = things[1].split(',').collect::>(); + + match event { + "activewindowv2" if args.len() == 1 => { + Some(HyprlandEvent::Active { address: args[0].to_string() }) + } + "openwindow" if args.len() == 4 => Some(HyprlandEvent::Open { + address: args[0].to_string(), + workspace: args[1].to_string(), + class: args[2].to_string(), + title: args[3].to_string(), + }), + "closewindow" if args.len() == 1 => { + Some(HyprlandEvent::Close { address: args[0].to_string() }) + } + "windowtitlev2" if args.len() == 2 => Some(HyprlandEvent::Title { + address: args[0].to_string(), + title: args[1].to_string(), + }), + _ => None, + } + }) + .collect(), + )) + } + + /// dispatches a command over hyprland's socket 1 and reads the result + fn dispatch_command(&self, command: &str) -> Result { + let mut stream = UnixStream::connect(self.path.join(".socket.sock")) + .context("failed to connect to hl's socket 1")?; + + stream + .write_all(format!("j/{command}").as_bytes()) + .context("failed to write to hl's socket 1")?; + + let mut buf = String::new(); + stream.read_to_string(&mut buf).context("failed to read from hl's socket 1")?; + + Ok(buf) + } + + /// gets the workspace state from socket 1 + pub fn get_all_clients(&self) -> Result> { + serde_json::from_str( + &self.dispatch_command("clients").context("failed to run `clients` hyprctl command")?, + ) + .context("failed to deserialize output of `clients` hyprctl command") + } +} diff --git a/src/main.rs b/src/main.rs index 2ef9155..d53ef0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,52 +1,168 @@ -use std::{thread::sleep, time::Duration}; +use std::{ + collections::HashMap, + thread::sleep, + time::{Duration, Instant, SystemTime}, +}; use anyhow::Result; use discord::Discord; -use hyprland::{ - data::{Client, Clients}, - event_listener::EventListener, - shared::HyprData, -}; +use hyprland::{Address, Hyprland}; use language::get_language; +use log::{debug, info}; pub mod discord; +pub mod hyprland; pub mod language; const APP_ID: &str = "1263505205522337886"; const ICONS_URL: &str = "https://raw.githubusercontent.com/xhyrom/zed-discord-presence/main/assets/icons"; +const ZED_CLASS: &str = "dev.zed.Zed"; -fn main() -> Result<()> { - let mut discord = Discord::new(APP_ID)?; - discord.connect()?; +#[derive(Debug)] +struct ZedInstance { + workspace: String, + file: Option, + started: SystemTime, - update_discord(&mut discord)?; - //set_activity(&mut discord, "main.rs", "a secret new rust project")?; - - sleep(Duration::from_secs(10)); - - Ok(()) + // last time it was focused + focused: SystemTime, } -pub fn update_discord(discord: &mut Discord) -> Result<()> { - let mut clients = - Clients::get()?.into_iter().filter(|c| c.class == "dev.zed.Zed").collect::>(); - clients.sort_by(|c, d| c.focus_history_id.cmp(&d.focus_history_id)); +impl ZedInstance { + pub fn new(title: &str) -> Self { + let mut s = Self { + workspace: String::new(), + file: None, + started: SystemTime::now(), + focused: SystemTime::now(), + }; - if let Some(client) = clients.first() { - let split = client.title.split('—').collect::>(); + s.set_title(title); - if split.len() == 1 { - set_activity(discord, "not-doing-anything-atm", split[0].trim())?; - } else if split.len() == 2 { - set_activity(discord, split[1].trim(), split[0].trim())?; - } + s } - Ok(()) + fn set_title(&mut self, title: &str) { + let split = title.split('—').collect::>(); + + if split.len() == 1 { + self.file = None; + self.workspace = split[0].trim().to_string(); + } else if split.len() == 2 { + self.workspace = split[0].trim().to_string(); + self.file = Some(split[1].trim().to_string()); + } + } } -pub fn set_activity(discord: &mut Discord, file: &str, workspace: &str) -> Result<()> { +fn main() -> Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let idle = Duration::from_secs(10 * 60); + + let mut discord = Discord::new(APP_ID)?; + let mut hyprland = Hyprland::env()?; + + let mut instances = HashMap::new(); + let mut active = Address::new(); + + for client in hyprland.get_all_clients()? { + if client.class != "dev.zed.Zed" { + continue; + } + + instances.insert( + // we strip the 0x as this only appears in this request + client.address.strip_prefix("0x").unwrap_or("unknown").to_string(), + ZedInstance::new(&client.title), + ); + } + + info!("connecting to discord ipc"); + discord.connect()?; + info!("connecting to hyprland ipc"); + hyprland.connect(Duration::from_secs(60))?; + + let mut first = true; + loop { + let events = hyprland.read_events()?; + debug!("received new hyprland events"); + + // only check on timeout if not active + let mut changed = first || events.is_none() && !instances.contains_key(&active); + first = false; + + if let Some(events) = events { + for event in events { + changed |= match event { + hyprland::HyprlandEvent::Open { address, title, class, .. } + if class == ZED_CLASS => + { + instances.insert(address, ZedInstance::new(&title)); + true + } + hyprland::HyprlandEvent::Active { address } => { + let changed = if let Some(instance) = instances.get_mut(&active) { + instance.focused = SystemTime::now(); + true + } else { + false + }; + + active = address; + + changed || instances.contains_key(&active) + } + hyprland::HyprlandEvent::Close { address } => { + instances.remove(&address).is_some() + } + hyprland::HyprlandEvent::Title { address, title } => { + if let Some(instance) = instances.get_mut(&address) { + instance.set_title(&title); + true + } else { + false + } + } + _ => false, + }; + } + } + + if changed { + if let Some(instance) = instances.get(&active).or_else(|| { + instances.values().max_by(|a, b| a.focused.cmp(&b.focused)).filter(|instance| { + SystemTime::now().duration_since(instance.focused).expect("compare times wtf") + < idle + }) + }) { + info!( + "setting discord activity to {} in workspace {}", + instance.file.as_ref().map(|s| s.as_str()).unwrap_or(""), + instance.workspace + ); + + set_activity( + &mut discord, + instance.file.as_ref().map(|s| s.as_str()).unwrap_or("nothing"), + &instance.workspace, + instance.started, + )? + } else { + info!("removing discord activity"); + discord.clear()?; + } + } + } +} + +pub fn set_activity( + discord: &mut Discord, + file: &str, + workspace: &str, + started: SystemTime, +) -> Result<()> { let language = get_language(file); let language_upper: String = language.chars().take(1).chain(language.chars().skip(1)).collect(); @@ -58,5 +174,6 @@ pub fn set_activity(discord: &mut Discord, file: &str, workspace: &str) -> Resul Some(format!("{ICONS_URL}/zed.png")), Some("Zed".to_string()), None, + started, ) } From 30034b9f601f77829ee741cf851c382e9ad2ac5e Mon Sep 17 00:00:00 2001 From: Virt <41426325+VirtCode@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:56:33 +0200 Subject: [PATCH 2/2] feat: more robust logic and handle discord reconnects --- src/discord.rs | 25 +++++++------- src/main.rs | 91 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index fa1a7d9..91883cb 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,34 +1,32 @@ -/* - * This file WAS ONCE is part of discord-presence. Extension for Zed that - * adds support for Discord Rich Presence using LSP. It is heavily modified - * to be used here. - * - * Copyright (c) 2025 Steinhübl, Virt - */ - -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use discord_rich_presence::{ DiscordIpc, DiscordIpcClient, activity::{Activity, Assets, Button, Timestamps}, }; -use log::{debug, error, info}; +use log::{debug, info}; #[derive(Debug)] pub struct Discord { client: DiscordIpcClient, + connected: bool, } impl Discord { pub fn new(id: &str) -> Result { - Ok(Self { client: DiscordIpcClient::new(id) }) + Ok(Self { client: DiscordIpcClient::new(id), connected: false }) + } + + pub fn is_connected(&self) -> bool { + self.connected } pub fn connect(&mut self) -> Result<()> { debug!("connecting to discord"); self.client.connect().context("failed to connect to discord ipc")?; + self.connected = true; info!("successfully connected to discord"); Ok(()) @@ -37,7 +35,10 @@ impl Discord { pub fn disconnect(&mut self) -> Result<()> { debug!("disconnecting from discord"); - self.client.close().context("failed to disconnect from discord") + self.client.close().context("failed to disconnect from discord")?; + self.connected = false; + + Ok(()) } pub fn clear(&mut self) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index d53ef0e..6845842 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ use std::{ collections::HashMap, - thread::sleep, time::{Duration, Instant, SystemTime}, }; @@ -8,7 +7,7 @@ use anyhow::Result; use discord::Discord; use hyprland::{Address, Hyprland}; use language::get_language; -use log::{debug, info}; +use log::{debug, error, info, warn}; pub mod discord; pub mod hyprland; @@ -19,7 +18,7 @@ const ICONS_URL: &str = "https://raw.githubusercontent.com/xhyrom/zed-discord-presence/main/assets/icons"; const ZED_CLASS: &str = "dev.zed.Zed"; -#[derive(Debug)] +#[derive(Debug, Clone)] struct ZedInstance { workspace: String, file: Option, @@ -29,6 +28,14 @@ struct ZedInstance { focused: SystemTime, } +impl PartialEq for ZedInstance { + fn eq(&self, other: &Self) -> bool { + self.workspace.eq(&other.workspace) + && self.file.eq(&other.file) + && self.started.eq(&other.started) + } +} + impl ZedInstance { pub fn new(title: &str) -> Self { let mut s = Self { @@ -79,19 +86,18 @@ fn main() -> Result<()> { ); } - info!("connecting to discord ipc"); - discord.connect()?; info!("connecting to hyprland ipc"); hyprland.connect(Duration::from_secs(60))?; - let mut first = true; + let mut updated = Instant::now() - idle; + let mut shown: Option = None; + loop { let events = hyprland.read_events()?; debug!("received new hyprland events"); - // only check on timeout if not active - let mut changed = first || events.is_none() && !instances.contains_key(&active); - first = false; + // update anyways if last update has been before idle timeout + let mut changed = Instant::now().duration_since(updated) < idle; if let Some(events) = events { for event in events { @@ -131,27 +137,55 @@ fn main() -> Result<()> { } if changed { - if let Some(instance) = instances.get(&active).or_else(|| { + debug!("checking for instance change"); + + let instance = instances.get(&active).or_else(|| { instances.values().max_by(|a, b| a.focused.cmp(&b.focused)).filter(|instance| { SystemTime::now().duration_since(instance.focused).expect("compare times wtf") < idle }) - }) { - info!( - "setting discord activity to {} in workspace {}", - instance.file.as_ref().map(|s| s.as_str()).unwrap_or(""), - instance.workspace - ); + }); - set_activity( - &mut discord, - instance.file.as_ref().map(|s| s.as_str()).unwrap_or("nothing"), - &instance.workspace, - instance.started, - )? - } else { - info!("removing discord activity"); - discord.clear()?; + if instance != shown.as_ref() { + debug!("updating discord status as change was detected"); + shown = instance.cloned(); + updated = Instant::now(); + + if !discord.is_connected() { + info!("(re-)connecting to discord ipc"); + + if let Err(e) = discord.connect() { + warn!("failed to connect to discord, waiting for next update: {e:#}"); + shown = None; + continue; + } + } + + let result = if let Some(ref instance) = shown { + info!( + "setting discord activity to {} in workspace {}", + instance.file.as_ref().map(|s| s.as_str()).unwrap_or(""), + instance.workspace + ); + set_activity( + &mut discord, + instance.file.as_ref().map(|s| s.as_str()).unwrap_or("nothing"), + &instance.workspace, + instance.started, + ) + } else { + info!("removing discord activity"); + discord.clear() + }; + + if let Err(e) = result { + warn!("failed to set discord activity: {e:#}"); + + debug!("disconnecting from discord"); + discord.disconnect().map_err(|e| error!("failed to disconnect: {e:#}")).ok(); + + shown = None; + } } } } @@ -164,7 +198,12 @@ pub fn set_activity( started: SystemTime, ) -> Result<()> { let language = get_language(file); - let language_upper: String = language.chars().take(1).chain(language.chars().skip(1)).collect(); + let language_upper: String = language + .chars() + .take(1) + .map(|a| a.to_ascii_uppercase()) + .chain(language.chars().skip(1)) + .collect(); discord.set( Some(format!("Editing {file}")),