diff --git a/Cargo.lock b/Cargo.lock index 718fcbf..dc4e60e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,7 +549,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -971,6 +971,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "clap_lex" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" + [[package]] name = "cocoa" version = "0.25.0" @@ -1428,6 +1468,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "4.0.0" @@ -3860,7 +3909,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.87", @@ -5443,6 +5492,8 @@ version = "0.1.0" dependencies = [ "catty", "chrono", + "clap", + "directories", "dotenv", "env_logger", "gpui", @@ -6011,6 +6062,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.25.0" diff --git a/assets/brand/reticle.svg b/assets/brand/reticle.svg new file mode 100644 index 0000000..c056f7b --- /dev/null +++ b/assets/brand/reticle.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/assets/brand/scope-login-bg.png b/assets/brand/scope-login-bg.png new file mode 100644 index 0000000..703a60f Binary files /dev/null and b/assets/brand/scope-login-bg.png differ diff --git a/src/ui/Cargo.toml b/src/ui/Cargo.toml index 0093a22..55ac201 100644 --- a/src/ui/Cargo.toml +++ b/src/ui/Cargo.toml @@ -28,6 +28,8 @@ random-string = "1.1.0" rust-embed = "8.5.0" chrono.workspace = true catty = "0.1.5" +directories = "5.0.1" +clap = { version = "4.5.21", features = ["derive"] } [features] default = ["gpui/x11"] diff --git a/src/ui/src/app.rs b/src/ui/src/app.rs index 407ab44..5c1acbb 100644 --- a/src/ui/src/app.rs +++ b/src/ui/src/app.rs @@ -2,7 +2,7 @@ use components::theme::ActiveTheme; use gpui::{div, img, rgb, Context, Model, ParentElement, Render, Styled, View, ViewContext, VisualContext}; use scope_backend_discord::{channel::DiscordChannel, client::DiscordClient, snowflake::Snowflake}; -use crate::channel::ChannelView; +use crate::{app_state::StateModel, channel::ChannelView}; pub struct App { channel: Model>>>, @@ -10,8 +10,7 @@ pub struct App { impl App { pub fn new(ctx: &mut ViewContext<'_, Self>) -> App { - let token = dotenv::var("DISCORD_TOKEN").expect("Must provide DISCORD_TOKEN in .env"); - let demo_channel_id = dotenv::var("DEMO_CHANNEL_ID").expect("Must provide DEMO_CHANNEL_ID in .env"); + let demo_channel_id = dotenv::var("DEMO_CHANNEL_ID").unwrap_or("1306357873437179944".to_owned()); let mut context = ctx.to_async(); @@ -19,10 +18,14 @@ impl App { let async_channel = channel.clone(); + let mut async_ctx = ctx.to_async(); + ctx .foreground_executor() .spawn(async move { - let client = DiscordClient::new(token).await; + let token = async_ctx.update_global::>(|global, cx| global.take_token(&mut *cx)).unwrap(); + + let client = DiscordClient::new(token.expect("Token to be set")).await; let channel = DiscordChannel::new( client.clone(), diff --git a/src/ui/src/app_state.rs b/src/ui/src/app_state.rs index 24332ff..89ce9c6 100644 --- a/src/ui/src/app_state.rs +++ b/src/ui/src/app_state.rs @@ -1,15 +1,43 @@ -use std::sync::Weak; +use gpui::*; -use gpui::{AppContext, Global}; +#[derive(Clone)] +pub struct State { + pub token: Option, +} -pub struct AppState {} +#[derive(Clone)] +pub struct StateModel { + pub inner: Model, +} -struct GlobalAppState(); +impl StateModel { + pub fn init(cx: &mut AppContext) { + let model = cx.new_model(|_cx| State { token: None }); + let this = Self { inner: model }; + cx.set_global(this.clone()); + } -impl Global for GlobalAppState {} + pub fn update(f: impl FnOnce(&mut Self, &mut AppContext), cx: &mut AppContext) { + if !cx.has_global::() { + return; + } + cx.update_global::(|mut this, cx| { + f(&mut this, cx); + }); + } -impl AppState { - pub fn set_global(_app_state: Weak, cx: &mut AppContext) { - cx.set_global(GlobalAppState()); + pub fn provide_token(&self, token: String, cx: &mut AppContext) { + self.inner.update(cx, |model, _| model.token = Some(token)) + } + + pub fn take_token(&self, cx: &mut AppContext) -> Option { + self.inner.update(cx, |model, _| model.token.take()) } } + +impl Global for StateModel {} + +#[derive(Clone, Debug)] +pub struct ListChangedEvent {} + +impl EventEmitter for State {} diff --git a/src/ui/src/main.rs b/src/ui/src/main.rs index 4282e3b..1ff0766 100644 --- a/src/ui/src/main.rs +++ b/src/ui/src/main.rs @@ -3,14 +3,17 @@ pub mod app; pub mod app_state; pub mod channel; pub mod menu; +pub mod oobe; use std::sync::Arc; -use app_state::AppState; +use app_state::StateModel; +use clap::Parser; use components::theme::{hsl, Theme, ThemeColor, ThemeMode}; use gpui::*; use http_client::anyhow; use menu::app_menus; +use oobe::login::OobeTokenLogin; #[derive(rust_embed::RustEmbed)] #[folder = "../../assets"] @@ -26,7 +29,8 @@ impl AssetSource for Assets { } } -fn init(_: Arc, cx: &mut AppContext) -> Result<()> { +fn init(cx: &mut AppContext) -> Result<()> { + StateModel::init(cx); components::init(cx); if cfg!(target_os = "macos") { @@ -44,16 +48,20 @@ fn init(_: Arc, cx: &mut AppContext) -> Result<()> { Ok(()) } +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Forces the out of box experience rather than using the token from ENV or appdata + #[arg(long)] + force_oobe: bool, +} + #[tokio::main] async fn main() { env_logger::init(); - let app_state = Arc::new(AppState {}); - App::new().with_assets(Assets).with_http_client(Arc::new(reqwest_client::ReqwestClient::new())).run(move |cx: &mut AppContext| { - AppState::set_global(Arc::downgrade(&app_state), cx); - - if let Err(e) = init(app_state.clone(), cx) { + if let Err(e) = init(cx) { log::error!("{}", e); return; } @@ -67,17 +75,29 @@ async fn main() { cx.set_global(theme); cx.refresh(); - let opts = WindowOptions { - window_decorations: Some(WindowDecorations::Client), - window_min_size: Some(size(Pixels(800.0), Pixels(600.0))), - titlebar: Some(TitlebarOptions { - appears_transparent: true, - title: Some(SharedString::new_static("scope")), - ..Default::default() - }), - ..Default::default() - }; - - cx.open_window(opts, |cx| cx.new_view(crate::app::App::new)).unwrap(); + let mut async_cx = cx.to_async(); + + let args = Args::parse(); + + cx.foreground_executor() + .spawn(async move { + if let Some(token) = OobeTokenLogin::get_token(&mut async_cx, args.force_oobe).await { + async_cx.update_global(|global: &mut StateModel, cx| global.provide_token(token, cx)).unwrap(); + + let opts = WindowOptions { + window_decorations: Some(WindowDecorations::Client), + window_min_size: Some(size(Pixels(800.0), Pixels(600.0))), + titlebar: Some(TitlebarOptions { + appears_transparent: true, + title: Some(SharedString::new_static("scope")), + ..Default::default() + }), + ..Default::default() + }; + + async_cx.open_window(opts, |cx| cx.new_view(crate::app::App::new)).unwrap(); + } + }) + .detach(); }); } diff --git a/src/ui/src/oobe/input.rs b/src/ui/src/oobe/input.rs new file mode 100644 index 0000000..7c02a77 --- /dev/null +++ b/src/ui/src/oobe/input.rs @@ -0,0 +1,29 @@ +use components::input::TextInput; +use gpui::{div, rgb, Context, Model, ParentElement, Pixels, Render, Styled, View, VisualContext}; + +pub struct OobeInput { + title: String, + pub input: View, +} + +impl OobeInput { + pub fn create(ctx: &mut gpui::ViewContext<'_, Self>, title: String, secure: bool) -> Self { + let input = ctx.new_view(|cx| { + let mut input = TextInput::new(cx); + + if secure { + input.set_masked(true, cx); + } + + input + }); + + OobeInput { title, input } + } +} + +impl Render for OobeInput { + fn render(&mut self, _: &mut gpui::ViewContext) -> impl gpui::IntoElement { + div().flex().flex_col().gap(Pixels(4.)).text_color(rgb(0xA7ACBB)).child(self.title.clone()).child(self.input.clone()) + } +} diff --git a/src/ui/src/oobe/login.rs b/src/ui/src/oobe/login.rs new file mode 100644 index 0000000..d6965ae --- /dev/null +++ b/src/ui/src/oobe/login.rs @@ -0,0 +1,191 @@ +use std::{ + io::{Read, Write}, + path::Path, +}; + +use components::{button::Button, IconName, StyledExt}; +use gpui::{ + div, img, rgb, size, svg, AsyncAppContext, Bounds, ClickEvent, Context, Element, IntoElement, ParentElement, Pixels, Render, RenderOnce, + SharedString, Styled, TitlebarOptions, View, VisualContext, WindowContext, WindowDecorations, WindowOptions, +}; + +use super::{input::OobeInput, titlebar::OobeTitleBar}; + +pub struct OobeTokenLogin { + token_is_ready_to_send: bool, + token: Option, + token_input: View, +} + +impl OobeTokenLogin { + fn try_load_persistent_token() -> Option { + if let Some(data_dirs) = directories::ProjectDirs::from("com", "Scope Client", "Scope") { + //TODO: Stabilize on a data model for long-term data storage. + // This PR sees this as a non-goal, however. + + let _ = std::fs::create_dir_all(data_dirs.data_local_dir()); + + match std::fs::File::open(data_dirs.data_local_dir().join(Path::new("token"))) { + Err(e) => { + log::warn!("Failed to open the token file: {:?}", e); + return None; + } + Ok(mut file) => { + let mut token = String::new(); + + if let Err(e) = file.read_to_string(&mut token) { + log::warn!("Failed to read the token file: {:?}", e); + return None; + } + + return Some(token); + } + } + } else { + log::warn!("No data dir"); + } + + None + } + + fn try_store_persistent_token(token: &String) { + if let Some(data_dirs) = directories::ProjectDirs::from("com", "Scope Client", "Scope") { + match std::fs::File::create(data_dirs.data_local_dir().join(Path::new("token"))) { + Err(e) => { + log::warn!("Failed to open the token file for write: {:?}", e); + return; + } + + Ok(mut file) => match file.write_all(token.as_bytes()) { + Ok(_) => return, + + Err(e) => { + log::warn!("Failed to write to the token file: {:?}", e); + return; + } + }, + } + } + } + + async fn get_token_from_oobe(cx: &mut AsyncAppContext) -> Option { + let size = size(Pixels(450.0), Pixels(500.0)); + + let window_options = cx + .update(|cx| WindowOptions { + window_decorations: Some(WindowDecorations::Client), + window_min_size: Some(size), + window_bounds: Some(gpui::WindowBounds::Windowed(Bounds::centered(None, size, cx))), + titlebar: Some(TitlebarOptions { + appears_transparent: true, + title: Some(SharedString::new_static("scope")), + ..Default::default() + }), + ..Default::default() + }) + .unwrap(); + + let window = cx.open_window(window_options, |cx| Self::build_root_view(cx)).unwrap(); + + let (token_sender, receiver) = catty::oneshot(); + + let mut token_sender = Some(token_sender); + + cx.update_window(*window, |win, cx| { + cx.observe(&win.downcast::().unwrap().model, move |model, cx| { + let model = model.read(cx); + + let token_is_ready_to_send = model.token_is_ready_to_send; + let token = model.token.clone(); + + println!("Updates"); + + cx.remove_window(); + + if token_is_ready_to_send { + token_sender.take().expect("Cannot double send").send(token.unwrap()).unwrap() + } + }) + .detach(); + }) + .unwrap(); + + let token = receiver.await.ok(); + + if let Some(ref token) = token { + Self::try_store_persistent_token(token); + } + + token + } + + pub async fn get_token(cx: &mut AsyncAppContext, force_oobe: bool) -> Option { + if force_oobe { + return Self::get_token_from_oobe(cx).await; + } + + if let Ok(token_in_env) = dotenv::var("DISCORD_TOKEN") { + Some(token_in_env) + } else if let Some(token_in_persistent_storage) = Self::try_load_persistent_token() { + Some(token_in_persistent_storage) + } else { + Self::get_token_from_oobe(cx).await + } + } + + fn build_root_view(cx: &mut WindowContext) -> gpui::View { + let token_input = cx.new_view(|cx| OobeInput::create(cx, "Your Discord Token".to_owned(), true)); + + cx.new_view(|_| Self { + token_is_ready_to_send: false, + token: None, + token_input, + }) + } +} + +impl Render for OobeTokenLogin { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + let title_bar = OobeTitleBar::new() + .child(div().flex().flex_row().text_color(rgb(0x0F1013)).gap_2().child(img("brand/scope-round-200.png").w_6().h_6()).child("Scope")); + + div().child(img("brand/scope-login-bg.png").bg(rgb(0x0F1013)).absolute().top_0().left_0().w(Pixels(450.)).h(Pixels(500.))).child( + div().absolute().top_0().left_0().w(Pixels(450.)).h(Pixels(500.)).flex().flex_col().child(title_bar).child( + div() + .flex() + .flex_col() + .justify_between() + .w_full() + .h_full() + .pt(Pixels(12.)) + .px(Pixels(47.)) + .pb(Pixels(43.)) + .text_color(gpui::white()) + .child( + div() + .flex() + .flex_col() + .child( + div() + .flex() + .flex_row() + .gap(Pixels(8.)) + .child(svg().text_color(rgb(0xFC3B8C)).w(Pixels(16.)).h(Pixels(16.)).path("brand/reticle.svg")) + .child(div().text_color(rgb(0x65687A)).relative().top(Pixels(-5.)).m_0().font_bold().text_size(Pixels(16.)).child("SCOPE S1")), + ) + .child(div().text_size(Pixels(36.)).text_color(rgb(0xE2E5ED)).font_extrabold().child("Login to Scope")), + ) + .child(self.token_input.clone()) + .child( + div().w_full().flex().flex_row().justify_end().child(Button::new("continue").label("Log In").icon(IconName::ArrowRight).on_click( + cx.listener(|view, _, cx| { + view.token_is_ready_to_send = true; + view.token = Some(view.token_input.model.read(cx).input.model.read(cx).text().to_string()); + cx.notify(); + }), + )), + ), + ), + ) + } +} diff --git a/src/ui/src/oobe/mod.rs b/src/ui/src/oobe/mod.rs new file mode 100644 index 0000000..300c7d8 --- /dev/null +++ b/src/ui/src/oobe/mod.rs @@ -0,0 +1,3 @@ +pub mod input; +pub mod login; +pub mod titlebar; diff --git a/src/ui/src/oobe/titlebar.rs b/src/ui/src/oobe/titlebar.rs new file mode 100644 index 0000000..5d20853 --- /dev/null +++ b/src/ui/src/oobe/titlebar.rs @@ -0,0 +1,237 @@ +use components::{h_flex, theme::ActiveTheme, Icon, IconName, Sizable as _}; +use gpui::{ + div, prelude::FluentBuilder as _, px, relative, rgb, AnyElement, Div, Element, Hsla, InteractiveElement as _, IntoElement, ParentElement, Pixels, + RenderOnce, Stateful, StatefulInteractiveElement as _, Style, Styled, WindowContext, +}; + +pub const TITLE_BAR_HEIGHT: Pixels = px(35.); +#[cfg(target_os = "macos")] +const TITLE_BAR_LEFT_PADDING: Pixels = px(80.); +#[cfg(not(target_os = "macos"))] +const TITLE_BAR_LEFT_PADDING: Pixels = px(12.); + +/// TitleBar used to customize the appearance of the title bar. +/// +/// We can put some elements inside the title bar. +#[derive(IntoElement)] +pub struct OobeTitleBar { + base: Stateful
, + children: Vec, +} + +impl OobeTitleBar { + pub fn new() -> Self { + Self { + base: div().id("title-bar").pl(TITLE_BAR_LEFT_PADDING), + children: Vec::new(), + } + } +} + +// The Windows control buttons have a fixed width of 35px. +// +// We don't need implementation the click event for the control buttons. +// If user clicked in the bounds, the window event will be triggered. +#[derive(IntoElement, Clone)] +enum ControlIcon { + Close, +} + +impl ControlIcon { + fn close() -> Self { + Self::Close + } + + fn id(&self) -> &'static str { + match self { + Self::Close => "close", + } + } + + fn icon(&self) -> IconName { + match self { + Self::Close => IconName::WindowClose, + } + } + + fn is_close(&self) -> bool { + matches!(self, Self::Close) + } + + fn fg(&self, cx: &WindowContext) -> Hsla { + if cx.theme().mode.is_dark() { + components::white() + } else { + components::black() + } + } + + fn hover_fg(&self, cx: &WindowContext) -> Hsla { + if self.is_close() || cx.theme().mode.is_dark() { + components::white() + } else { + components::black() + } + } + + fn hover_bg(&self, cx: &WindowContext) -> Hsla { + if self.is_close() { + if cx.theme().mode.is_dark() { + components::red_800() + } else { + components::red_600() + } + } else if cx.theme().mode.is_dark() { + components::stone_700() + } else { + components::stone_200() + } + } +} + +impl RenderOnce for ControlIcon { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let fg = self.fg(cx); + let hover_fg = self.hover_fg(cx); + let hover_bg = self.hover_bg(cx); + let icon = self.clone(); + let is_linux = cfg!(target_os = "linux"); + + div() + .id(self.id()) + .flex() + .cursor_pointer() + .w(TITLE_BAR_HEIGHT) + .h_full() + .justify_center() + .content_center() + .items_center() + .text_color(fg) + .when(is_linux, |this| { + this.on_click(move |_, cx| match icon { + Self::Close { .. } => { + cx.remove_window(); + } + }) + }) + .hover(|style| style.bg(hover_bg).text_color(hover_fg)) + .active(|style| style.bg(hover_bg.opacity(0.7))) + .child(Icon::new(self.icon()).small()) + } +} + +#[derive(IntoElement)] +struct WindowControls {} + +impl RenderOnce for WindowControls { + fn render(self, _: &mut WindowContext) -> impl IntoElement { + if cfg!(target_os = "macos") { + return div().id("window-controls"); + } + + h_flex().id("window-controls").items_center().flex_shrink_0().h_full().child(ControlIcon::close()) + } +} + +impl Styled for OobeTitleBar { + fn style(&mut self) -> &mut gpui::StyleRefinement { + self.base.style() + } +} + +impl ParentElement for OobeTitleBar { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for OobeTitleBar { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let is_linux = cfg!(target_os = "linux"); + + const HEIGHT: Pixels = px(34.); + + div() + .flex_shrink_0() + .w_full() + .child( + self + .base + .flex() + .w_full() + .flex_row() + .items_center() + .justify_end() + .h(HEIGHT) + .when(cx.is_fullscreen(), |this| this.pl(px(12.))) + .child(WindowControls {}), + ) + .when(is_linux, |this| { + this.child(div().top_0().left_0().absolute().size_full().h_full().child(TitleBarElement {})) + }) + } +} + +/// A TitleBar Element that can be move the window. +pub struct TitleBarElement {} + +impl IntoElement for TitleBarElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TitleBarElement { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn request_layout(&mut self, _: Option<&gpui::GlobalElementId>, cx: &mut WindowContext) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.flex_grow = 1.0; + style.flex_shrink = 1.0; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + let id = cx.request_layout(style, []); + (id, ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut WindowContext, + ) -> Self::PrepaintState { + } + + #[allow(unused_variables)] + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + use gpui::{MouseButton, MouseMoveEvent, MouseUpEvent}; + cx.on_mouse_event(move |ev: &MouseMoveEvent, _, cx: &mut WindowContext| { + if bounds.contains(&ev.position) && ev.pressed_button == Some(MouseButton::Left) { + cx.start_window_move(); + } + }); + + cx.on_mouse_event(move |ev: &MouseUpEvent, _, cx: &mut WindowContext| { + if ev.button == MouseButton::Left { + cx.show_window_menu(ev.position); + } + }); + } +}