diff --git a/Cargo.lock b/Cargo.lock index 511343d..a0e4059 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4275,6 +4275,7 @@ dependencies = [ "async-std", "bonsaidb", "catppuccin", + "clap 4.5.7", "cocoa", "crossbeam-channel", "env_logger 0.11.3", diff --git a/Cargo.toml b/Cargo.toml index 2bbac64..f719bd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ crossbeam-channel = "0.5.12" reqwest = "0.11.24" quick-xml = "0.31.0" scraper = "0.19.0" +clap = { version = "4.5.7", features = ["cargo", "derive", "string"] } [target.'cfg(target_os = "macos")'.dependencies] swift-rs = "1.0.6" diff --git a/src/app.rs b/src/app.rs index 2eefd21..097db77 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,18 +9,20 @@ * */ +use async_std::os::unix::net::UnixListener; use gpui::*; use crate::{ assets::Assets, commands::RootCommands, hotkey::HotkeyManager, + ipc::server::start_server, theme::Theme, window::{Window, WindowStyle}, workspace::Workspace, }; -pub fn run_app(app: gpui::App) { +pub fn run_app(listener: UnixListener, app: gpui::App) { app.with_assets(Assets).run(move |cx: &mut AppContext| { Theme::init(cx); // TODO: This still only works for a single display @@ -37,6 +39,7 @@ pub fn run_app(app: gpui::App) { theme.window_background.clone().unwrap_or_default(), )); RootCommands::init(cx); + cx.spawn(|cx| start_server(listener, cx)).detach(); HotkeyManager::init(cx); let view = Workspace::build(cx); Window::init(cx); diff --git a/src/commands/root/menu.rs b/src/commands/menu/list.rs similarity index 100% rename from src/commands/root/menu.rs rename to src/commands/menu/list.rs diff --git a/src/commands/menu/mod.rs b/src/commands/menu/mod.rs new file mode 100644 index 0000000..d17e233 --- /dev/null +++ b/src/commands/menu/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 89cb5bb..b18a120 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use gpui::*; use log::error; +use serde::{Deserialize, Serialize}; use crate::{ command, @@ -27,9 +28,7 @@ use crate::{ }, }; -#[cfg(target_os = "macos")] -use self::root::menu; -use self::root::{list, process, theme}; +use self::root::list; #[cfg(feature = "bitwarden")] mod bitwarden; @@ -37,18 +36,24 @@ mod bitwarden; mod clipboard; #[cfg(feature = "matrix")] mod matrix; +#[cfg(target_os = "macos")] +mod menu; +mod process; pub mod root; #[cfg(feature = "tailscale")] mod tailscale; +mod theme; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct RootCommand { pub id: String, title: String, subtitle: String, icon: Icon, keywords: Vec, + #[serde(skip)] shortcut: Option, + #[serde(skip)] pub action: Box, } impl RootCommand { @@ -77,7 +82,7 @@ pub trait RootCommandBuilder: CommandTrait { fn build(&self, cx: &mut WindowContext) -> RootCommand; } -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct RootCommands { pub commands: HashMap, } @@ -87,9 +92,9 @@ impl RootCommands { let commands: Vec> = vec![ Box::new(list::LoungyCommandBuilder), #[cfg(target_os = "macos")] - Box::new(menu::MenuCommandBuilder), - Box::new(process::ProcessCommandBuilder), - Box::new(theme::ThemeCommandBuilder), + Box::new(menu::list::MenuCommandBuilder), + Box::new(process::list::ProcessCommandBuilder), + Box::new(theme::list::ThemeCommandBuilder), #[cfg(feature = "tailscale")] Box::new(tailscale::list::TailscaleCommandBuilder), #[cfg(feature = "bitwarden")] diff --git a/src/commands/root/process.rs b/src/commands/process/list.rs similarity index 100% rename from src/commands/root/process.rs rename to src/commands/process/list.rs diff --git a/src/commands/process/mod.rs b/src/commands/process/mod.rs new file mode 100644 index 0000000..d17e233 --- /dev/null +++ b/src/commands/process/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/src/commands/root/mod.rs b/src/commands/root/mod.rs index 29df41c..a96eebb 100644 --- a/src/commands/root/mod.rs +++ b/src/commands/root/mod.rs @@ -10,8 +10,4 @@ */ pub mod list; -#[cfg(target_os = "macos")] -pub mod menu; pub mod numbat; -pub mod process; -pub mod theme; diff --git a/src/commands/root/theme.rs b/src/commands/theme/list.rs similarity index 100% rename from src/commands/root/theme.rs rename to src/commands/theme/list.rs diff --git a/src/commands/theme/mod.rs b/src/commands/theme/mod.rs new file mode 100644 index 0000000..d17e233 --- /dev/null +++ b/src/commands/theme/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/src/components/shared/icon.rs b/src/components/shared/icon.rs index 1121c18..b6c71c7 100644 --- a/src/components/shared/icon.rs +++ b/src/components/shared/icon.rs @@ -12,6 +12,7 @@ use std::fmt; use gpui::SharedString; +use serde::{Deserialize, Serialize}; fn to_kebap(s: &str) -> String { s.chars().fold(String::new(), |mut s, c| { @@ -40,7 +41,7 @@ impl fmt::Display for Icon { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Icon { Ratio, diff --git a/src/ipc/client.rs b/src/ipc/client.rs new file mode 100644 index 0000000..8daf498 --- /dev/null +++ b/src/ipc/client.rs @@ -0,0 +1,35 @@ +use async_std::{io::ReadExt, io::WriteExt, os::unix::net::UnixStream}; +use clap::Command; + +use crate::commands::RootCommands; + +use super::{ + server::{get_command, CommandPayload, TopLevelCommand}, + SOCKET_PATH, +}; + +pub async fn client_connect() -> anyhow::Result<()> { + let mut stream = UnixStream::connect(SOCKET_PATH).await?; + + let mut buf = vec![0; 8096]; + let n = stream.read(&mut buf).await?; + let root_commands: RootCommands = serde_json::from_slice(&buf[..n])?; + + let command: Command = get_command(&root_commands); + + let matches = command.get_matches(); + + let payload: CommandPayload = CommandPayload { + action: matches + .get_one::("Action") + .ok_or(anyhow::anyhow!("Action not found"))? + .clone(), + command: matches.get_one::("Command").cloned(), + }; + + let bytes = serde_json::to_vec(&payload)?; + + stream.write_all(&bytes).await?; + + Ok(()) +} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..705ce15 --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod server; + +pub const SOCKET_PATH: &str = "/tmp/loungy.sock"; diff --git a/src/ipc/server.rs b/src/ipc/server.rs new file mode 100644 index 0000000..9733af6 --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,157 @@ +use std::path::Path; + +use anyhow::anyhow; +use async_std::{ + io::{ReadExt, WriteExt}, + os::unix::net::{UnixListener, UnixStream}, +}; +use clap::{command, Arg, ValueEnum}; +use gpui::AsyncWindowContext; +use serde::{Deserialize, Serialize}; + +use crate::{ + commands::RootCommands, + state::{Actions, StateModel}, + window::Window, +}; + +use super::SOCKET_PATH; + +pub async fn setup_socket() -> anyhow::Result { + if Path::new(SOCKET_PATH).exists() { + if UnixStream::connect(SOCKET_PATH).await.is_ok() { + return Err(anyhow!("Server already running")); + } + std::fs::remove_file(SOCKET_PATH)?; + }; + let listener = UnixListener::bind(SOCKET_PATH).await?; + log::info!("Listening on socket: {}", SOCKET_PATH); + + Ok(listener) +} + +pub async fn start_server( + listener: UnixListener, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + let commands = cx.read_global::(|commands, _| commands.clone())?; + loop { + let (stream, _) = listener.accept().await?; + cx.spawn(|cx| handle_client(stream, commands.clone(), cx)) + .detach(); + } +} + +async fn handle_client( + mut stream: UnixStream, + commands: RootCommands, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + // Send available commands to the client + let bytes = serde_json::to_vec(&commands)?; + stream.write_all(&bytes).await?; + + let mut buf = vec![0; 1024]; + let n = stream.read(&mut buf).await?; + + let matches: CommandPayload = serde_json::from_slice(&buf[..n])?; + + let _ = cx.update::>(|cx| { + match matches.action { + TopLevelCommand::Toggle => { + Window::toggle(cx); + } + TopLevelCommand::Show => { + Window::open(cx); + } + TopLevelCommand::Hide => { + Window::close(cx); + } + TopLevelCommand::Quit => { + cx.quit(); + } + TopLevelCommand::Command => { + let Some(c) = matches.command else { + return Err(anyhow!("No command provided")); + }; + let Some((_, command)) = commands.commands.iter().find(|(k, _)| { + let split = k.split("::").collect::>(); + c.eq(split[2]) + }) else { + return Err(anyhow!("Command not found")); + }; + + let state = cx.global::(); + let state = state.inner.read(cx); + let mut is_active = false; + if let Some(active) = state.stack.last() { + is_active = active.id.eq(&command.id); + }; + if !is_active { + StateModel::update( + |this, cx| { + this.reset(cx); + }, + cx, + ); + (command.action)(&mut Actions::default(cx), cx); + Window::open(cx); + } else { + Window::toggle(cx); + } + } + } + Ok(()) + }); + Ok(()) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommandPayload { + pub action: TopLevelCommand, + pub command: Option, +} + +#[derive(Clone, Debug, ValueEnum, Serialize, Deserialize)] +pub enum TopLevelCommand { + Toggle, + Show, + Hide, + Quit, + Command, +} + +impl From for clap::builder::OsStr { + fn from(cmd: TopLevelCommand) -> Self { + match cmd { + TopLevelCommand::Toggle => "toggle".into(), + TopLevelCommand::Show => "show".into(), + TopLevelCommand::Hide => "hide".into(), + TopLevelCommand::Quit => "quit".into(), + TopLevelCommand::Command => "command".into(), + } + } +} + +pub fn get_command(commands: &RootCommands) -> clap::Command { + command!() + .arg( + Arg::new("Action") + .value_parser(clap::builder::EnumValueParser::::new()) + .required(true), + ) + .arg( + Arg::new("Command") + .required_if_eq("Action", TopLevelCommand::Command) + .value_parser( + commands + .commands + .keys() + .map(|key| { + let split = key.split("::").collect::>(); + split[2].to_string() + }) + .collect::>(), + ), + ) +} diff --git a/src/main.rs b/src/main.rs index 124c6b1..71f5307 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use app::run_app; use gpui::App; +use ipc::{client::client_connect, server::setup_socket}; mod app; mod assets; @@ -21,6 +22,7 @@ mod components; mod date; mod db; mod hotkey; +mod ipc; mod loader; mod paths; mod platform; @@ -33,7 +35,11 @@ mod workspace; #[async_std::main] async fn main() { env_logger::init(); - let app = App::new(); - run_app(app) + if let Ok(listener) = setup_socket().await { + let app = App::new(); + run_app(listener, app); + } else if let Err(e) = client_connect().await { + log::error!("CLI Error: {:?}", e); + } } diff --git a/src/state.rs b/src/state.rs index 614fb4d..df42679 100644 --- a/src/state.rs +++ b/src/state.rs @@ -525,6 +525,13 @@ impl<'a> Clone for Box { } } +// implement Default for CloneableFn +impl Default for Box { + fn default() -> Self { + Box::new(|_, _| {}) + } +} + #[derive(Clone, IntoElement, Deserialize)] pub struct Shortcut { inner: Keystroke,