logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

89
crates/client/Cargo.toml Normal file
View File

@@ -0,0 +1,89 @@
[package]
name = "client"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/client.rs"
doctest = false
[features]
test-support = ["clock/test-support", "collections/test-support", "gpui/test-support", "rpc/test-support"]
[dependencies]
anyhow.workspace = true
async-channel.workspace = true
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] }
base64.workspace = true
chrono = { workspace = true, features = ["serde"] }
clock.workspace = true
cloud_api_client.workspace = true
cloud_api_types.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
credentials_provider.workspace = true
db.workspace = true
derive_more.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
http_client.workspace = true
http_client_tls.workspace = true
httparse = "1.10"
log.workspace = true
parking_lot.workspace = true
paths.workspace = true
postage.workspace = true
rand.workspace = true
regex.workspace = true
release_channel.workspace = true
rpc = { workspace = true, features = ["gpui"] }
serde.workspace = true
serde_json.workspace = true
serde_urlencoded.workspace = true
settings.workspace = true
sha2.workspace = true
smol.workspace = true
telemetry.workspace = true
telemetry_events.workspace = true
text.workspace = true
thiserror.workspace = true
time.workspace = true
tiny_http.workspace = true
tokio-socks.workspace = true
tokio.workspace = true
url.workspace = true
util.workspace = true
worktree.workspace = true
zed_credentials_provider.workspace = true
[dev-dependencies]
clock = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }
fs.workspace = true
gpui = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
rpc = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
[target.'cfg(target_os = "windows")'.dependencies]
semver.workspace = true
windows.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2-foundation.workspace = true
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
tokio-native-tls = "0.3"
[target.'cfg(not(any(target_os = "windows", target_os = "macos")))'.dependencies]
rustls-pki-types = "1.12"
tokio-rustls = { version = "0.26", features = ["tls12", "ring"], default-features = false }

1
crates/client/LICENSE-GPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-GPL

2374
crates/client/src/client.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
use super::{Client, UserStore};
use cloud_api_client::LlmApiToken;
use cloud_api_types::websocket_protocol::MessageToClient;
use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
TaskExt,
};
use std::sync::Arc;
pub trait NeedsLlmTokenRefresh {
/// Returns whether the LLM token needs to be refreshed.
fn needs_llm_token_refresh(&self) -> bool;
}
impl NeedsLlmTokenRefresh for http_client::Response<http_client::AsyncBody> {
fn needs_llm_token_refresh(&self) -> bool {
self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some()
|| self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some()
}
}
enum TokenRefreshMode {
Refresh,
ClearAndRefresh,
}
pub fn global_llm_token(cx: &App) -> LlmApiToken {
RefreshLlmTokenListener::global(cx)
.read(cx)
.llm_api_token
.clone()
}
struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
impl Global for GlobalRefreshLlmTokenListener {}
pub struct LlmTokenRefreshedEvent;
pub struct RefreshLlmTokenListener {
client: Arc<Client>,
user_store: Entity<UserStore>,
llm_api_token: LlmApiToken,
_subscription: Subscription,
}
impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
impl RefreshLlmTokenListener {
pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx));
cx.set_global(GlobalRefreshLlmTokenListener(listener));
}
pub fn global(cx: &App) -> Entity<Self> {
GlobalRefreshLlmTokenListener::global(cx).0.clone()
}
fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
client.add_message_to_client_handler({
let this = cx.weak_entity();
move |message, cx| {
if let Some(this) = this.upgrade() {
Self::handle_refresh_llm_token(this, message, cx);
}
}
});
let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
if matches!(event, super::user::Event::OrganizationChanged) {
this.refresh(TokenRefreshMode::ClearAndRefresh, cx);
}
});
Self {
client,
user_store,
llm_api_token: LlmApiToken::default(),
_subscription: subscription,
}
}
fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context<Self>) {
let client = self.client.clone();
let llm_api_token = self.llm_api_token.clone();
let organization_id = self
.user_store
.read(cx)
.current_organization()
.map(|organization| organization.id.clone());
cx.spawn(async move |this, cx| {
match mode {
TokenRefreshMode::Refresh => {
client
.refresh_llm_token(&llm_api_token, organization_id)
.await?;
}
TokenRefreshMode::ClearAndRefresh => {
client
.clear_and_refresh_llm_token(&llm_api_token, organization_id)
.await?;
}
}
this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
})
.detach_and_log_err(cx);
}
fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
match message {
MessageToClient::UserUpdated => {
this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
}
}
}
}

View File

@@ -0,0 +1,66 @@
//! client proxy
mod http_proxy;
mod socks_proxy;
use anyhow::{Context as _, Result};
use http_client::Url;
use http_proxy::{HttpProxyType, connect_http_proxy_stream, parse_http_proxy};
use socks_proxy::{SocksVersion, connect_socks_proxy_stream, parse_socks_proxy};
pub(crate) async fn connect_proxy_stream(
proxy: &Url,
rpc_host: (&str, u16),
) -> Result<Box<dyn AsyncReadWrite>> {
let Some(((proxy_domain, proxy_port), proxy_type)) = parse_proxy_type(proxy) else {
// If parsing the proxy URL fails, we must avoid falling back to an insecure connection.
// SOCKS proxies are often used in contexts where security and privacy are critical,
// so any fallback could expose users to significant risks.
anyhow::bail!("Parsing proxy url failed");
};
// Connect to proxy and wrap protocol later
let stream = tokio::net::TcpStream::connect((proxy_domain.as_str(), proxy_port))
.await
.context("Failed to connect to proxy")?;
let proxy_stream = match proxy_type {
ProxyType::SocksProxy(proxy) => connect_socks_proxy_stream(stream, proxy, rpc_host).await?,
ProxyType::HttpProxy(proxy) => {
connect_http_proxy_stream(stream, proxy, rpc_host, &proxy_domain).await?
}
};
Ok(proxy_stream)
}
enum ProxyType<'t> {
SocksProxy(SocksVersion<'t>),
HttpProxy(HttpProxyType<'t>),
}
fn parse_proxy_type(proxy: &Url) -> Option<((String, u16), ProxyType<'_>)> {
let scheme = proxy.scheme();
let host = proxy.host()?.to_string();
let port = proxy.port_or_known_default()?;
let proxy_type = match scheme {
scheme if scheme.starts_with("socks") => {
Some(ProxyType::SocksProxy(parse_socks_proxy(scheme, proxy)))
}
scheme if scheme.starts_with("http") => {
Some(ProxyType::HttpProxy(parse_http_proxy(scheme, proxy)))
}
_ => None,
}?;
Some(((host, port), proxy_type))
}
pub(crate) trait AsyncReadWrite:
tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static
{
}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static> AsyncReadWrite
for T
{
}

View File

@@ -0,0 +1,193 @@
use anyhow::{Context, Result};
use base64::Engine;
use httparse::{EMPTY_HEADER, Response};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufStream},
net::TcpStream,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use tokio_native_tls::{TlsConnector, native_tls};
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
use tokio_rustls::TlsConnector;
use url::Url;
use super::AsyncReadWrite;
pub(super) enum HttpProxyType<'t> {
HTTP(Option<HttpProxyAuthorization<'t>>),
HTTPS(Option<HttpProxyAuthorization<'t>>),
}
pub(super) struct HttpProxyAuthorization<'t> {
username: &'t str,
password: &'t str,
}
pub(super) fn parse_http_proxy<'t>(scheme: &str, proxy: &'t Url) -> HttpProxyType<'t> {
let auth = proxy.password().map(|password| HttpProxyAuthorization {
username: proxy.username(),
password,
});
if scheme.starts_with("https") {
HttpProxyType::HTTPS(auth)
} else {
HttpProxyType::HTTP(auth)
}
}
pub(crate) async fn connect_http_proxy_stream(
stream: TcpStream,
http_proxy: HttpProxyType<'_>,
rpc_host: (&str, u16),
proxy_domain: &str,
) -> Result<Box<dyn AsyncReadWrite>> {
match http_proxy {
HttpProxyType::HTTP(auth) => http_connect(stream, rpc_host, auth).await,
HttpProxyType::HTTPS(auth) => https_connect(stream, rpc_host, auth, proxy_domain).await,
}
.context("error connecting to http/https proxy")
}
async fn http_connect<T>(
stream: T,
target: (&str, u16),
auth: Option<HttpProxyAuthorization<'_>>,
) -> Result<Box<dyn AsyncReadWrite>>
where
T: AsyncReadWrite,
{
let mut stream = BufStream::new(stream);
let request = make_request(target, auth);
stream.write_all(request.as_bytes()).await?;
stream.flush().await?;
check_response(&mut stream).await?;
Ok(Box::new(stream))
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
async fn https_connect<T>(
stream: T,
target: (&str, u16),
auth: Option<HttpProxyAuthorization<'_>>,
proxy_domain: &str,
) -> Result<Box<dyn AsyncReadWrite>>
where
T: AsyncReadWrite,
{
let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?);
let stream = tls_connector.connect(proxy_domain, stream).await?;
http_connect(stream, target, auth).await
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
async fn https_connect<T>(
stream: T,
target: (&str, u16),
auth: Option<HttpProxyAuthorization<'_>>,
proxy_domain: &str,
) -> Result<Box<dyn AsyncReadWrite>>
where
T: AsyncReadWrite,
{
let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain)
.context("Address resolution failed")?
.to_owned();
let tls_connector = TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config()));
let stream = tls_connector.connect(proxy_domain, stream).await?;
http_connect(stream, target, auth).await
}
fn make_request(target: (&str, u16), auth: Option<HttpProxyAuthorization<'_>>) -> String {
let (host, port) = target;
let mut request = format!(
"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\nProxy-Connection: Keep-Alive\r\n"
);
if let Some(HttpProxyAuthorization { username, password }) = auth {
let auth =
base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes());
let auth = format!("Proxy-Authorization: Basic {auth}\r\n");
request.push_str(&auth);
}
request.push_str("\r\n");
request
}
async fn check_response<T>(stream: &mut BufStream<T>) -> Result<()>
where
T: AsyncReadWrite,
{
let response = recv_response(stream).await?;
let mut dummy_headers = [EMPTY_HEADER; MAX_RESPONSE_HEADERS];
let mut parser = Response::new(&mut dummy_headers);
parser.parse(response.as_bytes())?;
match parser.code {
Some(code) => {
if code == 200 {
Ok(())
} else {
Err(anyhow::anyhow!(
"Proxy connection failed with HTTP code: {code}"
))
}
}
None => Err(anyhow::anyhow!(
"Proxy connection failed with no HTTP code: {}",
parser.reason.unwrap_or("Unknown reason")
)),
}
}
const MAX_RESPONSE_HEADER_LENGTH: usize = 4096;
const MAX_RESPONSE_HEADERS: usize = 16;
async fn recv_response<T>(stream: &mut BufStream<T>) -> Result<String>
where
T: AsyncReadWrite,
{
let mut response = String::new();
loop {
if stream.read_line(&mut response).await? == 0 {
return Err(anyhow::anyhow!("End of stream"));
}
if MAX_RESPONSE_HEADER_LENGTH < response.len() {
return Err(anyhow::anyhow!("Maximum response header length exceeded"));
}
if response.ends_with("\r\n\r\n") {
return Ok(response);
}
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::{HttpProxyAuthorization, HttpProxyType, parse_http_proxy};
#[test]
fn test_parse_http_proxy() {
let proxy = Url::parse("http://proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_http_proxy(scheme, &proxy);
assert!(matches!(version, HttpProxyType::HTTP(None)))
}
#[test]
fn test_parse_http_proxy_with_auth() {
let proxy = Url::parse("http://username:password@proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_http_proxy(scheme, &proxy);
assert!(matches!(
version,
HttpProxyType::HTTP(Some(HttpProxyAuthorization {
username: "username",
password: "password"
}))
))
}
}

View File

@@ -0,0 +1,226 @@
//! socks proxy
use anyhow::{Context as _, Result};
use http_client::Url;
use tokio::net::TcpStream;
use tokio_socks::{
IntoTargetAddr, TargetAddr,
tcp::{Socks4Stream, Socks5Stream},
};
use super::AsyncReadWrite;
/// Identification to a Socks V4 Proxy
pub(super) struct Socks4Identification<'a> {
user_id: &'a str,
}
/// Authorization to a Socks V5 Proxy
pub(super) struct Socks5Authorization<'a> {
username: &'a str,
password: &'a str,
}
/// Socks Proxy Protocol Version
///
/// V4 allows identification using a user_id
/// V5 allows authorization using a username and password
pub(super) enum SocksVersion<'a> {
V4 {
local_dns: bool,
identification: Option<Socks4Identification<'a>>,
},
V5 {
local_dns: bool,
authorization: Option<Socks5Authorization<'a>>,
},
}
pub(super) fn parse_socks_proxy<'t>(scheme: &str, proxy: &'t Url) -> SocksVersion<'t> {
if scheme.starts_with("socks4") {
let identification = match proxy.username() {
"" => None,
username => Some(Socks4Identification { user_id: username }),
};
SocksVersion::V4 {
local_dns: scheme != "socks4a",
identification,
}
} else {
let authorization = proxy.password().map(|password| Socks5Authorization {
username: proxy.username(),
password,
});
SocksVersion::V5 {
local_dns: scheme != "socks5h",
authorization,
}
}
}
pub(super) async fn connect_socks_proxy_stream(
stream: TcpStream,
socks_version: SocksVersion<'_>,
rpc_host: (&str, u16),
) -> Result<Box<dyn AsyncReadWrite>> {
let rpc_host = rpc_host
.into_target_addr()
.context("Failed to parse target addr")?;
let local_dns = match &socks_version {
SocksVersion::V4 { local_dns, .. } => local_dns,
SocksVersion::V5 { local_dns, .. } => local_dns,
};
let rpc_host = match (rpc_host, local_dns) {
(TargetAddr::Domain(domain, port), true) => {
let ip_addr = tokio::net::lookup_host((domain.as_ref(), port))
.await
.with_context(|| format!("Failed to lookup domain {}", domain))?
.next()
.ok_or_else(|| anyhow::anyhow!("Failed to lookup domain {}", domain))?;
TargetAddr::Ip(ip_addr)
}
(rpc_host, _) => rpc_host,
};
match socks_version {
SocksVersion::V4 {
identification: None,
..
} => {
let socks = Socks4Stream::connect_with_socket(stream, rpc_host)
.await
.context("error connecting to socks")?;
Ok(Box::new(socks))
}
SocksVersion::V4 {
identification: Some(Socks4Identification { user_id }),
..
} => {
let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id)
.await
.context("error connecting to socks")?;
Ok(Box::new(socks))
}
SocksVersion::V5 {
authorization: None,
..
} => {
let socks = Socks5Stream::connect_with_socket(stream, rpc_host)
.await
.context("error connecting to socks")?;
Ok(Box::new(socks))
}
SocksVersion::V5 {
authorization: Some(Socks5Authorization { username, password }),
..
} => {
let socks = Socks5Stream::connect_with_password_and_socket(
stream, rpc_host, username, password,
)
.await
.context("error connecting to socks")?;
Ok(Box::new(socks))
}
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::*;
#[test]
fn parse_socks4() {
let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V4 {
local_dns: true,
identification: None
}
))
}
#[test]
fn parse_socks4_with_identification() {
let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V4 {
local_dns: true,
identification: Some(Socks4Identification { user_id: "userid" })
}
))
}
#[test]
fn parse_socks4_with_remote_dns() {
let proxy = Url::parse("socks4a://proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V4 {
local_dns: false,
identification: None
}
))
}
#[test]
fn parse_socks5() {
let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V5 {
local_dns: true,
authorization: None
}
))
}
#[test]
fn parse_socks5_with_authorization() {
let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V5 {
local_dns: true,
authorization: Some(Socks5Authorization {
username: "username",
password: "password"
})
}
))
}
#[test]
fn parse_socks5_with_remote_dns() {
let proxy = Url::parse("socks5h://proxy.example.com:1080").unwrap();
let scheme = proxy.scheme();
let version = parse_socks_proxy(scheme, &proxy);
assert!(matches!(
version,
SocksVersion::V5 {
local_dns: false,
authorization: None
}
))
}
}

View File

@@ -0,0 +1,978 @@
mod event_coalescer;
use crate::TelemetrySettings;
use anyhow::{Context as _, Result};
use clock::SystemClock;
use fs::Fs;
use futures::channel::mpsc;
use futures::{Future, StreamExt};
use gpui::{App, AppContext as _, BackgroundExecutor, Task};
use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request};
use parking_lot::Mutex;
use regex::Regex;
use release_channel::ReleaseChannel;
use settings::{Settings, SettingsStore};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs::File;
use std::io::Write;
use std::sync::LazyLock;
use std::time::Instant;
use std::{env, mem, path::PathBuf, sync::Arc, time::Duration};
use telemetry_events::{AssistantEventData, AssistantPhase, Event, EventRequestBody, EventWrapper};
pub struct TelemetrySubscription {
pub historical_events: Result<HistoricalEvents>,
pub queued_events: Vec<EventWrapper>,
pub live_events: mpsc::UnboundedReceiver<EventWrapper>,
}
pub struct HistoricalEvents {
pub events: Vec<EventWrapper>,
pub parse_error_count: usize,
}
use util::ResultExt as _;
use worktree::{UpdatedEntriesSet, WorktreeId};
use self::event_coalescer::EventCoalescer;
pub struct Telemetry {
clock: Arc<dyn SystemClock>,
http_client: Arc<HttpClientWithUrl>,
executor: BackgroundExecutor,
state: Arc<Mutex<TelemetryState>>,
}
struct TelemetryState {
settings: TelemetrySettings,
system_id: Option<Arc<str>>, // Per system
installation_id: Option<Arc<str>>, // Per app installation (different for dev, nightly, preview, and stable)
session_id: Option<String>, // Per app launch
metrics_id: Option<Arc<str>>, // Per logged-in user
release_channel: Option<ReleaseChannel>,
architecture: &'static str,
events_queue: Vec<EventWrapper>,
flush_events_task: Option<Task<()>>,
log_file: Option<File>,
is_staff: Option<bool>,
first_event_date_time: Option<Instant>,
event_coalescer: EventCoalescer,
max_queue_size: usize,
worktrees_with_project_type_events_sent: HashSet<WorktreeId>,
os_name: String,
app_version: String,
os_version: Option<String>,
subscribers: Vec<mpsc::UnboundedSender<EventWrapper>>,
}
#[cfg(debug_assertions)]
const MAX_QUEUE_LEN: usize = 5;
#[cfg(not(debug_assertions))]
const MAX_QUEUE_LEN: usize = 50;
#[cfg(debug_assertions)]
const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(not(debug_assertions))]
const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5);
static ZED_CLIENT_CHECKSUM_SEED: LazyLock<Option<Vec<u8>>> = LazyLock::new(|| {
option_env!("ZED_CLIENT_CHECKSUM_SEED")
.map(|s| s.as_bytes().into())
.or_else(|| {
env::var("ZED_CLIENT_CHECKSUM_SEED")
.ok()
.map(|s| s.as_bytes().into())
})
});
pub static MINIDUMP_ENDPOINT: LazyLock<Option<String>> = LazyLock::new(|| {
option_env!("ZED_MINIDUMP_ENDPOINT")
.map(str::to_string)
.or_else(|| env::var("ZED_MINIDUMP_ENDPOINT").ok())
});
static DOTNET_PROJECT_FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap()
});
pub fn os_name() -> String {
#[cfg(target_os = "macos")]
{
"macOS".to_string()
}
#[cfg(target_os = "linux")]
{
format!("Linux {}", gpui::guess_compositor())
}
#[cfg(target_os = "freebsd")]
{
format!("FreeBSD {}", gpui::guess_compositor())
}
#[cfg(target_os = "windows")]
{
"Windows".to_string()
}
}
/// Note: This might do blocking IO! Only call from background threads
pub fn os_version() -> String {
cfg_select! {
feature = "test-support" => {
// MacOS branch in particular is quite slow, hence we ought to "avoid" it in tests.
"test binary".to_owned()
}
target_os = "macos" => {
static MACOS_VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(\s*\(Build [^)]*[0-9]\))").unwrap()
});
use objc2_foundation::NSProcessInfo;
let process_info = NSProcessInfo::processInfo();
let version_nsstring = process_info.operatingSystemVersionString();
// "Version 15.6.1 (Build 24G90)" -> "15.6.1 (Build 24G90)"
let version_string = version_nsstring.to_string().replace("Version ", "");
// "15.6.1 (Build 24G90)" -> "15.6.1"
// "26.0.0 (Build 25A5349a)" -> unchanged (Beta or Rapid Security Response; ends with letter)
MACOS_VERSION_REGEX
.replace_all(&version_string, "")
.to_string()
}
any(target_os = "linux", target_os = "freebsd") => {
use std::path::Path;
let content = if let Ok(file) = std::fs::read_to_string(&Path::new("/etc/os-release")) {
file
} else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) {
file
} else if let Ok(file) = std::fs::read_to_string(&Path::new("/var/run/os-release")) {
file
} else {
log::error!(
"Failed to load /etc/os-release, /usr/lib/os-release, or /var/run/os-release"
);
"".to_string()
};
let mut name = "unknown";
let mut version = "unknown";
for line in content.lines() {
match line.split_once('=') {
Some(("ID", val)) => name = val.trim_matches('"'),
Some(("VERSION_ID", val)) => version = val.trim_matches('"'),
_ => {}
}
}
format!("{} {}", name, version)
}
target_os = "windows" => {
let mut info = unsafe { std::mem::zeroed() };
let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) };
if status.is_ok() {
semver::Version::new(
info.dwMajorVersion as _,
info.dwMinorVersion as _,
info.dwBuildNumber as _,
)
.to_string()
} else {
"unknown".to_string()
}
}
}
}
impl Telemetry {
pub fn new(
clock: Arc<dyn SystemClock>,
client: Arc<HttpClientWithUrl>,
cx: &mut App,
) -> Arc<Self> {
let state = Arc::new(Mutex::new(TelemetryState {
settings: *TelemetrySettings::get_global(cx),
architecture: env::consts::ARCH,
release_channel: ReleaseChannel::try_global(cx),
system_id: None,
installation_id: None,
session_id: None,
metrics_id: None,
events_queue: Vec::new(),
flush_events_task: None,
log_file: None,
is_staff: None,
first_event_date_time: None,
event_coalescer: EventCoalescer::new(clock.clone()),
max_queue_size: MAX_QUEUE_LEN,
worktrees_with_project_type_events_sent: HashSet::new(),
os_version: None,
os_name: os_name(),
app_version: release_channel::AppVersion::global(cx).to_string(),
subscribers: Vec::new(),
}));
cx.background_spawn({
let state = state.clone();
let os_version = os_version();
state.lock().os_version = Some(os_version);
async move {
if let Some(tempfile) = File::create(Self::log_file_path()).ok() {
state.lock().log_file = Some(tempfile);
}
}
})
.detach();
cx.observe_global::<SettingsStore>({
let state = state.clone();
move |cx| {
let mut state = state.lock();
state.settings = *TelemetrySettings::get_global(cx);
}
})
.detach();
let this = Arc::new(Self {
clock,
http_client: client,
executor: cx.background_executor().clone(),
state,
});
let (tx, mut rx) = mpsc::unbounded();
::telemetry::init(tx);
cx.background_spawn({
let this = Arc::downgrade(&this);
async move {
if cfg!(feature = "test-support") {
return;
}
while let Some(event) = rx.next().await {
let Some(state) = this.upgrade() else { break };
state.report_event(Event::Flexible(event))
}
}
})
.detach();
// We should only ever have one instance of Telemetry, leak the subscription to keep it alive
// rather than store in TelemetryState, complicating spawn as subscriptions are not Send
std::mem::forget(cx.on_app_quit({
let this = this.clone();
move |_| this.shutdown_telemetry()
}));
this
}
#[cfg(any(test, feature = "test-support"))]
fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
Task::ready(())
}
// Skip calling this function in tests.
// TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings
#[cfg(not(any(test, feature = "test-support")))]
fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
telemetry::event!("App Closed");
// TODO: close final edit period and make sure it's sent
Task::ready(())
}
pub fn log_file_path() -> PathBuf {
paths::logs_dir().join("telemetry.log")
}
pub async fn subscribe_with_history(
self: &Arc<Self>,
fs: Arc<dyn Fs>,
) -> TelemetrySubscription {
let historical_events = self.read_log_file(fs).await;
let mut state = self.state.lock();
let queued_events: Vec<EventWrapper> = state.events_queue.clone();
let (tx, rx) = mpsc::unbounded();
state.subscribers.push(tx);
drop(state);
TelemetrySubscription {
historical_events,
queued_events,
live_events: rx,
}
}
async fn read_log_file(self: &Arc<Self>, fs: Arc<dyn Fs>) -> anyhow::Result<HistoricalEvents> {
const MAX_LOG_READ: usize = 5 * 1024 * 1024;
let path = Self::log_file_path();
let content = fs
.load_bytes(&path)
.await
.with_context(|| format!("failed to load telemetry log from {:?}", path))?;
let start_offset = if content.len() > MAX_LOG_READ {
let skip = content.len() - MAX_LOG_READ;
content[skip..]
.iter()
.position(|&b| b == b'\n')
.map(|pos| skip + pos + 1)
.unwrap_or(skip)
} else {
0
};
let content_str = std::str::from_utf8(&content[start_offset..])
.context("telemetry log file contains invalid UTF-8")?;
let mut events = Vec::new();
let mut parse_error_count = 0;
for line in content_str.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<EventWrapper>(line) {
Ok(event) => events.push(event),
Err(_) => parse_error_count += 1,
}
}
Ok(HistoricalEvents {
events,
parse_error_count,
})
}
pub fn has_checksum_seed(&self) -> bool {
ZED_CLIENT_CHECKSUM_SEED.is_some()
}
pub fn start(
self: &Arc<Self>,
system_id: Option<String>,
installation_id: Option<String>,
session_id: String,
cx: &App,
) {
let mut state = self.state.lock();
state.system_id = system_id.map(|id| id.into());
state.installation_id = installation_id.map(|id| id.into());
state.session_id = Some(session_id);
state.app_version = release_channel::AppVersion::global(cx).to_string();
state.os_name = os_name();
}
pub fn metrics_enabled(self: &Arc<Self>) -> bool {
self.state.lock().settings.metrics
}
pub fn diagnostics_enabled(self: &Arc<Self>) -> bool {
self.state.lock().settings.diagnostics
}
pub fn set_authenticated_user_info(
self: &Arc<Self>,
metrics_id: Option<String>,
is_staff: bool,
) {
let mut state = self.state.lock();
if !state.settings.metrics {
return;
}
let metrics_id: Option<Arc<str>> = metrics_id.map(|id| id.into());
state.metrics_id.clone_from(&metrics_id);
state.is_staff = Some(is_staff);
drop(state);
}
pub fn report_assistant_event(self: &Arc<Self>, event: AssistantEventData) {
let event_type = match event.phase {
AssistantPhase::Response => "Assistant Responded",
AssistantPhase::Invoked => "Assistant Invoked",
AssistantPhase::Accepted => "Assistant Response Accepted",
AssistantPhase::Rejected => "Assistant Response Rejected",
};
telemetry::event!(
event_type,
conversation_id = event.conversation_id,
kind = event.kind,
phase = event.phase,
message_id = event.message_id,
model = event.model,
model_provider = event.model_provider,
response_latency = event.response_latency,
error_message = event.error_message,
language_name = event.language_name,
);
}
pub fn log_edit_event(self: &Arc<Self>, environment: &'static str, is_via_ssh: bool) {
static LAST_EVENT_TIME: Mutex<Option<Instant>> = Mutex::new(None);
let mut state = self.state.lock();
let period_data = state.event_coalescer.log_event(environment);
drop(state);
if let Some(mut last_event) = LAST_EVENT_TIME.try_lock() {
let current_time = std::time::Instant::now();
let last_time = last_event.get_or_insert(current_time);
if current_time.duration_since(*last_time) > Duration::from_secs(60 * 10) {
*last_time = current_time;
} else {
return;
}
if let Some((start, end, environment)) = period_data {
let duration = end
.saturating_duration_since(start)
.min(Duration::from_secs(60 * 60 * 24))
.as_millis() as i64;
telemetry::event!(
"Editor Edited",
duration = duration,
environment = environment,
is_via_ssh = is_via_ssh
);
}
}
}
pub fn report_discovered_project_type_events(
self: &Arc<Self>,
worktree_id: WorktreeId,
updated_entries_set: &UpdatedEntriesSet,
) {
let Some(project_types) = self.detect_project_types(worktree_id, updated_entries_set)
else {
return;
};
for project_type in project_types {
telemetry::event!("Project Opened", project_type = project_type);
}
}
fn detect_project_types(
self: &Arc<Self>,
worktree_id: WorktreeId,
updated_entries_set: &UpdatedEntriesSet,
) -> Option<Vec<String>> {
let mut state = self.state.lock();
if state
.worktrees_with_project_type_events_sent
.contains(&worktree_id)
{
return None;
}
let mut project_types: HashSet<&str> = HashSet::new();
for (path, _, _) in updated_entries_set.iter() {
let Some(file_name) = path.file_name() else {
continue;
};
let project_type = match file_name {
"pnpm-lock.yaml" => Some("pnpm"),
"yarn.lock" => Some("yarn"),
"package.json" => Some("node"),
_ if DOTNET_PROJECT_FILES_REGEX.is_match(file_name) => Some("dotnet"),
_ => None,
};
if let Some(project_type) = project_type {
project_types.insert(project_type);
};
}
if !project_types.is_empty() {
state
.worktrees_with_project_type_events_sent
.insert(worktree_id);
}
let mut project_types: Vec<_> = project_types.into_iter().map(String::from).collect();
project_types.sort();
Some(project_types)
}
fn report_event(self: &Arc<Self>, mut event: Event) {
let mut state = self.state.lock();
// RUST_LOG=telemetry=trace to debug telemetry events
log::trace!(target: "telemetry", "{:?}", event);
if !state.settings.metrics {
return;
}
match &mut event {
Event::Flexible(event) => event
.event_properties
.insert("event_source".into(), "zed".into()),
};
if state.flush_events_task.is_none() {
let this = self.clone();
state.flush_events_task = Some(self.executor.spawn(async move {
this.executor.timer(FLUSH_INTERVAL).await;
this.flush_events().detach();
}));
}
let date_time = self.clock.utc_now();
let milliseconds_since_first_event = match state.first_event_date_time {
Some(first_event_date_time) => date_time
.saturating_duration_since(first_event_date_time)
.min(Duration::from_secs(60 * 60 * 24))
.as_millis() as i64,
None => {
state.first_event_date_time = Some(date_time);
0
}
};
let signed_in = state.metrics_id.is_some();
let event_wrapper = EventWrapper {
signed_in,
milliseconds_since_first_event,
event,
};
state
.subscribers
.retain(|tx| tx.unbounded_send(event_wrapper.clone()).is_ok());
state.events_queue.push(event_wrapper);
if state.installation_id.is_some() && state.events_queue.len() >= state.max_queue_size {
drop(state);
self.flush_events().detach();
}
}
pub fn metrics_id(self: &Arc<Self>) -> Option<Arc<str>> {
self.state.lock().metrics_id.clone()
}
pub fn system_id(self: &Arc<Self>) -> Option<Arc<str>> {
self.state.lock().system_id.clone()
}
pub fn installation_id(self: &Arc<Self>) -> Option<Arc<str>> {
self.state.lock().installation_id.clone()
}
pub fn is_staff(self: &Arc<Self>) -> Option<bool> {
self.state.lock().is_staff
}
fn build_request(
self: &Arc<Self>,
// We take in the JSON bytes buffer so we can reuse the existing allocation.
mut json_bytes: Vec<u8>,
event_request: &EventRequestBody,
) -> Result<Request<AsyncBody>> {
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, event_request)?;
let checksum = calculate_json_checksum(&json_bytes).unwrap_or_default();
Ok(Request::builder()
.method(Method::POST)
.uri(
self.http_client
.build_zed_api_url("/telemetry/events", &[])?
.as_ref(),
)
.header("Content-Type", "application/json")
.header("x-zed-checksum", checksum)
.body(json_bytes.into())?)
}
pub async fn flush_events_inner(self: &Arc<Self>) -> Result<()> {
let (json_bytes, request_body) = {
let mut state = self.state.lock();
state.first_event_date_time = None;
let events = mem::take(&mut state.events_queue);
state.flush_events_task.take();
if events.is_empty() {
return Ok(());
}
let mut json_bytes = Vec::new();
if let Some(file) = &mut state.log_file {
for event in &events {
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, event)?;
file.write_all(&json_bytes)?;
file.write_all(b"\n")?;
}
}
(
json_bytes,
EventRequestBody {
system_id: state.system_id.as_deref().map(Into::into),
installation_id: state.installation_id.as_deref().map(Into::into),
session_id: state.session_id.clone(),
metrics_id: state.metrics_id.as_deref().map(Into::into),
is_staff: state.is_staff,
app_version: state.app_version.clone(),
os_name: state.os_name.clone(),
os_version: state.os_version.clone(),
architecture: state.architecture.to_string(),
release_channel: state
.release_channel
.map(|channel| channel.display_name().to_owned()),
events,
},
)
};
let request = self.build_request(json_bytes, &request_body)?;
let response = self.http_client.send(request).await?;
if response.status() != 200 {
log::error!("Failed to send events: HTTP {:?}", response.status());
}
anyhow::Ok(())
}
pub fn flush_events(self: &Arc<Self>) -> Task<()> {
let this = self.clone();
self.executor.spawn(async move {
this.flush_events_inner().await.log_err();
})
}
}
pub fn calculate_json_checksum(json: &impl AsRef<[u8]>) -> Option<String> {
let checksum_seed = ZED_CLIENT_CHECKSUM_SEED.as_ref()?;
let mut summer = Sha256::new();
summer.update(checksum_seed);
summer.update(json);
summer.update(checksum_seed);
let mut checksum = String::new();
for byte in summer.finalize().as_slice() {
use std::fmt::Write;
write!(&mut checksum, "{:02x}", byte).unwrap();
}
Some(checksum)
}
#[cfg(test)]
mod tests {
use super::*;
use clock::FakeSystemClock;
use gpui::TestAppContext;
use http_client::FakeHttpClient;
use std::collections::HashMap;
use telemetry_events::FlexibleEvent;
use util::rel_path::RelPath;
use worktree::{PathChange, ProjectEntryId, WorktreeId};
#[gpui::test]
async fn test_telemetry_flush_on_max_queue_size(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let system_id = Some("system_id".to_string());
let installation_id = Some("installation_id".to_string());
let session_id = "session_id".to_string();
let (telemetry, first_date_time, event) = cx.update(|cx| {
let telemetry = Telemetry::new(clock.clone(), http, cx);
telemetry.state.lock().max_queue_size = 4;
telemetry.start(system_id, installation_id, session_id, cx);
assert!(is_empty_state(&telemetry));
let first_date_time = clock.utc_now();
let event_properties = HashMap::from_iter([(
"test_key".to_string(),
serde_json::Value::String("test_value".to_string()),
)]);
let event = FlexibleEvent {
event_type: "test".to_string(),
event_properties,
};
(telemetry, first_date_time, event)
});
cx.update(|_cx| {
telemetry.report_event(Event::Flexible(event.clone()));
assert_eq!(telemetry.state.lock().events_queue.len(), 1);
assert!(telemetry.state.lock().flush_events_task.is_some());
assert_eq!(
telemetry.state.lock().first_event_date_time,
Some(first_date_time)
);
clock.advance(Duration::from_millis(100));
telemetry.report_event(Event::Flexible(event.clone()));
assert_eq!(telemetry.state.lock().events_queue.len(), 2);
assert!(telemetry.state.lock().flush_events_task.is_some());
assert_eq!(
telemetry.state.lock().first_event_date_time,
Some(first_date_time)
);
clock.advance(Duration::from_millis(100));
telemetry.report_event(Event::Flexible(event.clone()));
assert_eq!(telemetry.state.lock().events_queue.len(), 3);
assert!(telemetry.state.lock().flush_events_task.is_some());
assert_eq!(
telemetry.state.lock().first_event_date_time,
Some(first_date_time)
);
clock.advance(Duration::from_millis(100));
// Adding a 4th event should cause a flush
telemetry.report_event(Event::Flexible(event));
});
// Run the spawned flush task to completion
executor.run_until_parked();
cx.update(|_cx| {
assert!(is_empty_state(&telemetry));
});
}
#[gpui::test]
async fn test_telemetry_flush_on_flush_interval(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let system_id = Some("system_id".to_string());
let installation_id = Some("installation_id".to_string());
let session_id = "session_id".to_string();
cx.update(|cx| {
let telemetry = Telemetry::new(clock.clone(), http, cx);
telemetry.state.lock().max_queue_size = 4;
telemetry.start(system_id, installation_id, session_id, cx);
assert!(is_empty_state(&telemetry));
let first_date_time = clock.utc_now();
let event_properties = HashMap::from_iter([(
"test_key".to_string(),
serde_json::Value::String("test_value".to_string()),
)]);
let event = FlexibleEvent {
event_type: "test".to_string(),
event_properties,
};
telemetry.report_event(Event::Flexible(event));
assert_eq!(telemetry.state.lock().events_queue.len(), 1);
assert!(telemetry.state.lock().flush_events_task.is_some());
assert_eq!(
telemetry.state.lock().first_event_date_time,
Some(first_date_time)
);
let duration = Duration::from_millis(1);
// Test 1 millisecond before the flush interval limit is met
executor.advance_clock(FLUSH_INTERVAL - duration);
assert!(!is_empty_state(&telemetry));
// Test the exact moment the flush interval limit is met
executor.advance_clock(duration);
assert!(is_empty_state(&telemetry));
});
}
#[gpui::test]
fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
let worktree_id = 1;
// Scan of empty worktree finds nothing
test_project_discovery_helper(telemetry.clone(), vec![], Some(vec![]), worktree_id);
// Files added, second scan of worktree 1 finds project type
test_project_discovery_helper(
telemetry.clone(),
vec!["package.json"],
Some(vec!["node"]),
worktree_id,
);
// Third scan of worktree does not double report, as we already reported
test_project_discovery_helper(telemetry, vec!["package.json"], None, worktree_id);
}
#[gpui::test]
fn test_pnpm_project_discovery(cx: &mut gpui::TestAppContext) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
test_project_discovery_helper(
telemetry,
vec!["package.json", "pnpm-lock.yaml"],
Some(vec!["node", "pnpm"]),
1,
);
}
#[gpui::test]
fn test_yarn_project_discovery(cx: &mut gpui::TestAppContext) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
test_project_discovery_helper(
telemetry,
vec!["package.json", "yarn.lock"],
Some(vec!["node", "yarn"]),
1,
);
}
#[gpui::test]
fn test_dotnet_project_discovery(cx: &mut gpui::TestAppContext) {
init_test(cx);
let clock = Arc::new(FakeSystemClock::new());
let http = FakeHttpClient::with_200_response();
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
// Using different worktrees, as production code blocks from reporting a
// project type for the same worktree multiple times
test_project_discovery_helper(
telemetry.clone(),
vec!["global.json"],
Some(vec!["dotnet"]),
1,
);
test_project_discovery_helper(
telemetry.clone(),
vec!["Directory.Build.props"],
Some(vec!["dotnet"]),
2,
);
test_project_discovery_helper(
telemetry.clone(),
vec!["file.csproj"],
Some(vec!["dotnet"]),
3,
);
test_project_discovery_helper(
telemetry.clone(),
vec!["file.fsproj"],
Some(vec!["dotnet"]),
4,
);
test_project_discovery_helper(
telemetry.clone(),
vec!["file.vbproj"],
Some(vec!["dotnet"]),
5,
);
test_project_discovery_helper(telemetry.clone(), vec!["file.sln"], Some(vec!["dotnet"]), 6);
// Each worktree should only send a single project type event, even when
// encountering multiple files associated with that project type
test_project_discovery_helper(
telemetry,
vec!["global.json", "Directory.Build.props"],
Some(vec!["dotnet"]),
7,
);
}
// TODO:
// Test settings
// Update FakeHTTPClient to keep track of the number of requests and assert on it
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
fn is_empty_state(telemetry: &Telemetry) -> bool {
telemetry.state.lock().events_queue.is_empty()
&& telemetry.state.lock().flush_events_task.is_none()
&& telemetry.state.lock().first_event_date_time.is_none()
}
fn test_project_discovery_helper(
telemetry: Arc<Telemetry>,
file_paths: Vec<&str>,
expected_project_types: Option<Vec<&str>>,
worktree_id_num: usize,
) {
let worktree_id = WorktreeId::from_usize(worktree_id_num);
let entries: Vec<_> = file_paths
.into_iter()
.enumerate()
.filter_map(|(i, path)| {
Some((
Arc::from(RelPath::unix(path).ok()?),
ProjectEntryId::from_proto(i as u64 + 1),
PathChange::Added,
))
})
.collect();
let updated_entries: UpdatedEntriesSet = Arc::from(entries.as_slice());
let detected_project_types = telemetry.detect_project_types(worktree_id, &updated_entries);
let expected_project_types =
expected_project_types.map(|types| types.iter().map(|&t| t.to_string()).collect());
assert_eq!(detected_project_types, expected_project_types);
}
}

View File

@@ -0,0 +1,283 @@
use std::time;
use std::{sync::Arc, time::Instant};
use clock::SystemClock;
const COALESCE_TIMEOUT: time::Duration = time::Duration::from_secs(20);
const SIMULATED_DURATION_FOR_SINGLE_EVENT: time::Duration = time::Duration::from_millis(1);
#[derive(Debug, PartialEq)]
struct PeriodData {
environment: &'static str,
start: Instant,
end: Option<Instant>,
}
pub struct EventCoalescer {
clock: Arc<dyn SystemClock>,
state: Option<PeriodData>,
}
impl EventCoalescer {
pub fn new(clock: Arc<dyn SystemClock>) -> Self {
Self { clock, state: None }
}
pub fn log_event(
&mut self,
environment: &'static str,
) -> Option<(Instant, Instant, &'static str)> {
let log_time = self.clock.utc_now();
let Some(state) = &mut self.state else {
self.state = Some(PeriodData {
start: log_time,
end: None,
environment,
});
return None;
};
let period_end = state
.end
.unwrap_or(state.start + SIMULATED_DURATION_FOR_SINGLE_EVENT);
let within_timeout = log_time - period_end < COALESCE_TIMEOUT;
let environment_is_same = state.environment == environment;
let should_coaelesce = !within_timeout || !environment_is_same;
if should_coaelesce {
let previous_environment = state.environment;
let original_start = state.start;
state.start = log_time;
state.end = None;
state.environment = environment;
return Some((
original_start,
if within_timeout { log_time } else { period_end },
previous_environment,
));
}
state.end = Some(log_time);
None
}
}
#[cfg(test)]
mod tests {
use clock::FakeSystemClock;
use super::*;
#[test]
fn test_same_context_exceeding_timeout() {
let clock = Arc::new(FakeSystemClock::new());
let environment_1 = "environment_1";
let mut event_coalescer = EventCoalescer::new(clock.clone());
assert_eq!(event_coalescer.state, None);
let period_start = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: None,
environment: environment_1,
})
);
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
// Ensure that many calls within the timeout don't start a new period
for _ in 0..100 {
clock.advance(within_timeout_adjustment);
let period_data = event_coalescer.log_event(environment_1);
let period_end = clock.utc_now();
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: Some(period_end),
environment: environment_1,
})
);
}
let period_end = clock.utc_now();
let exceed_timeout_adjustment = COALESCE_TIMEOUT * 2;
// Logging an event exceeding the timeout should start a new period
clock.advance(exceed_timeout_adjustment);
let new_period_start = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: new_period_start,
end: None,
environment: environment_1,
})
);
}
#[test]
fn test_different_environment_under_timeout() {
let clock = Arc::new(FakeSystemClock::new());
let environment_1 = "environment_1";
let mut event_coalescer = EventCoalescer::new(clock.clone());
assert_eq!(event_coalescer.state, None);
let period_start = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: None,
environment: environment_1,
})
);
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
clock.advance(within_timeout_adjustment);
let period_end = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: Some(period_end),
environment: environment_1,
})
);
clock.advance(within_timeout_adjustment);
// Logging an event within the timeout but with a different environment should start a new period
let period_end = clock.utc_now();
let environment_2 = "environment_2";
let period_data = event_coalescer.log_event(environment_2);
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_end,
end: None,
environment: environment_2,
})
);
}
#[test]
fn test_switching_environment_while_within_timeout() {
let clock = Arc::new(FakeSystemClock::new());
let environment_1 = "environment_1";
let mut event_coalescer = EventCoalescer::new(clock.clone());
assert_eq!(event_coalescer.state, None);
let period_start = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: None,
environment: environment_1,
})
);
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
clock.advance(within_timeout_adjustment);
let period_end = clock.utc_now();
let environment_2 = "environment_2";
let period_data = event_coalescer.log_event(environment_2);
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_end,
end: None,
environment: environment_2,
})
);
}
// 0 20 40 60
// |-------------------|-------------------|-------------------|-------------------
// |--------|----------env change
// |-------------------
// |period_start |period_end
// |new_period_start
#[test]
fn test_switching_environment_while_exceeding_timeout() {
let clock = Arc::new(FakeSystemClock::new());
let environment_1 = "environment_1";
let mut event_coalescer = EventCoalescer::new(clock.clone());
assert_eq!(event_coalescer.state, None);
let period_start = clock.utc_now();
let period_data = event_coalescer.log_event(environment_1);
assert_eq!(period_data, None);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_start,
end: None,
environment: environment_1,
})
);
let exceed_timeout_adjustment = COALESCE_TIMEOUT * 2;
clock.advance(exceed_timeout_adjustment);
let period_end = clock.utc_now();
let environment_2 = "environment_2";
let period_data = event_coalescer.log_event(environment_2);
assert_eq!(
period_data,
Some((
period_start,
period_start + SIMULATED_DURATION_FOR_SINGLE_EVENT,
environment_1
))
);
assert_eq!(
event_coalescer.state,
Some(PeriodData {
start: period_end,
end: None,
environment: environment_2,
})
);
}
// 0 20 40 60
// |-------------------|-------------------|-------------------|-------------------
// |--------|----------------------------------------env change
// |-------------------|
// |period_start |period_end
// |new_period_start
}

271
crates/client/src/test.rs Normal file
View File

@@ -0,0 +1,271 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use anyhow::{Context as _, Result, anyhow};
use cloud_api_client::{
AuthenticatedUser, GetAuthenticatedUserResponse, KnownOrUnknown, Plan, PlanInfo,
};
use cloud_llm_client::{CurrentUsage, UsageData, UsageLimit};
use futures::{StreamExt, stream::BoxStream};
use gpui::{AppContext as _, TestAppContext};
use http_client::{AsyncBody, Method, Request, http};
use parking_lot::Mutex;
use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto};
use crate::{Client, Connection, Credentials, EstablishConnectionError};
pub struct FakeServer {
peer: Arc<Peer>,
state: Arc<Mutex<FakeServerState>>,
}
#[derive(Default)]
struct FakeServerState {
incoming: Option<BoxStream<'static, Box<dyn proto::AnyTypedEnvelope>>>,
connection_id: Option<ConnectionId>,
forbid_connections: bool,
auth_count: usize,
access_token: usize,
}
impl FakeServer {
pub async fn for_client(
client_user_id: u64,
client: &Arc<Client>,
cx: &TestAppContext,
) -> Self {
let server = Self {
peer: Peer::new(0),
state: Default::default(),
};
client.http_client().as_fake().replace_handler({
let state = server.state.clone();
move |old_handler, req| {
let state = state.clone();
let old_handler = old_handler.clone();
async move {
match (req.method(), req.uri().path()) {
(&Method::GET, "/client/users/me") => {
let credentials = parse_authorization_header(&req);
if credentials
!= Some(Credentials {
user_id: client_user_id,
access_token: state.lock().access_token.to_string(),
})
{
return Ok(http_client::Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap());
}
Ok(http_client::Response::builder()
.status(200)
.body(
serde_json::to_string(&make_get_authenticated_user_response(
client_user_id as i32,
format!("user-{client_user_id}"),
))
.unwrap()
.into(),
)
.unwrap())
}
_ => old_handler(req).await,
}
}
}
});
client
.override_authenticate({
let state = Arc::downgrade(&server.state);
move |cx| {
let state = state.clone();
cx.spawn(async move |_| {
let state = state.upgrade().context("server dropped")?;
let mut state = state.lock();
state.auth_count += 1;
let access_token = state.access_token.to_string();
Ok(Credentials {
user_id: client_user_id,
access_token,
})
})
}
})
.override_establish_connection({
let peer = Arc::downgrade(&server.peer);
let state = Arc::downgrade(&server.state);
move |credentials, cx| {
let peer = peer.clone();
let state = state.clone();
let credentials = credentials.clone();
cx.spawn(async move |cx| {
let state = state.upgrade().context("server dropped")?;
let peer = peer.upgrade().context("server dropped")?;
if state.lock().forbid_connections {
Err(EstablishConnectionError::Other(anyhow!(
"server is forbidding connections"
)))?
}
if credentials
!= (Credentials {
user_id: client_user_id,
access_token: state.lock().access_token.to_string(),
})
{
Err(EstablishConnectionError::Unauthorized)?
}
let (client_conn, server_conn, _) =
Connection::in_memory(cx.background_executor().clone());
let (connection_id, io, incoming) =
peer.add_test_connection(server_conn, cx.background_executor().clone());
cx.background_spawn(io).detach();
{
let mut state = state.lock();
state.connection_id = Some(connection_id);
state.incoming = Some(incoming);
}
peer.send(
connection_id,
proto::Hello {
peer_id: Some(connection_id.into()),
},
)
.unwrap();
Ok(client_conn)
})
}
});
client
.connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
server
}
pub fn disconnect(&self) {
if self.state.lock().connection_id.is_some() {
self.peer.disconnect(self.connection_id());
let mut state = self.state.lock();
state.connection_id.take();
state.incoming.take();
}
}
pub fn auth_count(&self) -> usize {
self.state.lock().auth_count
}
pub fn roll_access_token(&self) {
self.state.lock().access_token += 1;
}
pub fn forbid_connections(&self) {
self.state.lock().forbid_connections = true;
}
pub fn allow_connections(&self) {
self.state.lock().forbid_connections = false;
}
pub fn send<T: proto::EnvelopedMessage>(&self, message: T) {
self.peer.send(self.connection_id(), message).unwrap();
}
#[allow(clippy::await_holding_lock)]
pub async fn receive<M: proto::EnvelopedMessage>(&self) -> Result<TypedEnvelope<M>> {
let message = self
.state
.lock()
.incoming
.as_mut()
.expect("not connected")
.next()
.await
.context("other half hung up")?;
let type_name = message.payload_type_name();
let message = message.into_any();
if message.is::<TypedEnvelope<M>>() {
return Ok(*message.downcast().unwrap());
}
panic!(
"fake server received unexpected message type: {:?}",
type_name
);
}
pub fn respond<T: proto::RequestMessage>(&self, receipt: Receipt<T>, response: T::Response) {
self.peer.respond(receipt, response).unwrap()
}
fn connection_id(&self) -> ConnectionId {
self.state.lock().connection_id.expect("not connected")
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
self.disconnect();
}
}
pub fn parse_authorization_header(req: &Request<AsyncBody>) -> Option<Credentials> {
let mut auth_header = req
.headers()
.get(http::header::AUTHORIZATION)?
.to_str()
.ok()?
.split_whitespace();
let user_id = auth_header.next()?.parse().ok()?;
let access_token = auth_header.next()?;
Some(Credentials {
user_id,
access_token: access_token.to_string(),
})
}
pub fn make_get_authenticated_user_response(
user_id: i32,
github_login: String,
) -> GetAuthenticatedUserResponse {
GetAuthenticatedUserResponse {
user: AuthenticatedUser {
id: user_id,
metrics_id: format!("metrics-id-{user_id}"),
avatar_url: "".to_string(),
github_login,
name: None,
is_staff: false,
accepted_tos_at: None,
has_connected_to_collab_once: false,
},
feature_flags: vec![],
organizations: vec![],
default_organization_id: None,
plans_by_organization: BTreeMap::new(),
configuration_by_organization: BTreeMap::new(),
plan: PlanInfo {
plan: KnownOrUnknown::Known(Plan::ZedPro),
subscription_period: None,
usage: CurrentUsage {
edit_predictions: UsageData {
used: 250,
limit: UsageLimit::Unlimited,
},
},
trial_started_at: None,
is_account_too_young: false,
has_overdue_invoices: false,
},
}
}

1101
crates/client/src/user.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
//! Contains helper functions for constructing URLs to various Zed-related pages.
//!
//! These URLs will adapt to the configured server URL in order to construct
//! links appropriate for the environment (e.g., by linking to a local copy of
//! zed.dev in development).
use gpui::App;
use settings::Settings;
use crate::ClientSettings;
fn server_url(cx: &App) -> &str {
&ClientSettings::get_global(cx).server_url
}
/// Returns the URL to the account page on zed.dev.
pub fn account_url(cx: &App) -> String {
format!("{server_url}/account", server_url = server_url(cx))
}
/// Returns the URL to the start trial page on zed.dev.
pub fn start_trial_url(cx: &App) -> String {
format!(
"{server_url}/account/start-trial",
server_url = server_url(cx)
)
}
/// Returns the URL to the upgrade page on zed.dev.
pub fn upgrade_to_zed_pro_url(cx: &App) -> String {
format!("{server_url}/account/upgrade", server_url = server_url(cx))
}
/// Returns the URL to Zed's terms of service.
pub fn terms_of_service(cx: &App) -> String {
format!("{server_url}/terms-of-service", server_url = server_url(cx))
}
/// Returns the URL to Zed AI's privacy and security docs.
pub fn ai_privacy_and_security(cx: &App) -> String {
format!(
"{server_url}/docs/ai/privacy-and-security",
server_url = server_url(cx)
)
}
/// Returns the URL to Zed's edit prediction documentation.
pub fn edit_prediction_docs(cx: &App) -> String {
format!(
"{server_url}/docs/ai/edit-prediction",
server_url = server_url(cx)
)
}
/// Returns the URL to Zed's ACP registry blog post.
pub fn acp_registry_blog(cx: &App) -> String {
format!(
"{server_url}/blog/acp-registry",
server_url = server_url(cx)
)
}
/// Returns the URL to Zed's Parallel Agents blog post.
pub fn parallel_agents_blog(cx: &App) -> String {
format!("{server_url}/blog", server_url = server_url(cx))
}
pub fn shared_agent_thread_url(session_id: &str) -> String {
format!("zed://agent/shared/{}", session_id)
}