Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/analytics/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
specta = { workspace = true, features = ["derive", "serde_json"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio = { workspace = true, features = ["sync", "rt"] }
tracing = { workspace = true }

[dev-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions crates/analytics/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use serde::{Serialize, ser::Serializer};
pub enum Error {
#[error("posthog error: {0}")]
PosthogError(String),
#[error("runtime error: {0}")]
Runtime(String),
}

impl From<posthog_rs::Error> for Error {
Expand Down
68 changes: 68 additions & 0 deletions crates/analytics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ use std::time::Duration;

mod error;
mod outlit;
pub mod runtime;

pub use error::*;
pub use runtime::AnalyticsRuntime;

use outlit::OutlitClient;
use posthog_rs::{ClientOptions, Event};
Expand Down Expand Up @@ -394,6 +396,72 @@ impl AnalyticsPayloadBuilder {
}
}

#[derive(Clone)]
pub struct AnalyticsService {
client: AnalyticsClient,
runtime: Arc<dyn AnalyticsRuntime>,
tokio_handle: tokio::runtime::Handle,
}

impl AnalyticsService {
pub fn new(client: AnalyticsClient, runtime: Arc<dyn AnalyticsRuntime>) -> Self {
Self {
client,
runtime,
tokio_handle: tokio::runtime::Handle::current(),
}
}

pub async fn event(&self, mut payload: AnalyticsPayload) -> Result<(), Error> {
if self.runtime.is_disabled() {
return Ok(());
}
self.runtime.enrich(&mut payload);
let distinct_id = self.runtime.distinct_id();
self.client.event(distinct_id, payload).await
}

pub fn event_fire_and_forget(&self, mut payload: AnalyticsPayload) {
if self.runtime.is_disabled() {
return;
}
self.runtime.enrich(&mut payload);
let distinct_id = self.runtime.distinct_id();
let client = self.client.clone();
self.tokio_handle.spawn(async move {
let _ = client.event(distinct_id, payload).await;
});
}

pub async fn set_properties(&self, payload: PropertiesPayload) -> Result<(), Error> {
if self.runtime.is_disabled() {
return Ok(());
}
let distinct_id = self.runtime.distinct_id();
self.client.set_properties(distinct_id, payload).await
}

pub async fn identify(
&self,
user_id: impl Into<String>,
payload: PropertiesPayload,
) -> Result<(), Error> {
if self.runtime.is_disabled() {
return Ok(());
}
let distinct_id = self.runtime.distinct_id();
self.client.identify(user_id, distinct_id, payload).await
}

pub fn is_disabled(&self) -> bool {
self.runtime.is_disabled()
}

pub fn set_disabled(&self, disabled: bool) -> Result<(), Error> {
self.runtime.set_disabled(disabled)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
8 changes: 8 additions & 0 deletions crates/analytics/src/runtime.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use crate::{AnalyticsPayload, Error};

pub trait AnalyticsRuntime: Send + Sync + 'static {
fn enrich(&self, payload: &mut AnalyticsPayload);
fn distinct_id(&self) -> String;
fn is_disabled(&self) -> bool;
fn set_disabled(&self, disabled: bool) -> Result<(), Error>;
}
116 changes: 22 additions & 94 deletions plugins/analytics/src/ext.rs
Original file line number Diff line number Diff line change
@@ -1,128 +1,56 @@
use tauri_plugin_misc::MiscPluginExt;
use tauri_plugin_store2::Store2PluginExt;

pub struct Analytics<'a, R: tauri::Runtime, M: tauri::Manager<R>> {
manager: &'a M,
_runtime: std::marker::PhantomData<fn() -> R>,
}

impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Analytics<'a, R, M> {
fn service(&self) -> tauri::State<'_, crate::ManagedState> {
self.manager.state::<crate::ManagedState>()
}

pub async fn event(
&self,
mut payload: hypr_analytics::AnalyticsPayload,
payload: hypr_analytics::AnalyticsPayload,
) -> Result<(), crate::Error> {
Self::enrich_payload(self.manager, &mut payload);

if self.is_disabled().unwrap_or(true) {
return Ok(());
}

let machine_id = hypr_host::fingerprint();
let client = self.manager.state::<crate::ManagedState>();
client
.event(machine_id, payload)
self.service()
.event(payload)
.await
.map_err(crate::Error::HyprAnalytics)?;

Ok(())
.map_err(crate::Error::HyprAnalytics)
}

pub fn event_fire_and_forget(&self, mut payload: hypr_analytics::AnalyticsPayload) {
Self::enrich_payload(self.manager, &mut payload);

if self.is_disabled().unwrap_or(true) {
return;
}

let machine_id = hypr_host::fingerprint();
let client = self.manager.state::<crate::ManagedState>().inner().clone();

tauri::async_runtime::spawn(async move {
let _ = client.event(machine_id, payload).await;
});
}

fn enrich_payload(manager: &M, payload: &mut hypr_analytics::AnalyticsPayload) {
let app_version = env!("APP_VERSION");
let app_identifier = manager.config().identifier.clone();
let git_hash = manager.misc().get_git_hash();
let bundle_id = manager.config().identifier.clone();

payload
.props
.entry("app_version".into())
.or_insert(app_version.into());

payload
.props
.entry("app_identifier".into())
.or_insert(app_identifier.into());

payload
.props
.entry("git_hash".into())
.or_insert(git_hash.into());

payload
.props
.entry("bundle_id".into())
.or_insert(bundle_id.into());

payload.props.entry("$set".into()).or_insert_with(|| {
serde_json::json!({
"app_version": app_version
})
});
pub fn event_fire_and_forget(&self, payload: hypr_analytics::AnalyticsPayload) {
self.service().event_fire_and_forget(payload);
}

pub fn set_disabled(&self, disabled: bool) -> Result<(), crate::Error> {
{
let store = self.manager.store2().scoped_store(crate::PLUGIN_NAME)?;
store.set(crate::StoreKey::Disabled, disabled)?;
}
Ok(())
self.service()
.set_disabled(disabled)
.map_err(crate::Error::HyprAnalytics)
}

pub fn is_disabled(&self) -> Result<bool, crate::Error> {
let store = self.manager.store2().scoped_store(crate::PLUGIN_NAME)?;
let v = store.get(crate::StoreKey::Disabled)?.unwrap_or(false);
Ok(v)
Ok(self.service().is_disabled())
}

pub async fn set_properties(
&self,
payload: hypr_analytics::PropertiesPayload,
) -> Result<(), crate::Error> {
if !self.is_disabled()? {
let machine_id = hypr_host::fingerprint();

let client = self.manager.state::<crate::ManagedState>();
client
.set_properties(machine_id, payload)
.await
.map_err(crate::Error::HyprAnalytics)?;
}

Ok(())
self.service()
.set_properties(payload)
.await
.map_err(crate::Error::HyprAnalytics)
}

pub async fn identify(
&self,
user_id: impl Into<String>,
payload: hypr_analytics::PropertiesPayload,
) -> Result<(), crate::Error> {
if !self.is_disabled()? {
let machine_id = hypr_host::fingerprint();
let user_id = user_id.into();

let client = self.manager.state::<crate::ManagedState>();
client
.identify(user_id, machine_id, payload)
.await
.map_err(crate::Error::HyprAnalytics)?;
}

Ok(())
self.service()
.identify(user_id, payload)
.await
.map_err(crate::Error::HyprAnalytics)
}
}

Expand Down
10 changes: 8 additions & 2 deletions plugins/analytics/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
use std::sync::Arc;

use tauri::Manager;

mod commands;
mod error;
mod ext;
mod store;
mod tauri_runtime;

pub use error::{Error, Result};
pub use ext::*;
use store::*;

pub use hypr_analytics::*;

pub type ManagedState = hypr_analytics::AnalyticsClient;
pub type ManagedState = hypr_analytics::AnalyticsService;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag plugin breaks: managed state type changed

Medium Severity

The Tauri managed state type changed from AnalyticsClient to AnalyticsService, but the plugins/flag plugin still declares pub type ManagedState = hypr_analytics::AnalyticsClient and calls self.manager.state::<ManagedState>() in get_posthog_flag. Since only AnalyticsService is now managed, any code path reaching a Posthog-backed feature flag will trigger a runtime panic. Currently no features use FlagStrategy::Posthog, so this is latent but structurally broken.

Additional Locations (1)

Fix in Cursor Fix in Web


const PLUGIN_NAME: &str = "analytics";

Expand Down Expand Up @@ -62,7 +65,10 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
builder.build()
};

assert!(app.manage(client));
let runtime = Arc::new(tauri_runtime::TauriAnalyticsRuntime::new(app));
let service = hypr_analytics::AnalyticsService::new(client, runtime);

assert!(app.manage(service));
Ok(())
})
.build()
Expand Down
84 changes: 84 additions & 0 deletions plugins/analytics/src/tauri_runtime.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use hypr_analytics::{AnalyticsPayload, AnalyticsRuntime};
use tauri_plugin_store2::Store2PluginExt;

pub struct TauriAnalyticsRuntime<R: tauri::Runtime> {
app_handle: tauri::AppHandle<R>,
app_version: String,
git_hash: String,
bundle_id: String,
app_identifier: String,
}

impl<R: tauri::Runtime> TauriAnalyticsRuntime<R> {
pub fn new(app_handle: &tauri::AppHandle<R>) -> Self {
use tauri_plugin_misc::MiscPluginExt;

let app_version = env!("APP_VERSION").to_string();
let git_hash = app_handle.misc().get_git_hash();
let bundle_id = app_handle.config().identifier.clone();
let app_identifier = app_handle.config().identifier.clone();

Self {
app_handle: app_handle.clone(),
app_version,
git_hash,
bundle_id,
app_identifier,
}
}
}

impl<R: tauri::Runtime> AnalyticsRuntime for TauriAnalyticsRuntime<R> {
fn enrich(&self, payload: &mut AnalyticsPayload) {
payload
.props
.entry("app_version".into())
.or_insert(self.app_version.clone().into());

payload
.props
.entry("app_identifier".into())
.or_insert(self.app_identifier.clone().into());

payload
.props
.entry("git_hash".into())
.or_insert(self.git_hash.clone().into());

payload
.props
.entry("bundle_id".into())
.or_insert(self.bundle_id.clone().into());

payload.props.entry("$set".into()).or_insert_with(|| {
serde_json::json!({
"app_version": self.app_version
})
});
}

fn distinct_id(&self) -> String {
hypr_host::fingerprint()
}

fn is_disabled(&self) -> bool {
let result: Result<bool, tauri_plugin_store2::Error> = (|| {
let store = self.app_handle.store2().scoped_store(crate::PLUGIN_NAME)?;
let v: bool = store.get(crate::StoreKey::Disabled)?.unwrap_or(false);
Ok(v)
})();
result.unwrap_or(true)
}

fn set_disabled(&self, disabled: bool) -> Result<(), hypr_analytics::Error> {
let store = self
.app_handle
.store2()
.scoped_store(crate::PLUGIN_NAME)
.map_err(|e| hypr_analytics::Error::Runtime(e.to_string()))?;
store
.set(crate::StoreKey::Disabled, disabled)
.map_err(|e| hypr_analytics::Error::Runtime(e.to_string()))?;
Ok(())
}
}