Merge pull request #1635 from zed-industries/new-signup-flow

Implement APIs for new signup flow
This commit is contained in:
Max Brunsfeld 2022-09-28 10:08:12 -07:00 committed by GitHub
commit 5d8fe33bd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 2571 additions and 1287 deletions

View file

@ -56,6 +56,7 @@ jobs:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }}
steps:
- name: Install Rust
run: |

21
Cargo.lock generated
View file

@ -945,6 +945,7 @@ dependencies = [
"async-recursion",
"async-tungstenite",
"collections",
"db",
"futures",
"gpui",
"image",
@ -955,13 +956,16 @@ dependencies = [
"postage",
"rand 0.8.5",
"rpc",
"serde",
"smol",
"sum_tree",
"tempfile",
"thiserror",
"time 0.3.11",
"tiny_http",
"url",
"util",
"uuid 1.1.2",
]
[[package]]
@ -1503,6 +1507,19 @@ dependencies = [
"matches",
]
[[package]]
name = "db"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"collections",
"gpui",
"parking_lot 0.11.2",
"rocksdb",
"tempdir",
]
[[package]]
name = "deflate"
version = "0.8.6"
@ -3949,6 +3966,7 @@ dependencies = [
"client",
"clock",
"collections",
"db",
"fsevent",
"futures",
"fuzzy",
@ -6334,6 +6352,9 @@ name = "uuid"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
dependencies = [
"getrandom 0.2.7",
]
[[package]]
name = "valuable"

View file

@ -12,6 +12,7 @@ test-support = ["collections/test-support", "gpui/test-support", "rpc/test-suppo
[dependencies]
collections = { path = "../collections" }
db = { path = "../db" }
gpui = { path = "../gpui" }
util = { path = "../util" }
rpc = { path = "../rpc" }
@ -31,7 +32,10 @@ smol = "1.2.5"
thiserror = "1.0.29"
time = { version = "0.3", features = ["serde", "serde-well-known"] }
tiny_http = "0.8"
uuid = { version = "1.1.2", features = ["v4"] }
url = "2.2"
serde = { version = "*", features = ["derive"] }
tempfile = "3"
[dev-dependencies]
collections = { path = "../collections", features = ["test-support"] }

View file

@ -601,7 +601,7 @@ mod tests {
let user_id = 5;
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = cx.update(|cx| Client::new(http_client.clone(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await;
Channel::init(&client);

View file

@ -3,6 +3,7 @@ pub mod test;
pub mod channel;
pub mod http;
pub mod telemetry;
pub mod user;
use anyhow::{anyhow, Context, Result};
@ -11,10 +12,14 @@ use async_tungstenite::tungstenite::{
error::Error as WebsocketError,
http::{Request, StatusCode},
};
use db::Db;
use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
use gpui::{
actions, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AsyncAppContext,
Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle,
actions,
serde_json::{json, Value},
AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext,
AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext,
ViewHandle,
};
use http::HttpClient;
use lazy_static::lazy_static;
@ -28,9 +33,11 @@ use std::{
convert::TryFrom,
fmt::Write as _,
future::Future,
path::PathBuf,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use telemetry::Telemetry;
use thiserror::Error;
use url::Url;
use util::{ResultExt, TryFutureExt};
@ -49,13 +56,29 @@ lazy_static! {
pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
actions!(client, [Authenticate]);
actions!(client, [Authenticate, TestTelemetry]);
pub fn init(rpc: Arc<Client>, cx: &mut MutableAppContext) {
cx.add_global_action(move |_: &Authenticate, cx| {
let rpc = rpc.clone();
cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await })
pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
cx.add_global_action({
let client = client.clone();
move |_: &Authenticate, cx| {
let client = client.clone();
cx.spawn(
|cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
)
.detach();
}
});
cx.add_global_action({
let client = client.clone();
move |_: &TestTelemetry, _| {
client.report_event(
"test_telemetry",
json!({
"test_property": "test_value"
}),
)
}
});
}
@ -63,6 +86,7 @@ pub struct Client {
id: usize,
peer: Arc<Peer>,
http: Arc<dyn HttpClient>,
telemetry: Arc<Telemetry>,
state: RwLock<ClientState>,
#[allow(clippy::type_complexity)]
@ -232,10 +256,11 @@ impl Drop for Subscription {
}
impl Client {
pub fn new(http: Arc<dyn HttpClient>) -> Arc<Self> {
pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
Arc::new(Self {
id: 0,
peer: Peer::new(),
telemetry: Telemetry::new(http.clone(), cx),
http,
state: Default::default(),
@ -308,9 +333,11 @@ impl Client {
log::info!("set status on client {}: {:?}", self.id, status);
let mut state = self.state.write();
*state.status.0.borrow_mut() = status;
let user_id = state.credentials.as_ref().map(|c| c.user_id);
match status {
Status::Connected { .. } => {
self.telemetry.set_user_id(user_id);
state._reconnect_task = None;
}
Status::ConnectionLost => {
@ -339,6 +366,7 @@ impl Client {
}));
}
Status::SignedOut | Status::UpgradeRequired => {
self.telemetry.set_user_id(user_id);
state._reconnect_task.take();
}
_ => {}
@ -595,6 +623,9 @@ impl Client {
if credentials.is_none() && try_keychain {
credentials = read_credentials_from_keychain(cx);
read_from_keychain = credentials.is_some();
if read_from_keychain {
self.report_event("read credentials from keychain", Default::default());
}
}
if credentials.is_none() {
let mut status_rx = self.status();
@ -878,6 +909,7 @@ impl Client {
) -> Task<Result<Credentials>> {
let platform = cx.platform();
let executor = cx.background();
let telemetry = self.telemetry.clone();
executor.clone().spawn(async move {
// Generate a pair of asymmetric encryption keys. The public key will be used by the
// zed server to encrypt the user's access token, so that it can'be intercepted by
@ -956,6 +988,8 @@ impl Client {
.context("failed to decrypt access token")?;
platform.activate(true);
telemetry.report_event("authenticate with browser", Default::default());
Ok(Credentials {
user_id: user_id.parse()?,
access_token,
@ -1020,6 +1054,18 @@ impl Client {
log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME);
self.peer.respond_with_error(receipt, error)
}
pub fn start_telemetry(&self, db: Arc<Db>) {
self.telemetry.start(db);
}
pub fn report_event(&self, kind: &str, properties: Value) {
self.telemetry.report_event(kind, properties)
}
pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
self.telemetry.log_file_path()
}
}
impl AnyWeakEntityHandle {
@ -1085,7 +1131,7 @@ mod tests {
cx.foreground().forbid_parking();
let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response());
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await;
let mut status = client.status();
assert!(matches!(
@ -1124,7 +1170,7 @@ mod tests {
let auth_count = Arc::new(Mutex::new(0));
let dropped_auth_count = Arc::new(Mutex::new(0));
let client = Client::new(FakeHttpClient::with_404_response());
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
client.override_authenticate({
let auth_count = auth_count.clone();
let dropped_auth_count = dropped_auth_count.clone();
@ -1173,7 +1219,7 @@ mod tests {
cx.foreground().forbid_parking();
let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response());
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await;
let (done_tx1, mut done_rx1) = smol::channel::unbounded();
@ -1219,7 +1265,7 @@ mod tests {
cx.foreground().forbid_parking();
let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response());
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await;
let model = cx.add_model(|_| Model::default());
@ -1247,7 +1293,7 @@ mod tests {
cx.foreground().forbid_parking();
let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response());
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await;
let model = cx.add_model(|_| Model::default());

View file

@ -0,0 +1,255 @@
use crate::http::HttpClient;
use db::Db;
use gpui::{
executor::Background,
serde_json::{self, value::Map, Value},
AppContext, Task,
};
use isahc::Request;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use serde::Serialize;
use std::{
io::Write,
mem,
path::PathBuf,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tempfile::NamedTempFile;
use util::{post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
pub struct Telemetry {
http_client: Arc<dyn HttpClient>,
executor: Arc<Background>,
session_id: u128,
state: Mutex<TelemetryState>,
}
#[derive(Default)]
struct TelemetryState {
user_id: Option<Arc<str>>,
device_id: Option<Arc<str>>,
app_version: Option<Arc<str>>,
os_version: Option<Arc<str>>,
os_name: &'static str,
queue: Vec<AmplitudeEvent>,
next_event_id: usize,
flush_task: Option<Task<()>>,
log_file: Option<NamedTempFile>,
}
const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
lazy_static! {
static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
.ok()
.or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
}
#[derive(Serialize)]
struct AmplitudeEventBatch {
api_key: &'static str,
events: Vec<AmplitudeEvent>,
}
#[derive(Serialize)]
struct AmplitudeEvent {
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<Arc<str>>,
device_id: Option<Arc<str>>,
event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
event_properties: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
user_properties: Option<Map<String, Value>>,
os_name: &'static str,
os_version: Option<Arc<str>>,
app_version: Option<Arc<str>>,
event_id: usize,
session_id: u128,
time: u128,
}
#[cfg(debug_assertions)]
const MAX_QUEUE_LEN: usize = 1;
#[cfg(not(debug_assertions))]
const MAX_QUEUE_LEN: usize = 10;
#[cfg(debug_assertions)]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(not(debug_assertions))]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
impl Telemetry {
pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
let platform = cx.platform();
let this = Arc::new(Self {
http_client: client,
executor: cx.background().clone(),
session_id: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
state: Mutex::new(TelemetryState {
os_version: platform
.os_version()
.log_err()
.map(|v| v.to_string().into()),
os_name: platform.os_name().into(),
app_version: platform
.app_version()
.log_err()
.map(|v| v.to_string().into()),
device_id: None,
queue: Default::default(),
flush_task: Default::default(),
next_event_id: 0,
log_file: None,
user_id: None,
}),
});
if AMPLITUDE_API_KEY.is_some() {
this.executor
.spawn({
let this = this.clone();
async move {
if let Some(tempfile) = NamedTempFile::new().log_err() {
this.state.lock().log_file = Some(tempfile);
}
}
})
.detach();
}
this
}
pub fn log_file_path(&self) -> Option<PathBuf> {
Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
}
pub fn start(self: &Arc<Self>, db: Arc<Db>) {
let this = self.clone();
self.executor
.spawn(
async move {
let device_id = if let Some(device_id) = db
.read(["device_id"])?
.into_iter()
.flatten()
.next()
.and_then(|bytes| String::from_utf8(bytes).ok())
{
device_id
} else {
let device_id = Uuid::new_v4().to_string();
db.write([("device_id", device_id.as_bytes())])?;
device_id
};
let device_id = Some(Arc::from(device_id));
let mut state = this.state.lock();
state.device_id = device_id.clone();
for event in &mut state.queue {
event.device_id = device_id.clone();
}
if !state.queue.is_empty() {
drop(state);
this.flush();
}
anyhow::Ok(())
}
.log_err(),
)
.detach();
}
pub fn set_user_id(&self, user_id: Option<u64>) {
self.state.lock().user_id = user_id.map(|id| id.to_string().into());
}
pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
if AMPLITUDE_API_KEY.is_none() {
return;
}
let mut state = self.state.lock();
let event = AmplitudeEvent {
event_type: kind.to_string(),
time: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
session_id: self.session_id,
event_properties: if let Value::Object(properties) = properties {
Some(properties)
} else {
None
},
user_properties: None,
user_id: state.user_id.clone(),
device_id: state.device_id.clone(),
os_name: state.os_name,
os_version: state.os_version.clone(),
app_version: state.app_version.clone(),
event_id: post_inc(&mut state.next_event_id),
};
state.queue.push(event);
if state.device_id.is_some() {
if state.queue.len() >= MAX_QUEUE_LEN {
drop(state);
self.flush();
} else {
let this = self.clone();
let executor = self.executor.clone();
state.flush_task = Some(self.executor.spawn(async move {
executor.timer(DEBOUNCE_INTERVAL).await;
this.flush();
}));
}
}
}
fn flush(self: &Arc<Self>) {
let mut state = self.state.lock();
let events = mem::take(&mut state.queue);
state.flush_task.take();
drop(state);
if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
let this = self.clone();
self.executor
.spawn(
async move {
let mut json_bytes = Vec::new();
if let Some(file) = &mut this.state.lock().log_file {
let file = file.as_file_mut();
for event in &events {
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, event)?;
file.write_all(&json_bytes)?;
file.write(b"\n")?;
}
}
let batch = AmplitudeEventBatch { api_key, events };
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, &batch)?;
let request =
Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
this.http_client.send(request).await?;
Ok(())
}
.log_err(),
)
.detach();
}
}
}

View file

@ -0,0 +1,6 @@
DROP TABLE signups;
ALTER TABLE users
DROP COLUMN github_user_id;
DROP INDEX index_users_on_email_address;

View file

@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS "signups" (
"id" SERIAL PRIMARY KEY,
"email_address" VARCHAR NOT NULL,
"email_confirmation_code" VARCHAR(64) NOT NULL,
"email_confirmation_sent" BOOLEAN NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"device_id" VARCHAR,
"user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
"inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL,
"platform_mac" BOOLEAN NOT NULL,
"platform_linux" BOOLEAN NOT NULL,
"platform_windows" BOOLEAN NOT NULL,
"platform_unknown" BOOLEAN NOT NULL,
"editor_features" VARCHAR[],
"programming_languages" VARCHAR[]
);
CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address");
CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent");
ALTER TABLE "users"
ADD "github_user_id" INTEGER;
CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");

View file

@ -1,6 +1,6 @@
use crate::{
auth,
db::{ProjectId, User, UserId},
db::{Invite, NewUserParams, ProjectId, Signup, User, UserId, WaitlistSummary},
rpc::{self, ResultExt},
AppState, Error, Result,
};
@ -25,12 +25,8 @@ use tracing::instrument;
pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
Router::new()
.route("/users", get(get_users).post(create_user))
.route(
"/users/:id",
put(update_user).delete(destroy_user).get(get_user),
)
.route("/users/:id", put(update_user).delete(destroy_user))
.route("/users/:id/access_tokens", post(create_access_token))
.route("/bulk_users", post(create_users))
.route("/users_with_no_invites", get(get_users_with_no_invites))
.route("/invite_codes/:code", get(get_user_for_invite_code))
.route("/panic", post(trace_panic))
@ -45,6 +41,11 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
)
.route("/user_activity/counts", get(get_active_user_counts))
.route("/project_metadata", get(get_project_metadata))
.route("/signups", post(create_signup))
.route("/signups_summary", get(get_waitlist_summary))
.route("/user_invites", post(create_invite_from_code))
.route("/unsent_invites", get(get_unsent_invites))
.route("/sent_invites", post(record_sent_invites))
.layer(
ServiceBuilder::new()
.layer(Extension(state))
@ -86,6 +87,8 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
#[derive(Debug, Deserialize)]
struct GetUsersQueryParams {
github_user_id: Option<i32>,
github_login: Option<String>,
query: Option<String>,
page: Option<u32>,
limit: Option<u32>,
@ -95,6 +98,14 @@ async fn get_users(
Query(params): Query<GetUsersQueryParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> {
if let Some(github_login) = &params.github_login {
let user = app
.db
.get_user_by_github_account(github_login, params.github_user_id)
.await?;
return Ok(Json(Vec::from_iter(user)));
}
let limit = params.limit.unwrap_or(100);
let users = if let Some(query) = params.query {
app.db.fuzzy_search_users(&query, limit).await?
@ -108,40 +119,61 @@ async fn get_users(
#[derive(Deserialize, Debug)]
struct CreateUserParams {
github_user_id: i32,
github_login: String,
invite_code: Option<String>,
email_address: Option<String>,
admin: bool,
email_address: String,
email_confirmation_code: Option<String>,
#[serde(default)]
invite_count: i32,
}
#[derive(Serialize, Debug)]
struct CreateUserResponse {
user: User,
signup_device_id: Option<String>,
}
async fn create_user(
Json(params): Json<CreateUserParams>,
Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> Result<Json<User>> {
let user_id = if let Some(invite_code) = params.invite_code {
let invitee_id = app
) -> Result<Json<CreateUserResponse>> {
let user = NewUserParams {
github_login: params.github_login,
github_user_id: params.github_user_id,
invite_count: params.invite_count,
};
let user_id;
let signup_device_id;
// Creating a user via the normal signup process
if let Some(email_confirmation_code) = params.email_confirmation_code {
let result = app
.db
.redeem_invite_code(
&invite_code,
&params.github_login,
params.email_address.as_deref(),
.create_user_from_invite(
&Invite {
email_address: params.email_address,
email_confirmation_code,
},
user,
)
.await?;
rpc_server
.invite_code_redeemed(&invite_code, invitee_id)
.await
.trace_err();
invitee_id
} else {
app.db
.create_user(
&params.github_login,
params.email_address.as_deref(),
params.admin,
)
.await?
};
user_id = result.user_id;
signup_device_id = result.signup_device_id;
if let Some(inviter_id) = result.inviting_user_id {
rpc_server
.invite_code_redeemed(inviter_id, user_id)
.await
.trace_err();
}
}
// Creating a user as an admin
else {
user_id = app
.db
.create_user(&params.email_address, false, user)
.await?;
signup_device_id = None;
}
let user = app
.db
@ -149,7 +181,10 @@ async fn create_user(
.await?
.ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
Ok(Json(user))
Ok(Json(CreateUserResponse {
user,
signup_device_id,
}))
}
#[derive(Deserialize)]
@ -171,7 +206,9 @@ async fn update_user(
}
if let Some(invite_count) = params.invite_count {
app.db.set_invite_count(user_id, invite_count).await?;
app.db
.set_invite_count_for_user(user_id, invite_count)
.await?;
rpc_server.invite_count_updated(user_id).await.trace_err();
}
@ -186,54 +223,6 @@ async fn destroy_user(
Ok(())
}
async fn get_user(
Path(login): Path<String>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<User>> {
let user = app
.db
.get_user_by_github_login(&login)
.await?
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(user))
}
#[derive(Deserialize)]
struct CreateUsersParams {
users: Vec<CreateUsersEntry>,
}
#[derive(Deserialize)]
struct CreateUsersEntry {
github_login: String,
email_address: String,
invite_count: usize,
}
async fn create_users(
Json(params): Json<CreateUsersParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> {
let user_ids = app
.db
.create_users(
params
.users
.into_iter()
.map(|params| {
(
params.github_login,
params.email_address,
params.invite_count,
)
})
.collect(),
)
.await?;
let users = app.db.get_users_by_ids(user_ids).await?;
Ok(Json(users))
}
#[derive(Debug, Deserialize)]
struct GetUsersWithNoInvites {
invited_by_another_user: bool,
@ -368,22 +357,24 @@ struct CreateAccessTokenResponse {
}
async fn create_access_token(
Path(login): Path<String>,
Path(user_id): Path<UserId>,
Query(params): Query<CreateAccessTokenQueryParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<CreateAccessTokenResponse>> {
// request.require_token().await?;
let user = app
.db
.get_user_by_github_login(&login)
.get_user_by_id(user_id)
.await?
.ok_or_else(|| anyhow!("user not found"))?;
let mut user_id = user.id;
if let Some(impersonate) = params.impersonate {
if user.admin {
if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
if let Some(impersonated_user) = app
.db
.get_user_by_github_account(&impersonate, None)
.await?
{
user_id = impersonated_user.id;
} else {
return Err(Error::Http(
@ -415,3 +406,59 @@ async fn get_user_for_invite_code(
) -> Result<Json<User>> {
Ok(Json(app.db.get_user_for_invite_code(&code).await?))
}
async fn create_signup(
Json(params): Json<Signup>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.create_signup(params).await?;
Ok(())
}
async fn get_waitlist_summary(
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<WaitlistSummary>> {
Ok(Json(app.db.get_waitlist_summary().await?))
}
#[derive(Deserialize)]
pub struct CreateInviteFromCodeParams {
invite_code: String,
email_address: String,
device_id: Option<String>,
}
async fn create_invite_from_code(
Json(params): Json<CreateInviteFromCodeParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Invite>> {
Ok(Json(
app.db
.create_invite_from_code(
&params.invite_code,
&params.email_address,
params.device_id.as_deref(),
)
.await?,
))
}
#[derive(Deserialize)]
pub struct GetUnsentInvitesParams {
pub count: usize,
}
async fn get_unsent_invites(
Query(params): Query<GetUnsentInvitesParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<Invite>>> {
Ok(Json(app.db.get_unsent_invites(params.count).await?))
}
async fn record_sent_invites(
Json(params): Json<Vec<Invite>>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.record_sent_invites(&params).await?;
Ok(())
}

View file

@ -11,7 +11,7 @@ mod db;
#[derive(Debug, Deserialize)]
struct GitHubUser {
id: usize,
id: i32,
login: String,
email: Option<String>,
}
@ -26,8 +26,11 @@ async fn main() {
let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
let client = reqwest::Client::new();
let current_user =
let mut current_user =
fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await;
current_user
.email
.get_or_insert_with(|| "placeholder@example.com".to_string());
let staff_users = fetch_github::<Vec<GitHubUser>>(
&client,
&github_token,
@ -64,16 +67,24 @@ async fn main() {
let mut zed_user_ids = Vec::<UserId>::new();
for (github_user, admin) in zed_users {
if let Some(user) = db
.get_user_by_github_login(&github_user.login)
.get_user_by_github_account(&github_user.login, Some(github_user.id))
.await
.expect("failed to fetch user")
{
zed_user_ids.push(user.id);
} else {
} else if let Some(email) = &github_user.email {
zed_user_ids.push(
db.create_user(&github_user.login, github_user.email.as_deref(), admin)
.await
.expect("failed to insert user"),
db.create_user(
email,
admin,
db::NewUserParams {
github_login: github_user.login,
github_user_id: github_user.id,
invite_count: 5,
},
)
.await
.expect("failed to insert user"),
);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
use crate::{
db::{tests::TestDb, ProjectId, UserId},
db::{NewUserParams, ProjectId, TestDb, UserId},
rpc::{Executor, Server, Store},
AppState,
};
@ -4652,7 +4652,18 @@ async fn test_random_collaboration(
let mut server = TestServer::start(cx.foreground(), cx.background()).await;
let db = server.app_state.db.clone();
let host_user_id = db.create_user("host", None, false).await.unwrap();
let host_user_id = db
.create_user(
"host@example.com",
false,
NewUserParams {
github_login: "host".into(),
github_user_id: 0,
invite_count: 0,
},
)
.await
.unwrap();
let mut available_guests = vec![
"guest-1".to_string(),
"guest-2".to_string(),
@ -4660,8 +4671,19 @@ async fn test_random_collaboration(
"guest-4".to_string(),
];
for username in &available_guests {
let guest_user_id = db.create_user(username, None, false).await.unwrap();
for (ix, username) in available_guests.iter().enumerate() {
let guest_user_id = db
.create_user(
&format!("{username}@example.com"),
false,
NewUserParams {
github_login: username.into(),
github_user_id: ix as i32,
invite_count: 0,
},
)
.await
.unwrap();
assert_eq!(*username, format!("guest-{}", guest_user_id));
server
.app_state
@ -5163,18 +5185,30 @@ impl TestServer {
});
let http = FakeHttpClient::with_404_response();
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
let user_id = if let Ok(Some(user)) = self
.app_state
.db
.get_user_by_github_account(name, None)
.await
{
user.id
} else {
self.app_state
.db
.create_user(name, None, false)
.create_user(
&format!("{name}@example.com"),
false,
NewUserParams {
github_login: name.into(),
github_user_id: 0,
invite_count: 0,
},
)
.await
.unwrap()
};
let client_name = name.to_string();
let mut client = Client::new(http.clone());
let mut client = cx.read(|cx| Client::new(http.clone(), cx));
let server = self.server.clone();
let db = self.app_state.db.clone();
let connection_killers = self.connection_killers.clone();

View file

@ -4,6 +4,8 @@ mod db;
mod env;
mod rpc;
#[cfg(test)]
mod db_tests;
#[cfg(test)]
mod integration_tests;

View file

@ -541,27 +541,30 @@ impl Server {
pub async fn invite_code_redeemed(
self: &Arc<Self>,
code: &str,
inviter_id: UserId,
invitee_id: UserId,
) -> Result<()> {
let user = self.app_state.db.get_user_for_invite_code(code).await?;
let store = self.store().await;
let invitee_contact = store.contact_for_user(invitee_id, true);
for connection_id in store.connection_ids_for_user(user.id) {
self.peer.send(
connection_id,
proto::UpdateContacts {
contacts: vec![invitee_contact.clone()],
..Default::default()
},
)?;
self.peer.send(
connection_id,
proto::UpdateInviteInfo {
url: format!("{}{}", self.app_state.invite_link_prefix, code),
count: user.invite_count as u32,
},
)?;
if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
if let Some(code) = &user.invite_code {
let store = self.store().await;
let invitee_contact = store.contact_for_user(invitee_id, true);
for connection_id in store.connection_ids_for_user(inviter_id) {
self.peer.send(
connection_id,
proto::UpdateContacts {
contacts: vec![invitee_contact.clone()],
..Default::default()
},
)?;
self.peer.send(
connection_id,
proto::UpdateInviteInfo {
url: format!("{}{}", self.app_state.invite_link_prefix, &code),
count: user.invite_count as u32,
},
)?;
}
}
}
Ok(())
}
@ -1401,7 +1404,7 @@ impl Server {
let users = match query.len() {
0 => vec![],
1 | 2 => db
.get_user_by_github_login(&query)
.get_user_by_github_account(&query, None)
.await?
.into_iter()
.collect(),

View file

@ -1216,7 +1216,7 @@ mod tests {
let languages = Arc::new(LanguageRegistry::test());
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = cx.read(|cx| Client::new(http_client.clone(), cx));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
let server = FakeServer::for_client(current_user_id, &client, cx).await;

22
crates/db/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "db"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/db.rs"
doctest = false
[features]
test-support = []
[dependencies]
collections = { path = "../collections" }
anyhow = "1.0.57"
async-trait = "0.1"
parking_lot = "0.11.1"
rocksdb = "0.18"
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
tempdir = { version = "0.3.7" }

View file

@ -30,6 +30,7 @@ use gpui::{
geometry::vector::{vec2f, Vector2F},
impl_actions, impl_internal_actions,
platform::CursorStyle,
serde_json::json,
text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox,
Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View,
ViewContext, ViewHandle, WeakViewHandle,
@ -1058,6 +1059,7 @@ impl Editor {
let editor_created_event = EditorCreated(cx.handle());
cx.emit_global(editor_created_event);
this.report_event("open editor", cx);
this
}
@ -5983,6 +5985,25 @@ impl Editor {
})
.collect()
}
fn report_event(&self, name: &str, cx: &AppContext) {
if let Some((project, file)) = self.project.as_ref().zip(
self.buffer
.read(cx)
.as_singleton()
.and_then(|b| b.read(cx).file()),
) {
project.read(cx).client().report_event(
name,
json!({
"file_extension": file
.path()
.extension()
.and_then(|e| e.to_str())
}),
);
}
}
}
impl EditorSnapshot {

View file

@ -404,6 +404,8 @@ impl Item for Editor {
project: ModelHandle<Project>,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>> {
self.report_event("save editor", cx);
let buffer = self.buffer().clone();
let buffers = buffer.read(cx).all_buffers();
let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse();

View file

@ -69,6 +69,8 @@ pub trait Platform: Send + Sync {
fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf>;
fn app_path(&self) -> Result<PathBuf>;
fn app_version(&self) -> Result<AppVersion>;
fn os_name(&self) -> &'static str;
fn os_version(&self) -> Result<AppVersion>;
}
pub(crate) trait ForegroundPlatform {

View file

@ -4,7 +4,7 @@ use super::{
use crate::{
executor, keymap,
platform::{self, CursorStyle},
Action, ClipboardItem, Event, Menu, MenuItem,
Action, AppVersion, ClipboardItem, Event, Menu, MenuItem,
};
use anyhow::{anyhow, Result};
use block::ConcreteBlock;
@ -16,7 +16,8 @@ use cocoa::{
},
base::{id, nil, selector, YES},
foundation::{
NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSString, NSUInteger, NSURL,
NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString,
NSUInteger, NSURL,
},
};
use core_foundation::{
@ -748,6 +749,22 @@ impl platform::Platform for MacPlatform {
}
}
}
fn os_name(&self) -> &'static str {
"macOS"
}
fn os_version(&self) -> Result<crate::AppVersion> {
unsafe {
let process_info = NSProcessInfo::processInfo(nil);
let version = process_info.operatingSystemVersion();
Ok(AppVersion {
major: version.majorVersion as usize,
minor: version.minorVersion as usize,
patch: version.patchVersion as usize,
})
}
}
}
unsafe fn path_from_objc(path: id) -> PathBuf {

View file

@ -196,6 +196,18 @@ impl super::Platform for Platform {
patch: 0,
})
}
fn os_name(&self) -> &'static str {
"test"
}
fn os_version(&self) -> Result<AppVersion> {
Ok(AppVersion {
major: 1,
minor: 0,
patch: 0,
})
}
}
impl Window {

View file

@ -10,6 +10,7 @@ doctest = false
[features]
test-support = [
"client/test-support",
"db/test-support",
"language/test-support",
"settings/test-support",
"text/test-support",
@ -20,6 +21,7 @@ text = { path = "../text" }
client = { path = "../client" }
clock = { path = "../clock" }
collections = { path = "../collections" }
db = { path = "../db" }
fsevent = { path = "../fsevent" }
fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
@ -54,6 +56,7 @@ rocksdb = "0.18"
[dev-dependencies]
client = { path = "../client", features = ["test-support"] }
collections = { path = "../collections", features = ["test-support"] }
db = { path = "../db", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] }

View file

@ -1,4 +1,3 @@
mod db;
pub mod fs;
mod ignore;
mod lsp_command;
@ -666,7 +665,7 @@ impl Project {
let languages = Arc::new(LanguageRegistry::test());
let http_client = client::test::FakeHttpClient::with_404_response();
let client = client::Client::new(http_client.clone());
let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
let project = cx.update(|cx| {

View file

@ -2804,7 +2804,7 @@ mod tests {
.await;
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client);
let client = cx.read(|cx| Client::new(http_client, cx));
let tree = Worktree::local(
client,
@ -2866,8 +2866,7 @@ mod tests {
fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client);
let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let tree = Worktree::local(
client,
Arc::from(Path::new("/root")),
@ -2945,8 +2944,7 @@ mod tests {
}));
let dir = parent_dir.path().join("tree");
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let tree = Worktree::local(
client,
@ -3016,8 +3014,7 @@ mod tests {
"ignored-dir": {}
}));
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let tree = Worktree::local(
client,
@ -3064,8 +3061,7 @@ mod tests {
#[gpui::test(iterations = 30)]
async fn test_create_directory(cx: &mut TestAppContext) {
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let fs = FakeFs::new(cx.background());
fs.insert_tree(

View file

@ -856,7 +856,7 @@ impl AppState {
let fs = project::FakeFs::new(cx.background().clone());
let languages = Arc::new(LanguageRegistry::test());
let http_client = client::test::FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone());
let client = Client::new(http_client.clone(), cx);
let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let themes = ThemeRegistry::new((), cx.font_cache().clone());

View file

@ -3,6 +3,10 @@ use std::process::Command;
fn main() {
println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14");
if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") {
println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}");
}
let output = Command::new("npm")
.current_dir("../../styles")
.args(["install", "--no-save"])

View file

@ -20,7 +20,7 @@ use futures::{
FutureExt, SinkExt, StreamExt,
};
use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext};
use isahc::{config::Configurable, AsyncBody, Request};
use isahc::{config::Configurable, Request};
use language::LanguageRegistry;
use log::LevelFilter;
use parking_lot::Mutex;
@ -88,7 +88,7 @@ fn main() {
});
app.run(move |cx| {
let client = client::Client::new(http.clone());
let client = client::Client::new(http.clone(), cx);
let mut languages = LanguageRegistry::new(login_shell_env_loaded);
languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone());
let languages = Arc::new(languages);
@ -121,7 +121,6 @@ fn main() {
vim::init(cx);
terminal::init(cx);
let db = cx.background().block(db);
cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
.detach();
@ -139,6 +138,10 @@ fn main() {
})
.detach();
let db = cx.background().block(db);
client.start_telemetry(db.clone());
client.report_event("start app", Default::default());
let project_store = cx.add_model(|_| ProjectStore::new(db.clone()));
let app_state = Arc::new(AppState {
languages,
@ -280,12 +283,10 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
"token": ZED_SECRET_CLIENT_TOKEN,
}))
.unwrap();
let request = Request::builder()
.uri(&panic_report_url)
.method(http::Method::POST)
let request = Request::post(&panic_report_url)
.redirect_policy(isahc::config::RedirectPolicy::Follow)
.header("Content-Type", "application/json")
.body(AsyncBody::from(body))?;
.body(body.into())?;
let response = http.send(request).await.context("error sending panic")?;
if response.status().is_success() {
fs::remove_file(child_path)

View file

@ -332,6 +332,11 @@ pub fn menus() -> Vec<Menu<'static>> {
action: Box::new(command_palette::Toggle),
},
MenuItem::Separator,
MenuItem::Action {
name: "View Telemetry Log",
action: Box::new(crate::OpenTelemetryLog),
},
MenuItem::Separator,
MenuItem::Action {
name: "Documentation",
action: Box::new(crate::OpenBrowser {

View file

@ -56,6 +56,7 @@ actions!(
DebugElements,
OpenSettings,
OpenLog,
OpenTelemetryLog,
OpenKeymap,
OpenDefaultSettings,
OpenDefaultKeymap,
@ -146,6 +147,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
open_log_file(workspace, app_state.clone(), cx);
}
});
cx.add_action({
let app_state = app_state.clone();
move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
open_telemetry_log_file(workspace, app_state.clone(), cx);
}
});
cx.add_action({
let app_state = app_state.clone();
move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
@ -504,6 +511,62 @@ fn open_log_file(
});
}
fn open_telemetry_log_file(
workspace: &mut Workspace,
app_state: Arc<AppState>,
cx: &mut ViewContext<Workspace>,
) {
workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
cx.spawn_weak(|workspace, mut cx| async move {
let workspace = workspace.upgrade(&cx)?;
let path = app_state.client.telemetry_log_file_path()?;
let log = app_state.fs.load(&path).await.log_err()?;
const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024;
let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN);
if let Some(newline_offset) = log[start_offset..].find('\n') {
start_offset += newline_offset + 1;
}
let log_suffix = &log[start_offset..];
workspace.update(&mut cx, |workspace, cx| {
let project = workspace.project().clone();
let buffer = project
.update(cx, |project, cx| project.create_buffer("", None, cx))
.expect("creating buffers on a local workspace always succeeds");
buffer.update(cx, |buffer, cx| {
buffer.set_language(app_state.languages.get_language("JSON"), cx);
buffer.edit(
[(
0..0,
concat!(
"// Zed collects anonymous usage data to help us understand how people are using the app.\n",
"// After the beta release, we'll provide the ability to opt out of this telemetry.\n",
"// Here is the data that has been reported for the current session:\n",
"\n"
),
)],
None,
cx,
);
buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx);
});
let buffer = cx.add_model(|cx| {
MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
});
workspace.add_item(
Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
cx,
);
});
Some(())
})
.detach();
});
}
fn open_bundled_config_file(
workspace: &mut Workspace,
app_state: Arc<AppState>,