From daa691ea8d1ea14f01ec38e05c190505f21e7802 Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Thu, 15 Jan 2026 10:13:23 -0800 Subject: [PATCH 01/71] Use clap enum & struct parser rather than macro fn --- packages/agent_cli/src/main.rs | 303 ++++++++++++++---------- packages/agent_cli/src/playit_secret.rs | 13 +- 2 files changed, 190 insertions(+), 126 deletions(-) diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index f91aa089..9463bb27 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -3,7 +3,7 @@ use std::fmt::{Display, Formatter}; use std::sync::LazyLock; use std::time::Duration; -use clap::{Command, arg}; +use clap::{Parser, Subcommand}; use playit_agent_core::agent_control::platform::current_platform; use playit_agent_core::agent_control::version::{help_register_version, register_platform}; use rand::Rng; @@ -32,13 +32,148 @@ pub mod signal_handle; pub mod ui; pub mod util; +#[derive(Parser)] +#[command(name = "playit-cli")] +struct Cli { + /// Secret code for the agent + #[arg(long)] + secret: Option, + + /// Path to file containing secret + #[arg(long)] + secret_path: Option, + + /// Wait for secret_path file to read secret + #[arg(short = 'w', long)] + secret_wait: bool, + + /// Prints logs to stdout + #[arg(short = 's', long)] + stdout: bool, + + /// Path to write logs to + #[arg(short = 'l', long)] + log_path: Option, + + /// Overrides platform in version to be docker + #[arg(long)] + platform_docker: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Print version information + Version, + + /// Start the playit agent + Start, + + /// Removes the secret key on your system so the playit agent can be re-claimed + Reset, + + /// Shows the file path where the playit secret can be found + SecretPath, + + #[cfg(target_os = "linux")] + /// Setup playit for Linux service + Setup, + + /// Account management commands + Account { + #[command(subcommand)] + command: AccountCommands, + }, + + /// Setting up a new playit agent + #[command(about = "Setting up a new playit agent", long_about = "Provides a URL that can be visited to claim the agent and generate a secret key")] + Claim { + #[command(subcommand)] + command: ClaimCommands, + }, + + /// Manage tunnels + Tunnels { + #[command(subcommand)] + command: TunnelCommands, + }, +} + +#[derive(Subcommand)] +enum AccountCommands { + /// Generates a link to allow user to login + LoginUrl, +} + +#[derive(Subcommand)] +enum ClaimCommands { + /// Generates a random claim code + Generate, + + /// Print a claim URL given the code and options + Url { + /// Claim code + claim_code: String, + + /// Name for the agent + #[arg(long, default_value = "from-cli")] + name: String, + + /// The agent type + #[arg(long, default_value = "self-managed")] + r#type: String, + }, + + /// Exchanges the claim for the secret key + Exchange { + /// Claim code (see "claim generate") + claim_code: String, + + /// Number of seconds to wait (0=infinite) + #[arg(long, default_value = "0")] + wait: u32, + }, +} + +#[derive(Subcommand)] +enum TunnelCommands { + /// Create a tunnel if it doesn't exist with the parameters + Prepare { + /// Either "tcp", "udp", or "both" + port_type: String, + + /// Number of ports in a series to allocate + #[arg(default_value = "1")] + port_count: String, + + /// The tunnel type + #[arg(long)] + r#type: Option, + + /// Name of the tunnel + #[arg(long)] + name: Option, + + #[arg(long)] + exact: bool, + + #[arg(long)] + ignore_name: bool, + }, + + /// List tunnels (format "[tunnel-id] [port-type] [port-count] [public-address]") + List, +} + #[tokio::main] async fn main() -> Result { - let matches = cli().get_matches(); + let cli = Cli::parse(); /* register docker */ { - let platform = if matches.get_flag("platform_docker") { + let platform = if cli.platform_docker { Platform::Docker } else { current_platform() @@ -52,11 +187,15 @@ async fn main() -> Result { ); } - let mut secret = PlayitSecret::from_args(&matches).await; + let mut secret = PlayitSecret::from_args( + cli.secret.clone(), + cli.secret_path.clone(), + cli.secret_wait, + ).await; let _ = secret.with_default_path().await; - let log_only = matches.get_flag("stdout"); - let log_path = matches.get_one::("log_path"); + let log_only = cli.stdout; + let log_path = cli.log_path.as_ref(); // Use log-only mode if stdout flag is set OR if a log file path is specified let use_log_only = log_only || log_path.is_some(); @@ -110,18 +249,18 @@ async fn main() -> Result { } }; - match matches.subcommand() { + match cli.command { None => { ui.write_screen("no command provided, doing auto run").await; tokio::time::sleep(Duration::from_secs(1)).await; autorun(&mut ui, secret).await?; } - Some(("start", _)) => { + Some(Commands::Start) => { autorun(&mut ui, secret).await?; } - Some(("version", _)) => println!("{}", env!("CARGO_PKG_VERSION")), + Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), #[cfg(target_os = "linux")] - Some(("setup", _)) => { + Some(Commands::Setup) => { let mut secret = PlayitSecret::linux_service(); let key = secret .ensure_valid(&mut ui) @@ -142,11 +281,15 @@ async fn main() -> Result { ui.write_screen("Playit setup, secret written to /etc/playit/playit.toml") .await; } - Some(("reset", _)) => loop { - let mut secerts = PlayitSecret::from_args(&matches).await; - secerts.with_default_path().await; - - let path = secerts.get_path().unwrap(); + Some(Commands::Reset) => loop { + let mut secrets = PlayitSecret::from_args( + cli.secret.clone(), + cli.secret_path.clone(), + cli.secret_wait, + ).await; + secrets.with_default_path().await; + + let path = secrets.get_path().unwrap(); if !tokio::fs::try_exists(path).await.unwrap_or(false) { break; } @@ -154,14 +297,18 @@ async fn main() -> Result { tokio::fs::remove_file(path).await.unwrap(); println!("deleted secret at: {}", path); }, - Some(("secret-path", _)) => { - let mut secerts = PlayitSecret::from_args(&matches).await; - secerts.with_default_path().await; - let path = secerts.get_path().unwrap(); + Some(Commands::SecretPath) => { + let mut secrets = PlayitSecret::from_args( + cli.secret.clone(), + cli.secret_path.clone(), + cli.secret_wait, + ).await; + secrets.with_default_path().await; + let path = secrets.get_path().unwrap(); println!("{}", path); } - Some(("account", m)) => match m.subcommand() { - Some(("login-url", _)) => { + Some(Commands::Account { command }) => match command { + AccountCommands::LoginUrl => { let api = secret.create_api().await?; let session = api.login_guest().await?; println!( @@ -169,31 +316,28 @@ async fn main() -> Result { session.session_key ) } - _ => return Err(CliError::NotImplemented), }, - Some(("claim", m)) => match m.subcommand() { - Some(("generate", _)) => { + Some(Commands::Claim { command }) => match command { + ClaimCommands::Generate => { ui.write_screen(claim_generate()).await; } - Some(("url", m)) => { - let code = m.get_one::("CLAIM_CODE").expect("required"); - ui.write_screen(claim_url(code)?.to_string()).await; + ClaimCommands::Url { claim_code, .. } => { + ui.write_screen(claim_url(&claim_code)?.to_string()).await; } - Some(("exchange", m)) => { - let claim_code = m.get_one::("CLAIM_CODE").expect("required"); - let wait: u32 = m - .get_one::("wait") - .expect("required") - .parse() - .expect("invalid wait value"); - + ClaimCommands::Exchange { claim_code, wait } => { let secret_key = - claim_exchange(&mut ui, claim_code, ClaimAgentType::SelfManaged, wait).await?; + claim_exchange(&mut ui, &claim_code, ClaimAgentType::SelfManaged, wait).await?; ui.write_screen(secret_key).await; } - _ => return Err(CliError::NotImplemented), }, - _ => return Err(CliError::NotImplemented), + Some(Commands::Tunnels { command }) => match command { + TunnelCommands::Prepare { .. } => { + return Err(CliError::NotImplemented); + } + TunnelCommands::List => { + return Err(CliError::NotImplemented); + } + }, } Ok(std::process::ExitCode::SUCCESS) @@ -362,86 +506,3 @@ impl From for CliError { CliError::TunnelSetupError(e) } } - -fn cli() -> Command { - let mut cmd = Command::new("playit-cli") - .arg(arg!(--secret "secret code for the agent").required(false)) - .arg(arg!(--secret_path "path to file containing secret").required(false)) - .arg(arg!(-w --secret_wait "wait for secret_path file to read secret").required(false)) - .arg(arg!(-s --stdout "prints logs to stdout").required(false)) - .arg(arg!(-l --log_path "path to write logs to").required(false)) - .arg(arg!(--platform_docker "overrides platform in version to be docker").required(false)) - .subcommand_required(false) - .subcommand(Command::new("version")) - .subcommand( - Command::new("account") - .subcommand_required(true) - .subcommand( - Command::new("login-url") - .about("Generates a link to allow user to login") - ) - ) - .subcommand( - Command::new("claim") - .subcommand_required(true) - .arg(arg!(--name "name of the agent").required(false)) - .about("Setting up a new playit agent") - .long_about("Provides a URL that can be visited to claim the agent and generate a secret key") - .subcommand( - Command::new("generate") - .about("Generates a random claim code") - ) - .subcommand( - Command::new("url") - .about("Print a claim URL given the code and options") - .arg(arg!( "claim code")) - .arg(arg!(--name [NAME] "name for the agent").default_value("from-cli")) - .arg(arg!(--type [TYPE] "the agent type").default_value("self-managed")) - ) - .subcommand( - Command::new("exchange") - .about("Exchanges the claim for the secret key") - .arg(arg!( "claim code (see \"claim generate\")")) - .arg(arg!(--wait "number of seconds to wait 0=infinite").default_value("0")) - ) - ) - .subcommand( - Command::new("start") - .about("Start the playit agent") - ) - .subcommand( - Command::new("tunnels") - .subcommand_required(true) - .about("Manage tunnels") - .subcommand( - Command::new("prepare") - .about("Create a tunnel if it doesn't exist with the parameters") - .arg(arg!(--type [TUNNEL_TYPE] "the tunnel type")) - .arg(arg!(--name [NAME] "name of the tunnel")) - .arg(arg!( "either \"tcp\", \"udp\", or \"both\"")) - .arg(arg!( "number of ports in a series to allocate").default_value("1")) - .arg(arg!(--exact)) - .arg(arg!(--ignore_name)) - ) - .subcommand( - Command::new("list") - .about("List tunnels (format \"[tunnel-id] [port-type] [port-count] [public-address]\")") - ) - ) - .subcommand( - Command::new("reset") - .about("removes the secret key on your system so the playit agent can be re-claimed") - ) - .subcommand( - Command::new("secret-path") - .about("shows the file path where the playit secret can be found") - ) - ; - - #[cfg(target_os = "linux")] - { - cmd = cmd.subcommand(Command::new("setup")); - } - - cmd -} diff --git a/packages/agent_cli/src/playit_secret.rs b/packages/agent_cli/src/playit_secret.rs index f15fa575..181a0918 100644 --- a/packages/agent_cli/src/playit_secret.rs +++ b/packages/agent_cli/src/playit_secret.rs @@ -1,6 +1,5 @@ use std::time::Duration; -use clap::ArgMatches; use playit_api_client::{PlayitApi, api::*}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; @@ -221,9 +220,13 @@ impl PlayitSecret { } } - pub async fn from_args(matches: &ArgMatches) -> Self { - let mut secret = matches.get_one::("secret").cloned(); - let mut path = matches.get_one::("secret_path").cloned(); + pub async fn from_args( + secret: Option, + secret_path: Option, + secret_wait: bool, + ) -> Self { + let mut secret = secret; + let mut path = secret_path; if secret.is_none() && path.is_none() { if let Ok(secret_env) = std::env::var("PLAYIT_SECRET") { @@ -243,7 +246,7 @@ impl PlayitSecret { secret: RwLock::new(secret), path, allow_path_read, - wait_for_path: matches.get_flag("secret_wait"), + wait_for_path: secret_wait, } } From e4befe6b30795316e0caff6039abcfa95d53970f Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Thu, 15 Jan 2026 10:53:06 -0800 Subject: [PATCH 02/71] Oneshot doing a service --- Cargo.lock | 166 ++++++- packages/agent_cli/Cargo.toml | 4 + packages/agent_cli/src/main.rs | 315 +++++++++++- packages/agent_cli/src/service/daemon.rs | 331 +++++++++++++ packages/agent_cli/src/service/ipc.rs | 576 ++++++++++++++++++++++ packages/agent_cli/src/service/manager.rs | 208 ++++++++ packages/agent_cli/src/service/mod.rs | 14 + 7 files changed, 1605 insertions(+), 9 deletions(-) create mode 100644 packages/agent_cli/src/service/daemon.rs create mode 100644 packages/agent_cli/src/service/ipc.rs create mode 100644 packages/agent_cli/src/service/manager.rs create mode 100644 packages/agent_cli/src/service/mod.rs diff --git a/Cargo.lock b/Cargo.lock index e59a3d1d..df2a40c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -457,13 +466,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -474,7 +503,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -489,6 +518,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "dotenv" version = "0.15.0" @@ -501,6 +536,26 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d" +dependencies = [ + "codepage", + "encoding_rs", + "windows-sys 0.52.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -722,6 +777,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -987,6 +1051,21 @@ dependencies = [ "syn", ] +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1299,9 +1378,10 @@ version = "0.17.1" dependencies = [ "clap", "crossterm", - "dirs", + "dirs 6.0.0", "dotenv", "hex", + "interprocess", "playit-agent-core", "playit-agent-proto", "playit-api-client", @@ -1310,6 +1390,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "service-manager", "tokio", "toml 0.9.8", "tracing", @@ -1320,6 +1401,19 @@ dependencies = [ "winres", ] +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1374,6 +1468,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1503,6 +1606,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1512,6 +1621,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1744,6 +1864,22 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "service-manager" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73b205a13c82cdd9fd05e22d5f4ff0269f656adf68732c4d4e4f11360975ebb" +dependencies = [ + "cfg-if", + "dirs 4.0.0", + "encoding-utils", + "encoding_rs", + "log", + "plist", + "which", + "xml-rs", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2459,6 +2595,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2732,6 +2886,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.1" diff --git a/packages/agent_cli/Cargo.toml b/packages/agent_cli/Cargo.toml index ec58e152..efb1a82e 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/agent_cli/Cargo.toml @@ -29,6 +29,10 @@ crossterm = "0.28" ratatui = "0.29" dotenv = "0.15.0" +# Service management and IPC +service-manager = "0.10" +interprocess = { version = "2.2", features = ["tokio"] } + playit-agent-core = { path = "../agent_core", version = "0.17.1" } playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } playit-api-client = { path = "../api_client", version = "0.2.0" } diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index 9463bb27..4169dd7d 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -28,6 +28,7 @@ pub static API_BASE: LazyLock = pub mod autorun; pub mod playit_secret; +pub mod service; pub mod signal_handle; pub mod ui; pub mod util; @@ -68,8 +69,51 @@ enum Commands { /// Print version information Version, - /// Start the playit agent - Start, + /// Start the playit agent (starts service and attaches to receive updates) + Start { + /// Install as system-wide service (requires admin/root) + #[arg(long)] + system: bool, + }, + + /// Stop the background service + Stop { + /// Stop system-wide service (requires admin/root) + #[arg(long)] + system: bool, + }, + + /// Show the status of the background service + Status { + /// Check system-wide service status + #[arg(long)] + system: bool, + }, + + /// Install the playit agent as a system service + Install { + /// Install as system-wide service (requires admin/root) + #[arg(long)] + system: bool, + }, + + /// Uninstall the playit agent system service + Uninstall { + /// Uninstall system-wide service (requires admin/root) + #[arg(long)] + system: bool, + }, + + /// Run the agent directly in foreground (for Docker/debugging) + RunEmbedded, + + /// Internal: Run as background service daemon + #[command(hide = true)] + RunService { + /// Run as user service (not system-wide) + #[arg(long)] + user: bool, + }, /// Removes the secret key on your system so the playit agent can be re-claimed Reset, @@ -251,13 +295,36 @@ async fn main() -> Result { match cli.command { None => { - ui.write_screen("no command provided, doing auto run").await; - tokio::time::sleep(Duration::from_secs(1)).await; - autorun(&mut ui, secret).await?; + // Default behavior: start service and attach + run_start_command(&mut ui, false).await?; + } + Some(Commands::Start { system }) => { + run_start_command(&mut ui, system).await?; + } + Some(Commands::Stop { system }) => { + run_stop_command(system).await?; + } + Some(Commands::Status { system }) => { + run_status_command(system).await?; } - Some(Commands::Start) => { + Some(Commands::Install { system }) => { + run_install_command(system)?; + } + Some(Commands::Uninstall { system }) => { + run_uninstall_command(system)?; + } + Some(Commands::RunEmbedded) => { + // Run agent directly (like old start behavior) autorun(&mut ui, secret).await?; } + Some(Commands::RunService { user }) => { + // Run as background daemon + let system_mode = !user; + if let Err(e) = service::run_daemon(system_mode).await { + tracing::error!("Daemon error: {}", e); + return Err(CliError::ServiceError(e.to_string())); + } + } Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), #[cfg(target_os = "linux")] Some(Commands::Setup) => { @@ -343,6 +410,240 @@ async fn main() -> Result { Ok(std::process::ExitCode::SUCCESS) } +/// Run the start command: start service and attach to receive updates +async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliError> { + use crate::service::ipc::{IpcClient, ServiceEvent}; + use crate::service::manager::ensure_service_running; + + // Ensure service is running + ui.write_screen("Starting playit service...").await; + + match ensure_service_running(system_mode).await { + Ok(_) => { + tracing::info!("Service is running"); + } + Err(e) => { + tracing::warn!("Failed to start via service manager: {}", e); + // Fall back to starting directly if service manager fails + ui.write_screen("Service manager not available, running directly...").await; + tokio::time::sleep(Duration::from_secs(1)).await; + + // Load secret and run embedded + let mut secret = PlayitSecret::from_args(None, None, false).await; + let _ = secret.with_default_path().await; + return autorun(ui, secret).await; + } + } + + // Connect to service via IPC + ui.write_screen("Connecting to service...").await; + + let mut client = match IpcClient::connect(system_mode).await { + Ok(client) => client, + Err(e) => { + return Err(CliError::IpcError(format!("Failed to connect to service: {}", e))); + } + }; + + // Subscribe to updates + if let Err(e) = client.subscribe().await { + return Err(CliError::IpcError(format!("Failed to subscribe: {}", e))); + } + + tracing::info!("Connected to service, receiving updates"); + + // Main loop: receive events and update UI + loop { + tokio::select! { + event_result = client.recv_event() => { + match event_result { + Ok(event) => { + match event { + ServiceEvent::AgentData { .. } => { + if let Some(data) = event.to_agent_data() { + ui.update_agent_data(data); + } + } + ServiceEvent::Stats { .. } => { + if let Some(stats) = event.to_connection_stats() { + ui.update_stats(stats); + } + } + ServiceEvent::Log { level, target, message, timestamp } => { + if let Some(log_capture) = ui.log_capture() { + use crate::ui::log_capture::{LogEntry, LogLevel}; + let log_level = match level.as_str() { + "error" | "ERROR" => LogLevel::Error, + "warn" | "WARN" => LogLevel::Warn, + "info" | "INFO" => LogLevel::Info, + "debug" | "DEBUG" => LogLevel::Debug, + _ => LogLevel::Trace, + }; + log_capture.push(LogEntry { + level: log_level, + target, + message, + timestamp, + }); + } + } + ServiceEvent::Status { .. } => { + // Status updates are handled separately + } + ServiceEvent::Ack { .. } | ServiceEvent::Error { .. } => { + // Acknowledgements handled in specific commands + } + } + } + Err(e) => { + tracing::error!("IPC error: {}", e); + ui.write_screen(format!("Connection to service lost: {}", e)).await; + tokio::time::sleep(Duration::from_secs(2)).await; + break; + } + } + } + // Handle TUI tick + _ = tokio::time::sleep(Duration::from_millis(50)) => { + if ui.is_tui() { + match ui.tick_tui() { + Ok(true) => {} // Continue + Ok(false) => { + // Quit requested - just detach, don't stop service + ui.shutdown_tui()?; + println!("Detached from service. Service continues running in background."); + println!("Use 'playit-cli stop' to stop the service."); + break; + } + Err(e) => { + ui.shutdown_tui()?; + return Err(e); + } + } + } + } + // Handle Ctrl+C + _ = tokio::signal::ctrl_c() => { + if ui.is_tui() { + ui.shutdown_tui()?; + } + println!("\nDetached from service. Service continues running in background."); + println!("Use 'playit-cli stop' to stop the service."); + break; + } + } + } + + Ok(()) +} + +/// Run the stop command +async fn run_stop_command(system_mode: bool) -> Result<(), CliError> { + use crate::service::ipc::IpcClient; + + // First try to stop via IPC + if let Ok(mut client) = IpcClient::connect(system_mode).await { + match client.stop().await { + Ok(_) => { + println!("Service stop requested"); + // Wait a bit for service to stop + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err(e) => { + tracing::warn!("Failed to send stop via IPC: {}", e); + } + } + } + + // Also try via service manager + match service::ServiceController::new(system_mode) { + Ok(controller) => { + if let Err(e) = controller.stop() { + tracing::warn!("Failed to stop via service manager: {}", e); + } + } + Err(e) => { + tracing::debug!("Service manager not available: {}", e); + } + } + + // Verify service stopped + tokio::time::sleep(Duration::from_millis(500)).await; + if !IpcClient::is_running(system_mode).await { + println!("Service stopped"); + } else { + println!("Service may still be running"); + } + + Ok(()) +} + +/// Run the status command +async fn run_status_command(system_mode: bool) -> Result<(), CliError> { + use crate::service::ipc::IpcClient; + + if !IpcClient::is_running(system_mode).await { + println!("Service is not running"); + return Ok(()); + } + + let mut client = match IpcClient::connect(system_mode).await { + Ok(client) => client, + Err(e) => { + println!("Service appears to be running but cannot connect: {}", e); + return Ok(()); + } + }; + + match client.status().await { + Ok(service::ipc::ServiceEvent::Status { running, pid, uptime_secs }) => { + println!("Service status:"); + println!(" Running: {}", running); + println!(" PID: {}", pid); + println!(" Uptime: {} seconds", uptime_secs); + } + Ok(other) => { + println!("Unexpected response: {:?}", other); + } + Err(e) => { + println!("Failed to get status: {}", e); + } + } + + Ok(()) +} + +/// Run the install command +fn run_install_command(system_mode: bool) -> Result<(), CliError> { + let controller = service::ServiceController::new(system_mode) + .map_err(|e| CliError::ServiceError(e.to_string()))?; + + controller.install() + .map_err(|e| CliError::ServiceError(e.to_string()))?; + + let mode_str = if system_mode { "system" } else { "user" }; + println!("Service installed successfully ({} mode)", mode_str); + println!("Use 'playit-cli start' to start the service"); + + Ok(()) +} + +/// Run the uninstall command +fn run_uninstall_command(system_mode: bool) -> Result<(), CliError> { + let controller = service::ServiceController::new(system_mode) + .map_err(|e| CliError::ServiceError(e.to_string()))?; + + // Try to stop first + let _ = controller.stop(); + + controller.uninstall() + .map_err(|e| CliError::ServiceError(e.to_string()))?; + + println!("Service uninstalled successfully"); + + Ok(()) +} + pub fn claim_generate() -> String { let mut buffer = [0u8; 5]; rand::rng().fill(&mut buffer); @@ -472,6 +773,8 @@ pub enum CliError { ApiError(ApiResponseError), ApiFail(String), TunnelSetupError(SetupError), + ServiceError(String), + IpcError(String), } impl Error for CliError {} diff --git a/packages/agent_cli/src/service/daemon.rs b/packages/agent_cli/src/service/daemon.rs new file mode 100644 index 00000000..12f4dee7 --- /dev/null +++ b/packages/agent_cli/src/service/daemon.rs @@ -0,0 +1,331 @@ +//! Daemon entry point for running the agent as a background service. + +use std::sync::Arc; +use std::time::Duration; + +use playit_agent_core::network::origin_lookup::OriginLookup; +use playit_agent_core::network::tcp::tcp_settings::TcpSettings; +use playit_agent_core::network::udp::udp_settings::UdpSettings; +use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; +use playit_agent_core::stats::AgentStats; +use playit_agent_core::utils::now_milli; +use playit_api_client::api::AccountStatus; +use tokio::sync::{broadcast, watch}; + +use crate::playit_secret::PlayitSecret; +use crate::service::ipc::{IpcError, IpcServer, ServiceEvent}; +use crate::ui::tui_app::{ + AccountStatusInfo, AgentData, NoticeInfo, PendingTunnelInfo, TunnelInfo, +}; +use crate::API_BASE; +use playit_agent_core::network::origin_lookup::{OriginResource, OriginTarget}; +use std::net::SocketAddr; + +/// Error type for daemon operations +#[derive(Debug)] +pub enum DaemonError { + Ipc(IpcError), + SecretError(String), + SetupError(String), +} + +impl std::fmt::Display for DaemonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DaemonError::Ipc(e) => write!(f, "IPC error: {}", e), + DaemonError::SecretError(e) => write!(f, "Secret error: {}", e), + DaemonError::SetupError(e) => write!(f, "Setup error: {}", e), + } + } +} + +impl std::error::Error for DaemonError {} + +impl From for DaemonError { + fn from(e: IpcError) -> Self { + DaemonError::Ipc(e) + } +} + +/// Run the daemon (background service) +pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { + let start_time = now_milli(); + + tracing::info!("Starting playit daemon (system_mode={})", system_mode); + + // Create IPC server (this also enforces single-instance) + let ipc_server = Arc::new( + IpcServer::new(system_mode) + .await + .map_err(DaemonError::Ipc)?, + ); + let event_tx = ipc_server.event_sender(); + + tracing::info!("IPC server created"); + + // Shutdown signal + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + // Load secret + let mut secret = PlayitSecret::from_args(None, None, false).await; + let _ = secret.with_default_path().await; + + // Get or wait for valid secret + let secret_code = match get_secret(&mut secret).await { + Ok(code) => code, + Err(e) => { + tracing::error!("Failed to get secret: {}", e); + return Err(DaemonError::SecretError(e)); + } + }; + + let api = playit_api_client::PlayitApi::create(API_BASE.to_string(), Some(secret_code.clone())); + + // Setup origin lookup + let lookup = Arc::new(OriginLookup::default()); + match api.v1_agents_rundata().await { + Ok(data) => lookup.update_from_run_data(&data).await, + Err(e) => { + tracing::warn!("Failed to load initial rundata: {:?}", e); + } + } + + // Create agent settings + let settings = PlayitAgentSettings { + udp_settings: UdpSettings::default(), + tcp_settings: TcpSettings::default(), + api_url: API_BASE.to_string(), + secret_key: secret_code, + }; + + // Start the agent + let (runner, stats) = match PlayitAgent::new(settings, lookup.clone()).await { + Ok(res) => { + let stats = res.stats(); + (res, stats) + } + Err(e) => { + tracing::error!("Failed to create agent: {:?}", e); + return Err(DaemonError::SetupError(format!("Failed to create agent: {:?}", e))); + } + }; + + tracing::info!("Agent created, starting tasks"); + + // Spawn the agent runner + let agent_handle = tokio::spawn(runner.run()); + + // Spawn IPC server + let ipc_handle = { + let server = ipc_server.clone(); + let rx = shutdown_rx.clone(); + tokio::spawn(async move { + if let Err(e) = server.run(rx).await { + tracing::error!("IPC server error: {}", e); + } + }) + }; + + // Spawn stats broadcaster + let stats_handle = { + let event_tx = event_tx.clone(); + let shutdown_rx = shutdown_rx.clone(); + tokio::spawn(broadcast_stats(stats, event_tx, shutdown_rx)) + }; + + // Spawn agent data fetcher/broadcaster + let data_handle = { + let event_tx = event_tx.clone(); + let shutdown_rx = shutdown_rx.clone(); + tokio::spawn(broadcast_agent_data(api, lookup, event_tx, shutdown_rx, start_time)) + }; + + // Wait for shutdown signal (Ctrl+C or stop command) + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("Received Ctrl+C, shutting down"); + } + _ = agent_handle => { + tracing::info!("Agent task completed"); + } + } + + // Signal shutdown + let _ = shutdown_tx.send(true); + + // Wait for tasks to complete + let _ = tokio::time::timeout(Duration::from_secs(5), async { + let _ = ipc_handle.await; + let _ = stats_handle.await; + let _ = data_handle.await; + }) + .await; + + tracing::info!("Daemon shutdown complete"); + Ok(()) +} + +/// Get secret code, waiting if necessary +async fn get_secret(secret: &mut PlayitSecret) -> Result { + // Try to get existing secret + match secret.get().await { + Ok(code) => return Ok(code), + Err(e) => { + tracing::warn!("No valid secret found: {:?}", e); + } + } + + // For daemon mode, we don't do interactive setup + // The user should run the CLI to set up the secret first + Err("No valid secret found. Please run 'playit-cli' to set up the agent first.".to_string()) +} + +/// Broadcast stats at regular intervals +async fn broadcast_stats( + stats: AgentStats, + event_tx: broadcast::Sender, + mut shutdown_rx: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_millis(100)); + + loop { + tokio::select! { + _ = interval.tick() => { + let snapshot = stats.snapshot(); + let event = ServiceEvent::Stats { + bytes_in: snapshot.bytes_in, + bytes_out: snapshot.bytes_out, + active_tcp: snapshot.active_tcp, + active_udp: snapshot.active_udp, + }; + // Ignore send errors (no subscribers) + let _ = event_tx.send(event); + } + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + } +} + +/// Fetch and broadcast agent data at regular intervals +async fn broadcast_agent_data( + api: playit_api_client::PlayitApi, + lookup: Arc, + event_tx: broadcast::Sender, + mut shutdown_rx: watch::Receiver, + _start_time: u64, +) { + let mut interval = tokio::time::interval(Duration::from_secs(3)); + let mut guest_login_link: Option<(String, u64)> = None; + + loop { + tokio::select! { + _ = interval.tick() => { + match api.v1_agents_rundata().await { + Ok(mut api_data) => { + lookup.update_from_run_data(&api_data).await; + + // Build agent data + let account_status = match api_data.permissions.account_status { + AccountStatus::Guest => AccountStatusInfo::Guest, + AccountStatus::EmailNotVerified => AccountStatusInfo::EmailNotVerified, + AccountStatus::Verified => AccountStatusInfo::Verified, + }; + + // Get login link for guest accounts + let login_link = match api_data.permissions.account_status { + AccountStatus::Guest => { + let now = now_milli(); + match &guest_login_link { + Some((link, ts)) if now - *ts < 15_000 => Some(link.clone()), + _ => { + if let Ok(session) = api.login_guest().await { + let link = format!( + "https://playit.gg/login/guest-account/{}", + session.session_key + ); + guest_login_link = Some((link.clone(), now_milli())); + Some(link) + } else { + None + } + } + } + } + _ => None, + }; + + api_data.notices.sort_by_key(|n| n.priority); + + let notices: Vec = api_data + .notices + .iter() + .map(|n| NoticeInfo { + priority: format!("{:?}", n.priority), + message: n.message.to_string(), + resolve_link: n.resolve_link.as_ref().map(|s| s.to_string()), + }) + .collect(); + + let tunnels: Vec = api_data + .tunnels + .iter() + .filter_map(|tunnel| { + let origin = OriginResource::from_agent_tunnel(tunnel)?; + + let destination = match origin.target { + OriginTarget::Https { + ip, + http_port, + https_port, + } => format!("{ip} (http: {http_port}, https: {https_port})"), + OriginTarget::Port { ip, port } => SocketAddr::new(ip, port).to_string(), + }; + + Some(TunnelInfo { + display_address: tunnel.display_address.clone(), + destination, + is_disabled: tunnel.disabled_reason.is_some(), + disabled_reason: tunnel.disabled_reason.as_ref().map(|s| s.to_string()), + }) + }) + .collect(); + + let pending_tunnels: Vec = api_data + .pending + .iter() + .map(|p| PendingTunnelInfo { + id: p.id.to_string(), + status_msg: p.status_msg.clone(), + }) + .collect(); + + let agent_data = AgentData { + version: env!("CARGO_PKG_VERSION").to_string(), + tunnels, + pending_tunnels, + notices, + account_status, + agent_id: api_data.agent_id.to_string(), + login_link, + }; + + let event = ServiceEvent::from(&agent_data); + let _ = event_tx.send(event); + } + Err(error) => { + tracing::error!(?error, "Failed to load agent data"); + } + } + } + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + } +} diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs new file mode 100644 index 00000000..f8c48369 --- /dev/null +++ b/packages/agent_cli/src/service/ipc.rs @@ -0,0 +1,576 @@ +//! IPC protocol for communication between CLI and background service. +//! +//! Uses JSON messages delimited by newlines over local sockets. + +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +use interprocess::local_socket::{ + tokio::{prelude::*, Stream}, + GenericFilePath, GenericNamespaced, ListenerOptions, ToFsName, ToNsName, +}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; +use tokio::sync::broadcast; + +use crate::ui::tui_app::{ + AccountStatusInfo, AgentData, ConnectionStats, NoticeInfo, PendingTunnelInfo, TunnelInfo, +}; + +/// Error types for IPC operations +#[derive(Debug)] +pub enum IpcError { + /// Another instance is already running + AlreadyRunning, + /// Failed to bind to socket + BindFailed(io::Error), + /// Failed to connect to socket + ConnectionFailed(io::Error), + /// IO error during communication + IoError(io::Error), + /// JSON serialization/deserialization error + JsonError(serde_json::Error), + /// Service is not running + NotRunning, +} + +impl std::fmt::Display for IpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IpcError::AlreadyRunning => write!(f, "Another instance is already running"), + IpcError::BindFailed(e) => write!(f, "Failed to bind to socket: {}", e), + IpcError::ConnectionFailed(e) => write!(f, "Failed to connect to socket: {}", e), + IpcError::IoError(e) => write!(f, "IO error: {}", e), + IpcError::JsonError(e) => write!(f, "JSON error: {}", e), + IpcError::NotRunning => write!(f, "Service is not running"), + } + } +} + +impl std::error::Error for IpcError {} + +impl From for IpcError { + fn from(e: io::Error) -> Self { + IpcError::IoError(e) + } +} + +impl From for IpcError { + fn from(e: serde_json::Error) -> Self { + IpcError::JsonError(e) + } +} + +/// Request messages from CLI to service +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServiceRequest { + /// Subscribe to all updates (agent_data, stats, logs) + Subscribe, + /// One-shot status query + Status, + /// Request service shutdown + Stop, +} + +/// Event/response messages from service to CLI +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServiceEvent { + /// Status response + Status { + running: bool, + pid: u32, + uptime_secs: u64, + }, + /// Agent data update (tunnels, notices, account info) + AgentData { + version: String, + agent_id: String, + account_status: String, + login_link: Option, + tunnels: Vec, + pending_tunnels: Vec, + notices: Vec, + }, + /// Connection stats update + Stats { + bytes_in: u64, + bytes_out: u64, + active_tcp: u32, + active_udp: u32, + }, + /// Log entry + Log { + level: String, + target: String, + message: String, + timestamp: u64, + }, + /// Acknowledgement (for stop command) + Ack { success: bool }, + /// Error response + Error { message: String }, +} + +/// JSON-serializable tunnel info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TunnelInfoJson { + pub display_address: String, + pub destination: String, + pub is_disabled: bool, + pub disabled_reason: Option, +} + +/// JSON-serializable pending tunnel info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingTunnelInfoJson { + pub id: String, + pub status_msg: String, +} + +/// JSON-serializable notice info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoticeInfoJson { + pub priority: String, + pub message: String, + pub resolve_link: Option, +} + +impl From<&TunnelInfo> for TunnelInfoJson { + fn from(t: &TunnelInfo) -> Self { + TunnelInfoJson { + display_address: t.display_address.clone(), + destination: t.destination.clone(), + is_disabled: t.is_disabled, + disabled_reason: t.disabled_reason.clone(), + } + } +} + +impl From for TunnelInfo { + fn from(t: TunnelInfoJson) -> Self { + TunnelInfo { + display_address: t.display_address, + destination: t.destination, + is_disabled: t.is_disabled, + disabled_reason: t.disabled_reason, + } + } +} + +impl From<&PendingTunnelInfo> for PendingTunnelInfoJson { + fn from(p: &PendingTunnelInfo) -> Self { + PendingTunnelInfoJson { + id: p.id.clone(), + status_msg: p.status_msg.clone(), + } + } +} + +impl From for PendingTunnelInfo { + fn from(p: PendingTunnelInfoJson) -> Self { + PendingTunnelInfo { + id: p.id, + status_msg: p.status_msg, + } + } +} + +impl From<&NoticeInfo> for NoticeInfoJson { + fn from(n: &NoticeInfo) -> Self { + NoticeInfoJson { + priority: n.priority.clone(), + message: n.message.clone(), + resolve_link: n.resolve_link.clone(), + } + } +} + +impl From for NoticeInfo { + fn from(n: NoticeInfoJson) -> Self { + NoticeInfo { + priority: n.priority, + message: n.message, + resolve_link: n.resolve_link, + } + } +} + +impl From<&AgentData> for ServiceEvent { + fn from(data: &AgentData) -> Self { + ServiceEvent::AgentData { + version: data.version.clone(), + agent_id: data.agent_id.clone(), + account_status: format!("{:?}", data.account_status), + login_link: data.login_link.clone(), + tunnels: data.tunnels.iter().map(|t| t.into()).collect(), + pending_tunnels: data.pending_tunnels.iter().map(|p| p.into()).collect(), + notices: data.notices.iter().map(|n| n.into()).collect(), + } + } +} + +impl From<&ConnectionStats> for ServiceEvent { + fn from(stats: &ConnectionStats) -> Self { + ServiceEvent::Stats { + bytes_in: stats.bytes_in, + bytes_out: stats.bytes_out, + active_tcp: stats.active_tcp, + active_udp: stats.active_udp, + } + } +} + +impl ServiceEvent { + /// Convert AgentData event back to AgentData struct + pub fn to_agent_data(&self) -> Option { + match self { + ServiceEvent::AgentData { + version, + agent_id, + account_status, + login_link, + tunnels, + pending_tunnels, + notices, + } => { + let status = match account_status.as_str() { + "Guest" => AccountStatusInfo::Guest, + "EmailNotVerified" => AccountStatusInfo::EmailNotVerified, + "Verified" => AccountStatusInfo::Verified, + _ => AccountStatusInfo::Unknown, + }; + Some(AgentData { + version: version.clone(), + agent_id: agent_id.clone(), + account_status: status, + login_link: login_link.clone(), + tunnels: tunnels.iter().cloned().map(|t| t.into()).collect(), + pending_tunnels: pending_tunnels.iter().cloned().map(|p| p.into()).collect(), + notices: notices.iter().cloned().map(|n| n.into()).collect(), + }) + } + _ => None, + } + } + + /// Convert Stats event back to ConnectionStats struct + pub fn to_connection_stats(&self) -> Option { + match self { + ServiceEvent::Stats { + bytes_in, + bytes_out, + active_tcp, + active_udp, + } => Some(ConnectionStats { + bytes_in: *bytes_in, + bytes_out: *bytes_out, + active_tcp: *active_tcp, + active_udp: *active_udp, + }), + _ => None, + } + } +} + +/// Get the socket path for the IPC connection +pub fn get_socket_path(system_mode: bool) -> String { + #[cfg(target_os = "linux")] + { + if system_mode { + "/var/run/playit-agent.sock".to_string() + } else { + // Use abstract socket namespace on Linux + format!("@playit-agent-{}", unsafe { libc::getuid() }) + } + } + + #[cfg(target_os = "macos")] + { + if system_mode { + "/var/run/playit-agent.sock".to_string() + } else { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("playit_gg"); + let _ = std::fs::create_dir_all(&data_dir); + data_dir + .join("playit-agent.sock") + .to_string_lossy() + .to_string() + } + } + + #[cfg(target_os = "windows")] + { + if system_mode { + r"\\.\pipe\playit-agent-system".to_string() + } else { + format!(r"\\.\pipe\playit-agent-{}", whoami::username()) + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = system_mode; + "./playit-agent.sock".to_string() + } +} + +/// Check if another instance is running by attempting to connect +pub async fn is_instance_running(system_mode: bool) -> bool { + let socket_path = get_socket_path(system_mode); + try_connect(&socket_path).await.is_ok() +} + +/// Try to connect to a socket path +async fn try_connect(socket_path: &str) -> Result { + // Try namespaced socket first (for abstract sockets on Linux) + if socket_path.starts_with('@') { + let name = socket_path[1..].to_ns_name::() + .map_err(|e| IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + Stream::connect(name) + .await + .map_err(IpcError::ConnectionFailed) + } else { + let name = socket_path.to_fs_name::() + .map_err(|e| IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + Stream::connect(name) + .await + .map_err(IpcError::ConnectionFailed) + } +} + +/// IPC Server for the background service +pub struct IpcServer { + event_tx: broadcast::Sender, + socket_path: String, + #[allow(dead_code)] + system_mode: bool, +} + +impl IpcServer { + /// Create a new IPC server + /// + /// This will fail if another instance is already running (single-instance enforcement) + pub async fn new(system_mode: bool) -> Result { + let socket_path = get_socket_path(system_mode); + + // Check if another instance is running + if try_connect(&socket_path).await.is_ok() { + return Err(IpcError::AlreadyRunning); + } + + // Remove stale socket file if it exists (not needed for abstract sockets) + if !socket_path.starts_with('@') && !socket_path.starts_with(r"\\.\pipe\") { + let _ = std::fs::remove_file(&socket_path); + } + + let (event_tx, _) = broadcast::channel(256); + + Ok(IpcServer { + event_tx, + socket_path, + system_mode, + }) + } + + /// Get a sender for broadcasting events to subscribers + pub fn event_sender(&self) -> broadcast::Sender { + self.event_tx.clone() + } + + /// Run the IPC server accept loop + pub async fn run( + self: Arc, + mut shutdown_rx: tokio::sync::watch::Receiver, + ) -> Result<(), IpcError> { + let listener = self.create_listener().await?; + + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok(stream) => { + let server = self.clone(); + tokio::spawn(async move { + if let Err(e) = server.handle_client(stream).await { + tracing::warn!("Client connection error: {}", e); + } + }); + } + Err(e) => { + tracing::error!("Accept error: {}", e); + } + } + } + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + tracing::info!("IPC server shutting down"); + break; + } + } + } + } + + Ok(()) + } + + async fn create_listener(&self) -> Result { + if self.socket_path.starts_with('@') { + let name = self.socket_path[1..].to_ns_name::() + .map_err(|e| IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + ListenerOptions::new() + .name(name) + .create_tokio() + .map_err(IpcError::BindFailed) + } else { + let name = self.socket_path.clone().to_fs_name::() + .map_err(|e| IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + ListenerOptions::new() + .name(name) + .create_tokio() + .map_err(IpcError::BindFailed) + } + } + + async fn handle_client(&self, stream: Stream) -> Result<(), IpcError> { + let (reader, writer) = stream.split(); + let mut reader = BufReader::new(reader); + let mut writer = BufWriter::new(writer); + let mut line = String::new(); + let mut event_rx = self.event_tx.subscribe(); + + loop { + tokio::select! { + // Read requests from client + read_result = reader.read_line(&mut line) => { + match read_result { + Ok(0) => break, // Connection closed + Ok(_) => { + let request: ServiceRequest = serde_json::from_str(line.trim())?; + line.clear(); + + match request { + ServiceRequest::Subscribe => { + // Client wants to receive events - handled by event_rx + tracing::debug!("Client subscribed to events"); + } + ServiceRequest::Status => { + let event = ServiceEvent::Status { + running: true, + pid: std::process::id(), + uptime_secs: 0, // TODO: Track actual uptime + }; + self.send_event(&mut writer, &event).await?; + } + ServiceRequest::Stop => { + self.send_event(&mut writer, &ServiceEvent::Ack { success: true }).await?; + // The daemon will handle the actual shutdown + tracing::info!("Stop request received"); + } + } + } + Err(e) => return Err(e.into()), + } + } + // Forward events to client + event_result = event_rx.recv() => { + match event_result { + Ok(event) => { + self.send_event(&mut writer, &event).await?; + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Client is too slow, skip some events + tracing::warn!("Client lagged behind, some events dropped"); + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + } + } + + Ok(()) + } + + async fn send_event( + &self, + writer: &mut BufWriter, + event: &ServiceEvent, + ) -> Result<(), IpcError> { + let json = serde_json::to_string(event)?; + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok(()) + } +} + +/// IPC Client for connecting to the background service +pub struct IpcClient { + reader: BufReader, + writer: BufWriter, +} + +impl IpcClient { + /// Connect to the background service + pub async fn connect(system_mode: bool) -> Result { + let socket_path = get_socket_path(system_mode); + let stream = try_connect(&socket_path).await?; + let (reader, writer) = stream.split(); + + Ok(IpcClient { + reader: BufReader::new(reader), + writer: BufWriter::new(writer), + }) + } + + /// Check if the service is running (without maintaining connection) + pub async fn is_running(system_mode: bool) -> bool { + is_instance_running(system_mode).await + } + + /// Send a request to the service + pub async fn send_request(&mut self, request: &ServiceRequest) -> Result<(), IpcError> { + let json = serde_json::to_string(request)?; + self.writer.write_all(json.as_bytes()).await?; + self.writer.write_all(b"\n").await?; + self.writer.flush().await?; + Ok(()) + } + + /// Receive an event from the service + pub async fn recv_event(&mut self) -> Result { + let mut line = String::new(); + let bytes_read = self.reader.read_line(&mut line).await?; + if bytes_read == 0 { + return Err(IpcError::IoError(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Connection closed", + ))); + } + let event = serde_json::from_str(line.trim())?; + Ok(event) + } + + /// Subscribe to events and return a stream of events + pub async fn subscribe(&mut self) -> Result<(), IpcError> { + self.send_request(&ServiceRequest::Subscribe).await + } + + /// Request service status + pub async fn status(&mut self) -> Result { + self.send_request(&ServiceRequest::Status).await?; + self.recv_event().await + } + + /// Request service stop + pub async fn stop(&mut self) -> Result { + self.send_request(&ServiceRequest::Stop).await?; + self.recv_event().await + } +} diff --git a/packages/agent_cli/src/service/manager.rs b/packages/agent_cli/src/service/manager.rs new file mode 100644 index 00000000..009b245c --- /dev/null +++ b/packages/agent_cli/src/service/manager.rs @@ -0,0 +1,208 @@ +//! Service manager integration for install/uninstall/start/stop. + +use service_manager::{ + ServiceInstallCtx, ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx, + ServiceUninstallCtx, +}; +use std::ffi::OsString; +use std::path::PathBuf; + +/// Error type for service manager operations +#[derive(Debug)] +pub enum ServiceManagerError { + /// Service manager not available on this platform + NotAvailable(String), + /// Failed to install service + InstallFailed(String), + /// Failed to uninstall service + UninstallFailed(String), + /// Failed to start service + StartFailed(String), + /// Failed to stop service + StopFailed(String), + /// Service not found + NotFound, + /// Generic IO error + IoError(std::io::Error), +} + +impl std::fmt::Display for ServiceManagerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServiceManagerError::NotAvailable(msg) => { + write!(f, "Service manager not available: {}", msg) + } + ServiceManagerError::InstallFailed(msg) => { + write!(f, "Failed to install service: {}", msg) + } + ServiceManagerError::UninstallFailed(msg) => { + write!(f, "Failed to uninstall service: {}", msg) + } + ServiceManagerError::StartFailed(msg) => write!(f, "Failed to start service: {}", msg), + ServiceManagerError::StopFailed(msg) => write!(f, "Failed to stop service: {}", msg), + ServiceManagerError::NotFound => write!(f, "Service not found"), + ServiceManagerError::IoError(e) => write!(f, "IO error: {}", e), + } + } +} + +impl std::error::Error for ServiceManagerError {} + +impl From for ServiceManagerError { + fn from(e: std::io::Error) -> Self { + ServiceManagerError::IoError(e) + } +} + +/// Service controller for managing the playit agent service +pub struct ServiceController { + manager: Box, + label: ServiceLabel, + system_mode: bool, +} + +impl ServiceController { + /// Service label for playit agent + const SERVICE_LABEL: &'static str = "gg.playit.agent"; + const USER_SERVICE_LABEL: &'static str = "gg.playit.agent.user"; + + /// Create a new service controller + pub fn new(system_mode: bool) -> Result { + let manager = ::native() + .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; + + let label_str = if system_mode { + Self::SERVICE_LABEL + } else { + Self::USER_SERVICE_LABEL + }; + + let label: ServiceLabel = label_str.parse().unwrap(); + + Ok(ServiceController { + manager, + label, + system_mode, + }) + } + + /// Get the path to the current executable + fn get_executable_path() -> Result { + std::env::current_exe().map_err(ServiceManagerError::IoError) + } + + /// Install the service + pub fn install(&self) -> Result<(), ServiceManagerError> { + let program = Self::get_executable_path()?; + + // Build arguments for the service + let mut args = vec![OsString::from("run-service")]; + if !self.system_mode { + args.push(OsString::from("--user")); + } + + let ctx = ServiceInstallCtx { + label: self.label.clone(), + program, + args, + contents: None, + username: None, + working_directory: None, + environment: None, + autostart: true, + restart_policy: service_manager::RestartPolicy::OnFailure { delay_secs: Some(5) }, + }; + + self.manager + .install(ctx) + .map_err(|e| ServiceManagerError::InstallFailed(e.to_string()))?; + + Ok(()) + } + + /// Uninstall the service + pub fn uninstall(&self) -> Result<(), ServiceManagerError> { + let ctx = ServiceUninstallCtx { + label: self.label.clone(), + }; + + self.manager + .uninstall(ctx) + .map_err(|e| ServiceManagerError::UninstallFailed(e.to_string()))?; + + Ok(()) + } + + /// Start the service + pub fn start(&self) -> Result<(), ServiceManagerError> { + let ctx = ServiceStartCtx { + label: self.label.clone(), + }; + + self.manager + .start(ctx) + .map_err(|e| ServiceManagerError::StartFailed(e.to_string()))?; + + Ok(()) + } + + /// Stop the service + pub fn stop(&self) -> Result<(), ServiceManagerError> { + let ctx = ServiceStopCtx { + label: self.label.clone(), + }; + + self.manager + .stop(ctx) + .map_err(|e| ServiceManagerError::StopFailed(e.to_string()))?; + + Ok(()) + } + + /// Check if the service is installed + pub fn is_installed(&self) -> bool { + // Try to query the service - if it fails, it's not installed + // This is a heuristic since service-manager doesn't have a direct "is_installed" method + true // For now, assume it might be installed + } + + /// Get the service label + pub fn label(&self) -> &ServiceLabel { + &self.label + } + + /// Check if running in system mode + pub fn is_system_mode(&self) -> bool { + self.system_mode + } +} + +/// Ensure the service is running, starting it if necessary +pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceManagerError> { + use crate::service::ipc::IpcClient; + + // First check if service is already running via IPC + if IpcClient::is_running(system_mode).await { + tracing::debug!("Service is already running"); + return Ok(()); + } + + // Try to start via service manager + let controller = ServiceController::new(system_mode)?; + + tracing::info!("Starting service via service manager"); + controller.start()?; + + // Wait for service to be ready + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if IpcClient::is_running(system_mode).await { + tracing::debug!("Service started successfully"); + return Ok(()); + } + } + + Err(ServiceManagerError::StartFailed( + "Service did not start within timeout".to_string(), + )) +} diff --git a/packages/agent_cli/src/service/mod.rs b/packages/agent_cli/src/service/mod.rs new file mode 100644 index 00000000..5ccb15f9 --- /dev/null +++ b/packages/agent_cli/src/service/mod.rs @@ -0,0 +1,14 @@ +//! Background service management for playit agent. +//! +//! This module provides: +//! - IPC protocol for communication between CLI and background service +//! - Daemon entry point for running the agent as a background service +//! - Service manager integration for install/uninstall/start/stop + +pub mod daemon; +pub mod ipc; +pub mod manager; + +pub use daemon::run_daemon; +pub use ipc::{IpcClient, IpcServer, ServiceEvent, ServiceRequest}; +pub use manager::ServiceController; From dbcb5851fadc021185fd84984528e41afc91305e Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Thu, 15 Jan 2026 11:53:07 -0800 Subject: [PATCH 03/71] working :) --- Cargo.lock | 1 + packages/agent_cli/Cargo.toml | 1 + packages/agent_cli/src/autorun.rs | 2 + packages/agent_cli/src/main.rs | 19 ++---- packages/agent_cli/src/service/daemon.rs | 50 +++++++-------- packages/agent_cli/src/service/ipc.rs | 39 ++++++++---- packages/agent_cli/src/service/manager.rs | 77 +++++++++++++++++++++-- packages/agent_cli/src/ui/tui_app.rs | 7 +-- 8 files changed, 133 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df2a40c0..1757dc67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,6 +1392,7 @@ dependencies = [ "serde_yaml", "service-manager", "tokio", + "tokio-util", "toml 0.9.8", "tracing", "tracing-appender", diff --git a/packages/agent_cli/Cargo.toml b/packages/agent_cli/Cargo.toml index efb1a82e..7af06da7 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/agent_cli/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/playit-cloud/playit-agent" [dependencies] tokio = { workspace = true } +tokio-util = { workspace = true } rand = { workspace = true } hex = { workspace = true } toml = { workspace = true } diff --git a/packages/agent_cli/src/autorun.rs b/packages/agent_cli/src/autorun.rs index 97257f45..36e4bf84 100644 --- a/packages/agent_cli/src/autorun.rs +++ b/packages/agent_cli/src/autorun.rs @@ -84,6 +84,7 @@ async fn run_tui_loop( lookup: Arc, stats: AgentStats, ) -> Result<(), CliError> { + let start_time = now_milli(); let (data_tx, mut data_rx) = mpsc::channel::(4); // Spawn the data fetcher task @@ -195,6 +196,7 @@ async fn run_tui_loop( account_status, agent_id: api_data.agent_id.to_string(), login_link, + start_time, }; if data_tx.send(agent_data).await.is_err() { diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index 4169dd7d..44b0b9aa 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -418,23 +418,12 @@ async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliErro // Ensure service is running ui.write_screen("Starting playit service...").await; - match ensure_service_running(system_mode).await { - Ok(_) => { - tracing::info!("Service is running"); - } - Err(e) => { - tracing::warn!("Failed to start via service manager: {}", e); - // Fall back to starting directly if service manager fails - ui.write_screen("Service manager not available, running directly...").await; - tokio::time::sleep(Duration::from_secs(1)).await; - - // Load secret and run embedded - let mut secret = PlayitSecret::from_args(None, None, false).await; - let _ = secret.with_default_path().await; - return autorun(ui, secret).await; - } + if let Err(e) = ensure_service_running(system_mode).await { + return Err(CliError::ServiceError(format!("Failed to start service: {}", e))); } + tracing::info!("Service is running"); + // Connect to service via IPC ui.write_screen("Connecting to service...").await; diff --git a/packages/agent_cli/src/service/daemon.rs b/packages/agent_cli/src/service/daemon.rs index 12f4dee7..8895496f 100644 --- a/packages/agent_cli/src/service/daemon.rs +++ b/packages/agent_cli/src/service/daemon.rs @@ -10,7 +10,8 @@ use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; use playit_agent_core::stats::AgentStats; use playit_agent_core::utils::now_milli; use playit_api_client::api::AccountStatus; -use tokio::sync::{broadcast, watch}; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; use crate::playit_secret::PlayitSecret; use crate::service::ipc::{IpcError, IpcServer, ServiceEvent}; @@ -53,9 +54,12 @@ pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { tracing::info!("Starting playit daemon (system_mode={})", system_mode); + // Shutdown signal + let cancel_token = CancellationToken::new(); + // Create IPC server (this also enforces single-instance) let ipc_server = Arc::new( - IpcServer::new(system_mode) + IpcServer::new(system_mode, cancel_token.clone()) .await .map_err(DaemonError::Ipc)?, ); @@ -63,9 +67,6 @@ pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { tracing::info!("IPC server created"); - // Shutdown signal - let (shutdown_tx, shutdown_rx) = watch::channel(false); - // Load secret let mut secret = PlayitSecret::from_args(None, None, false).await; let _ = secret.with_default_path().await; @@ -118,9 +119,8 @@ pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { // Spawn IPC server let ipc_handle = { let server = ipc_server.clone(); - let rx = shutdown_rx.clone(); tokio::spawn(async move { - if let Err(e) = server.run(rx).await { + if let Err(e) = server.run().await { tracing::error!("IPC server error: {}", e); } }) @@ -129,29 +129,32 @@ pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { // Spawn stats broadcaster let stats_handle = { let event_tx = event_tx.clone(); - let shutdown_rx = shutdown_rx.clone(); - tokio::spawn(broadcast_stats(stats, event_tx, shutdown_rx)) + let token = cancel_token.clone(); + tokio::spawn(broadcast_stats(stats, event_tx, token)) }; // Spawn agent data fetcher/broadcaster let data_handle = { let event_tx = event_tx.clone(); - let shutdown_rx = shutdown_rx.clone(); - tokio::spawn(broadcast_agent_data(api, lookup, event_tx, shutdown_rx, start_time)) + let token = cancel_token.clone(); + tokio::spawn(broadcast_agent_data(api, lookup, event_tx, token, start_time)) }; - // Wait for shutdown signal (Ctrl+C or stop command) + // Wait for shutdown signal (Ctrl+C, stop command, or agent completion) tokio::select! { _ = tokio::signal::ctrl_c() => { tracing::info!("Received Ctrl+C, shutting down"); } + _ = cancel_token.cancelled() => { + tracing::info!("Shutdown requested via IPC"); + } _ = agent_handle => { tracing::info!("Agent task completed"); } } - // Signal shutdown - let _ = shutdown_tx.send(true); + // Signal shutdown to all tasks + cancel_token.cancel(); // Wait for tasks to complete let _ = tokio::time::timeout(Duration::from_secs(5), async { @@ -184,7 +187,7 @@ async fn get_secret(secret: &mut PlayitSecret) -> Result { async fn broadcast_stats( stats: AgentStats, event_tx: broadcast::Sender, - mut shutdown_rx: watch::Receiver, + cancel_token: CancellationToken, ) { let mut interval = tokio::time::interval(Duration::from_millis(100)); @@ -201,10 +204,8 @@ async fn broadcast_stats( // Ignore send errors (no subscribers) let _ = event_tx.send(event); } - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - break; - } + _ = cancel_token.cancelled() => { + break; } } } @@ -215,8 +216,8 @@ async fn broadcast_agent_data( api: playit_api_client::PlayitApi, lookup: Arc, event_tx: broadcast::Sender, - mut shutdown_rx: watch::Receiver, - _start_time: u64, + cancel_token: CancellationToken, + start_time: u64, ) { let mut interval = tokio::time::interval(Duration::from_secs(3)); let mut guest_login_link: Option<(String, u64)> = None; @@ -311,6 +312,7 @@ async fn broadcast_agent_data( account_status, agent_id: api_data.agent_id.to_string(), login_link, + start_time, }; let event = ServiceEvent::from(&agent_data); @@ -321,10 +323,8 @@ async fn broadcast_agent_data( } } } - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - break; - } + _ = cancel_token.cancelled() => { + break; } } } diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs index f8c48369..2b54bd35 100644 --- a/packages/agent_cli/src/service/ipc.rs +++ b/packages/agent_cli/src/service/ipc.rs @@ -93,6 +93,7 @@ pub enum ServiceEvent { tunnels: Vec, pending_tunnels: Vec, notices: Vec, + start_time: u64, }, /// Connection stats update Stats { @@ -208,6 +209,7 @@ impl From<&AgentData> for ServiceEvent { tunnels: data.tunnels.iter().map(|t| t.into()).collect(), pending_tunnels: data.pending_tunnels.iter().map(|p| p.into()).collect(), notices: data.notices.iter().map(|n| n.into()).collect(), + start_time: data.start_time, } } } @@ -235,6 +237,7 @@ impl ServiceEvent { tunnels, pending_tunnels, notices, + start_time, } => { let status = match account_status.as_str() { "Guest" => AccountStatusInfo::Guest, @@ -250,6 +253,7 @@ impl ServiceEvent { tunnels: tunnels.iter().cloned().map(|t| t.into()).collect(), pending_tunnels: pending_tunnels.iter().cloned().map(|p| p.into()).collect(), notices: notices.iter().cloned().map(|n| n.into()).collect(), + start_time: *start_time, }) } _ => None, @@ -349,13 +353,20 @@ pub struct IpcServer { socket_path: String, #[allow(dead_code)] system_mode: bool, + start_time: u64, + cancel_token: tokio_util::sync::CancellationToken, } impl IpcServer { /// Create a new IPC server /// /// This will fail if another instance is already running (single-instance enforcement) - pub async fn new(system_mode: bool) -> Result { + pub async fn new( + system_mode: bool, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Result { + use playit_agent_core::utils::now_milli; + let socket_path = get_socket_path(system_mode); // Check if another instance is running @@ -369,11 +380,14 @@ impl IpcServer { } let (event_tx, _) = broadcast::channel(256); + let start_time = now_milli(); Ok(IpcServer { event_tx, socket_path, system_mode, + start_time, + cancel_token, }) } @@ -383,10 +397,7 @@ impl IpcServer { } /// Run the IPC server accept loop - pub async fn run( - self: Arc, - mut shutdown_rx: tokio::sync::watch::Receiver, - ) -> Result<(), IpcError> { + pub async fn run(self: Arc) -> Result<(), IpcError> { let listener = self.create_listener().await?; loop { @@ -406,11 +417,9 @@ impl IpcServer { } } } - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - tracing::info!("IPC server shutting down"); - break; - } + _ = self.cancel_token.cancelled() => { + tracing::info!("IPC server shutting down"); + break; } } } @@ -459,17 +468,21 @@ impl IpcServer { tracing::debug!("Client subscribed to events"); } ServiceRequest::Status => { + use playit_agent_core::utils::now_milli; + let uptime_ms = now_milli().saturating_sub(self.start_time); + let uptime_secs = uptime_ms / 1000; let event = ServiceEvent::Status { running: true, pid: std::process::id(), - uptime_secs: 0, // TODO: Track actual uptime + uptime_secs, }; self.send_event(&mut writer, &event).await?; } ServiceRequest::Stop => { self.send_event(&mut writer, &ServiceEvent::Ack { success: true }).await?; - // The daemon will handle the actual shutdown - tracing::info!("Stop request received"); + tracing::info!("Stop request received, initiating shutdown"); + // Trigger daemon shutdown + self.cancel_token.cancel(); } } } diff --git a/packages/agent_cli/src/service/manager.rs b/packages/agent_cli/src/service/manager.rs index 009b245c..34efbf83 100644 --- a/packages/agent_cli/src/service/manager.rs +++ b/packages/agent_cli/src/service/manager.rs @@ -187,17 +187,38 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana return Ok(()); } - // Try to start via service manager - let controller = ServiceController::new(system_mode)?; + // Try to start via service manager first + let service_manager_result = match ServiceController::new(system_mode) { + Ok(controller) => { + tracing::info!("Starting service via service manager"); + controller.start() + } + Err(e) => { + tracing::debug!("Service manager not available: {}", e); + Err(e) + } + }; + + // If service manager worked, wait for it to be ready + if service_manager_result.is_ok() { + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if IpcClient::is_running(system_mode).await { + tracing::debug!("Service started via service manager"); + return Ok(()); + } + } + } - tracing::info!("Starting service via service manager"); - controller.start()?; + // If service manager failed or service didn't start, spawn daemon directly + tracing::info!("Starting daemon process directly"); + spawn_daemon_process(system_mode)?; - // Wait for service to be ready + // Wait for daemon to be ready for _ in 0..50 { tokio::time::sleep(std::time::Duration::from_millis(100)).await; if IpcClient::is_running(system_mode).await { - tracing::debug!("Service started successfully"); + tracing::debug!("Daemon started successfully"); return Ok(()); } } @@ -206,3 +227,47 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana "Service did not start within timeout".to_string(), )) } + +/// Spawn the daemon process directly (without service manager) +fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { + let exe = std::env::current_exe().map_err(ServiceManagerError::IoError)?; + + let mut args = vec!["run-service".to_string()]; + if !system_mode { + args.push("--user".to_string()); + } + + #[cfg(unix)] + { + use std::process::{Command, Stdio}; + + // Spawn detached process + Command::new(&exe) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)))?; + } + + #[cfg(windows)] + { + use std::process::{Command, Stdio}; + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + const DETACHED_PROCESS: u32 = 0x00000008; + + Command::new(&exe) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn() + .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)))?; + } + + Ok(()) +} diff --git a/packages/agent_cli/src/ui/tui_app.rs b/packages/agent_cli/src/ui/tui_app.rs index d7ae3b72..0defe8ae 100644 --- a/packages/agent_cli/src/ui/tui_app.rs +++ b/packages/agent_cli/src/ui/tui_app.rs @@ -7,7 +7,6 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use playit_agent_core::utils::now_milli; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, @@ -33,6 +32,8 @@ pub struct AgentData { pub account_status: AccountStatusInfo, pub agent_id: String, pub login_link: Option, + /// Start time of the agent/service in milliseconds since epoch + pub start_time: u64, } #[derive(Clone, Debug)] @@ -96,7 +97,6 @@ pub struct TuiApp { log_capture: Arc, agent_data: AgentData, stats: ConnectionStats, - start_time: u64, // UI state mode: TuiMode, @@ -118,7 +118,6 @@ impl TuiApp { log_capture: LogCapture::new(500), agent_data: AgentData::default(), stats: ConnectionStats::default(), - start_time: now_milli(), mode: TuiMode::Setup { message: "Initializing...".to_string() }, focused_panel: FocusedPanel::Tunnels, tunnel_list_state: ListState::default(), @@ -367,7 +366,7 @@ impl TuiApp { let mode = self.mode.clone(); let agent_data = self.agent_data.clone(); let stats = self.stats.clone(); - let start_time = self.start_time; + let start_time = agent_data.start_time; let focused_panel = self.focused_panel; let quit_confirm = self.quit_confirm; let log_entries = self.log_capture.get_entries(); From 0904676100d50dd54e973c8d80ed6def4881c4e5 Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Thu, 15 Jan 2026 12:25:53 -0800 Subject: [PATCH 04/71] bugfixes --- packages/agent_cli/src/main.rs | 148 +++++++++++++++------- packages/agent_cli/src/service/daemon.rs | 31 ++++- packages/agent_cli/src/service/ipc.rs | 47 +++++-- packages/agent_cli/src/service/manager.rs | 30 +++-- packages/agent_cli/src/ui/log_capture.rs | 38 ++++++ 5 files changed, 220 insertions(+), 74 deletions(-) diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index 44b0b9aa..74889f69 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -7,9 +7,9 @@ use clap::{Parser, Subcommand}; use playit_agent_core::agent_control::platform::current_platform; use playit_agent_core::agent_control::version::{help_register_version, register_platform}; use rand::Rng; +use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; use uuid::Uuid; use autorun::autorun; @@ -74,6 +74,10 @@ enum Commands { /// Install as system-wide service (requires admin/root) #[arg(long)] system: bool, + + /// Print logs to stdout instead of using TUI + #[arg(short = 's', long)] + stdout: bool, }, /// Stop the background service @@ -132,7 +136,10 @@ enum Commands { }, /// Setting up a new playit agent - #[command(about = "Setting up a new playit agent", long_about = "Provides a URL that can be visited to claim the agent and generate a secret key")] + #[command( + about = "Setting up a new playit agent", + long_about = "Provides a URL that can be visited to claim the agent and generate a secret key" + )] Claim { #[command(subcommand)] command: ClaimCommands, @@ -231,18 +238,31 @@ async fn main() -> Result { ); } - let mut secret = PlayitSecret::from_args( - cli.secret.clone(), - cli.secret_path.clone(), - cli.secret_wait, - ).await; + let mut secret = + PlayitSecret::from_args(cli.secret.clone(), cli.secret_path.clone(), cli.secret_wait).await; let _ = secret.with_default_path().await; + // Handle run-service command first - it sets up its own logging + if let Some(Commands::RunService { user }) = &cli.command { + let system_mode = !user; + if let Err(e) = service::run_daemon(system_mode).await { + eprintln!("Daemon error: {}", e); + return Ok(std::process::ExitCode::FAILURE); + } + return Ok(std::process::ExitCode::SUCCESS); + } + let log_only = cli.stdout; let log_path = cli.log_path.as_ref(); - // Use log-only mode if stdout flag is set OR if a log file path is specified - let use_log_only = log_only || log_path.is_some(); + // Check if Start command has --stdout flag + let start_stdout = matches!( + &cli.command, + Some(Commands::Start { stdout: true, .. } | Commands::RunEmbedded) + ); + + // Use log-only mode if stdout flag is set OR if a log file path is specified OR if start --stdout + let use_log_only = log_only || log_path.is_some() || start_stdout; // Create UI first so we can get its log capture let mut ui = UI::new(UISettings { @@ -252,12 +272,12 @@ async fn main() -> Result { /* setup logging */ // Get log level from PLAYIT_LOG env var, defaulting to "info" - let log_filter = EnvFilter::try_from_env("PLAYIT_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); + let log_filter = + EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - let _guard = match (log_only, log_path) { - (true, Some(_)) => panic!("try to use -s and -l at the same time"), - (false, Some(path)) => { + let _guard = match (use_log_only, log_path) { + (true, Some(path)) => { + // Log to file let write_path = match path.rsplit_once("/") { Some((dir, file)) => tracing_appender::rolling::never(dir, file), None => tracing_appender::rolling::never(".", path), @@ -272,6 +292,7 @@ async fn main() -> Result { Some(guard) } (true, None) => { + // Log to stdout (for -s flag, run-embedded, or start -s) let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); tracing_subscriber::fmt() .with_ansi(current_platform() == Platform::Linux) @@ -280,6 +301,9 @@ async fn main() -> Result { .init(); Some(guard) } + (false, Some(_)) => { + panic!("log_path set but use_log_only is false - this shouldn't happen"); + } (false, None) => { // TUI mode - set up log capture layer with filter if let Some(log_capture) = ui.log_capture() { @@ -295,11 +319,11 @@ async fn main() -> Result { match cli.command { None => { - // Default behavior: start service and attach - run_start_command(&mut ui, false).await?; + // Default behavior: start service and attach (use TUI) + run_start_command(&mut ui, false, false).await?; } - Some(Commands::Start { system }) => { - run_start_command(&mut ui, system).await?; + Some(Commands::Start { system, stdout }) => { + run_start_command(&mut ui, system, stdout).await?; } Some(Commands::Stop { system }) => { run_stop_command(system).await?; @@ -314,16 +338,16 @@ async fn main() -> Result { run_uninstall_command(system)?; } Some(Commands::RunEmbedded) => { - // Run agent directly (like old start behavior) - autorun(&mut ui, secret).await?; + // Run agent directly without TUI, printing logs to stdout + let mut embedded_ui = UI::new(UISettings { + auto_answer: Some(true), + log_only: true, + }); + autorun(&mut embedded_ui, secret).await?; } - Some(Commands::RunService { user }) => { - // Run as background daemon - let system_mode = !user; - if let Err(e) = service::run_daemon(system_mode).await { - tracing::error!("Daemon error: {}", e); - return Err(CliError::ServiceError(e.to_string())); - } + Some(Commands::RunService { .. }) => { + // Handled above before logging setup + unreachable!("RunService is handled before logging setup"); } Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), #[cfg(target_os = "linux")] @@ -353,7 +377,8 @@ async fn main() -> Result { cli.secret.clone(), cli.secret_path.clone(), cli.secret_wait, - ).await; + ) + .await; secrets.with_default_path().await; let path = secrets.get_path().unwrap(); @@ -369,7 +394,8 @@ async fn main() -> Result { cli.secret.clone(), cli.secret_path.clone(), cli.secret_wait, - ).await; + ) + .await; secrets.with_default_path().await; let path = secrets.get_path().unwrap(); println!("{}", path); @@ -411,18 +437,26 @@ async fn main() -> Result { } /// Run the start command: start service and attach to receive updates -async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliError> { +async fn run_start_command( + ui: &mut UI, + system_mode: bool, + stdout_mode: bool, +) -> Result<(), CliError> { use crate::service::ipc::{IpcClient, ServiceEvent}; use crate::service::manager::ensure_service_running; // Ensure service is running - ui.write_screen("Starting playit service...").await; + ui.write_screen("Ensuring playit service is running...") + .await; if let Err(e) = ensure_service_running(system_mode).await { - return Err(CliError::ServiceError(format!("Failed to start service: {}", e))); + return Err(CliError::ServiceError(format!( + "Failed to start service: {}", + e + ))); } - tracing::info!("Service is running"); + ui.write_screen("Service is running").await; // Connect to service via IPC ui.write_screen("Connecting to service...").await; @@ -430,7 +464,10 @@ async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliErro let mut client = match IpcClient::connect(system_mode).await { Ok(client) => client, Err(e) => { - return Err(CliError::IpcError(format!("Failed to connect to service: {}", e))); + return Err(CliError::IpcError(format!( + "Failed to connect to service: {}", + e + ))); } }; @@ -449,17 +486,24 @@ async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliErro Ok(event) => { match event { ServiceEvent::AgentData { .. } => { - if let Some(data) = event.to_agent_data() { - ui.update_agent_data(data); + if !stdout_mode { + if let Some(data) = event.to_agent_data() { + ui.update_agent_data(data); + } } } ServiceEvent::Stats { .. } => { - if let Some(stats) = event.to_connection_stats() { - ui.update_stats(stats); + if !stdout_mode { + if let Some(stats) = event.to_connection_stats() { + ui.update_stats(stats); + } } } ServiceEvent::Log { level, target, message, timestamp } => { - if let Some(log_capture) = ui.log_capture() { + if stdout_mode { + // Print log directly to stdout + println!("{} [{}] {}: {}", timestamp, level.to_uppercase(), target, message); + } else if let Some(log_capture) = ui.log_capture() { use crate::ui::log_capture::{LogEntry, LogLevel}; let log_level = match level.as_str() { "error" | "ERROR" => LogLevel::Error, @@ -485,16 +529,20 @@ async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliErro } } Err(e) => { - tracing::error!("IPC error: {}", e); - ui.write_screen(format!("Connection to service lost: {}", e)).await; + if stdout_mode { + eprintln!("Connection to service lost: {}", e); + } else { + tracing::error!("IPC error: {}", e); + ui.write_screen(format!("Connection to service lost: {}", e)).await; + } tokio::time::sleep(Duration::from_secs(2)).await; break; } } } - // Handle TUI tick + // Handle TUI tick (only when not in stdout mode) _ = tokio::time::sleep(Duration::from_millis(50)) => { - if ui.is_tui() { + if !stdout_mode && ui.is_tui() { match ui.tick_tui() { Ok(true) => {} // Continue Ok(false) => { @@ -513,7 +561,7 @@ async fn run_start_command(ui: &mut UI, system_mode: bool) -> Result<(), CliErro } // Handle Ctrl+C _ = tokio::signal::ctrl_c() => { - if ui.is_tui() { + if !stdout_mode && ui.is_tui() { ui.shutdown_tui()?; } println!("\nDetached from service. Service continues running in background."); @@ -585,7 +633,11 @@ async fn run_status_command(system_mode: bool) -> Result<(), CliError> { }; match client.status().await { - Ok(service::ipc::ServiceEvent::Status { running, pid, uptime_secs }) => { + Ok(service::ipc::ServiceEvent::Status { + running, + pid, + uptime_secs, + }) => { println!("Service status:"); println!(" Running: {}", running); println!(" PID: {}", pid); @@ -607,7 +659,8 @@ fn run_install_command(system_mode: bool) -> Result<(), CliError> { let controller = service::ServiceController::new(system_mode) .map_err(|e| CliError::ServiceError(e.to_string()))?; - controller.install() + controller + .install() .map_err(|e| CliError::ServiceError(e.to_string()))?; let mode_str = if system_mode { "system" } else { "user" }; @@ -625,7 +678,8 @@ fn run_uninstall_command(system_mode: bool) -> Result<(), CliError> { // Try to stop first let _ = controller.stop(); - controller.uninstall() + controller + .uninstall() .map_err(|e| CliError::ServiceError(e.to_string()))?; println!("Service uninstalled successfully"); diff --git a/packages/agent_cli/src/service/daemon.rs b/packages/agent_cli/src/service/daemon.rs index 8895496f..6be8bc92 100644 --- a/packages/agent_cli/src/service/daemon.rs +++ b/packages/agent_cli/src/service/daemon.rs @@ -1,9 +1,12 @@ //! Daemon entry point for running the agent as a background service. +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use playit_agent_core::network::origin_lookup::OriginLookup; +use playit_agent_core::agent_control::platform::current_platform; +use playit_api_client::api::Platform; +use playit_agent_core::network::origin_lookup::{OriginLookup, OriginResource, OriginTarget}; use playit_agent_core::network::tcp::tcp_settings::TcpSettings; use playit_agent_core::network::udp::udp_settings::UdpSettings; use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; @@ -12,15 +15,17 @@ use playit_agent_core::utils::now_milli; use playit_api_client::api::AccountStatus; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; use crate::playit_secret::PlayitSecret; use crate::service::ipc::{IpcError, IpcServer, ServiceEvent}; +use crate::ui::log_capture::IpcBroadcastLayer; use crate::ui::tui_app::{ AccountStatusInfo, AgentData, NoticeInfo, PendingTunnelInfo, TunnelInfo, }; use crate::API_BASE; -use playit_agent_core::network::origin_lookup::{OriginResource, OriginTarget}; -use std::net::SocketAddr; /// Error type for daemon operations #[derive(Debug)] @@ -52,6 +57,24 @@ impl From for DaemonError { pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { let start_time = now_milli(); + // Create broadcast channel for IPC events (including logs) + let (event_tx, _) = broadcast::channel::(256); + + // Set up tracing with IPC broadcast layer + let log_filter = + EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + + let ipc_log_layer = IpcBroadcastLayer::new(event_tx.clone()); + + // Also log to stderr for debugging (with color on Linux) + let use_ansi = current_platform() == Platform::Linux; + + tracing_subscriber::registry() + .with(log_filter) + .with(ipc_log_layer) + .with(tracing_subscriber::fmt::layer().with_ansi(use_ansi).with_writer(std::io::stderr)) + .init(); + tracing::info!("Starting playit daemon (system_mode={})", system_mode); // Shutdown signal @@ -59,7 +82,7 @@ pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { // Create IPC server (this also enforces single-instance) let ipc_server = Arc::new( - IpcServer::new(system_mode, cancel_token.clone()) + IpcServer::new_with_sender(system_mode, cancel_token.clone(), event_tx.clone()) .await .map_err(DaemonError::Ipc)?, ); diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs index 2b54bd35..9bf69a6c 100644 --- a/packages/agent_cli/src/service/ipc.rs +++ b/packages/agent_cli/src/service/ipc.rs @@ -7,8 +7,8 @@ use std::path::PathBuf; use std::sync::Arc; use interprocess::local_socket::{ - tokio::{prelude::*, Stream}, GenericFilePath, GenericNamespaced, ListenerOptions, ToFsName, ToNsName, + tokio::{Stream, prelude::*}, }; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; @@ -333,14 +333,18 @@ pub async fn is_instance_running(system_mode: bool) -> bool { async fn try_connect(socket_path: &str) -> Result { // Try namespaced socket first (for abstract sockets on Linux) if socket_path.starts_with('@') { - let name = socket_path[1..].to_ns_name::() - .map_err(|e| IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + let name = socket_path[1..] + .to_ns_name::() + .map_err(|e| { + IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) + })?; Stream::connect(name) .await .map_err(IpcError::ConnectionFailed) } else { - let name = socket_path.to_fs_name::() - .map_err(|e| IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + let name = socket_path.to_fs_name::().map_err(|e| { + IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) + })?; Stream::connect(name) .await .map_err(IpcError::ConnectionFailed) @@ -364,6 +368,18 @@ impl IpcServer { pub async fn new( system_mode: bool, cancel_token: tokio_util::sync::CancellationToken, + ) -> Result { + let (event_tx, _) = broadcast::channel(256); + Self::new_with_sender(system_mode, cancel_token, event_tx).await + } + + /// Create a new IPC server with an existing broadcast sender + /// + /// This allows sharing the event channel with other components (like logging) + pub async fn new_with_sender( + system_mode: bool, + cancel_token: tokio_util::sync::CancellationToken, + event_tx: broadcast::Sender, ) -> Result { use playit_agent_core::utils::now_milli; @@ -379,7 +395,6 @@ impl IpcServer { let _ = std::fs::remove_file(&socket_path); } - let (event_tx, _) = broadcast::channel(256); let start_time = now_milli(); Ok(IpcServer { @@ -427,17 +442,27 @@ impl IpcServer { Ok(()) } - async fn create_listener(&self) -> Result { + async fn create_listener( + &self, + ) -> Result { if self.socket_path.starts_with('@') { - let name = self.socket_path[1..].to_ns_name::() - .map_err(|e| IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + let name = self.socket_path[1..] + .to_ns_name::() + .map_err(|e| { + IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) + })?; ListenerOptions::new() .name(name) .create_tokio() .map_err(IpcError::BindFailed) } else { - let name = self.socket_path.clone().to_fs_name::() - .map_err(|e| IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)))?; + let name = self + .socket_path + .clone() + .to_fs_name::() + .map_err(|e| { + IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) + })?; ListenerOptions::new() .name(name) .create_tokio() diff --git a/packages/agent_cli/src/service/manager.rs b/packages/agent_cli/src/service/manager.rs index 34efbf83..c5b927d7 100644 --- a/packages/agent_cli/src/service/manager.rs +++ b/packages/agent_cli/src/service/manager.rs @@ -110,7 +110,9 @@ impl ServiceController { working_directory: None, environment: None, autostart: true, - restart_policy: service_manager::RestartPolicy::OnFailure { delay_secs: Some(5) }, + restart_policy: service_manager::RestartPolicy::OnFailure { + delay_secs: Some(5), + }, }; self.manager @@ -183,7 +185,7 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana // First check if service is already running via IPC if IpcClient::is_running(system_mode).await { - tracing::debug!("Service is already running"); + tracing::info!("Service is already running"); return Ok(()); } @@ -194,7 +196,7 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana controller.start() } Err(e) => { - tracing::debug!("Service manager not available: {}", e); + tracing::error!("Service manager not available: {}", e); Err(e) } }; @@ -204,7 +206,7 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana for _ in 0..50 { tokio::time::sleep(std::time::Duration::from_millis(100)).await; if IpcClient::is_running(system_mode).await { - tracing::debug!("Service started via service manager"); + tracing::info!("Service started via service manager"); return Ok(()); } } @@ -218,7 +220,7 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana for _ in 0..50 { tokio::time::sleep(std::time::Duration::from_millis(100)).await; if IpcClient::is_running(system_mode).await { - tracing::debug!("Daemon started successfully"); + tracing::info!("Daemon started successfully"); return Ok(()); } } @@ -231,7 +233,7 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana /// Spawn the daemon process directly (without service manager) fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { let exe = std::env::current_exe().map_err(ServiceManagerError::IoError)?; - + let mut args = vec!["run-service".to_string()]; if !system_mode { args.push("--user".to_string()); @@ -240,7 +242,7 @@ fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { #[cfg(unix)] { use std::process::{Command, Stdio}; - + // Spawn detached process Command::new(&exe) .args(&args) @@ -248,17 +250,19 @@ fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)))?; + .map_err(|e| { + ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)) + })?; } #[cfg(windows)] { - use std::process::{Command, Stdio}; use std::os::windows::process::CommandExt; - + use std::process::{Command, Stdio}; + const CREATE_NO_WINDOW: u32 = 0x08000000; const DETACHED_PROCESS: u32 = 0x00000008; - + Command::new(&exe) .args(&args) .stdin(Stdio::null()) @@ -266,7 +270,9 @@ fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { .stderr(Stdio::null()) .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) .spawn() - .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)))?; + .map_err(|e| { + ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)) + })?; } Ok(()) diff --git a/packages/agent_cli/src/ui/log_capture.rs b/packages/agent_cli/src/ui/log_capture.rs index e6737acc..e5ad10c7 100644 --- a/packages/agent_cli/src/ui/log_capture.rs +++ b/packages/agent_cli/src/ui/log_capture.rs @@ -1,9 +1,12 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +use tokio::sync::broadcast; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::Context; use tracing_subscriber::Layer; +use crate::service::ipc::ServiceEvent; + /// A log entry captured from tracing #[derive(Clone, Debug)] pub struct LogEntry { @@ -156,3 +159,38 @@ impl tracing::field::Visit for MessageVisitor { } } } + +/// Tracing layer that broadcasts log events via IPC +pub struct IpcBroadcastLayer { + event_tx: broadcast::Sender, +} + +impl IpcBroadcastLayer { + pub fn new(event_tx: broadcast::Sender) -> Self { + IpcBroadcastLayer { event_tx } + } +} + +impl Layer for IpcBroadcastLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + use playit_agent_core::utils::now_milli; + + let metadata = event.metadata(); + let level = LogLevel::from(metadata.level()); + let target = metadata.target().to_string(); + + // Extract the message from the event + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + + let log_event = ServiceEvent::Log { + level: level.as_str().to_string(), + target, + message: visitor.message, + timestamp: now_milli(), + }; + + // Ignore send errors (no subscribers connected) + let _ = self.event_tx.send(log_event); + } +} From 40dd04ac6939e3d5b9555235462d192af2f70b16 Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Thu, 15 Jan 2026 13:39:21 -0800 Subject: [PATCH 05/71] Improve log format --- Cargo.lock | 1 + packages/agent_cli/Cargo.toml | 1 + packages/agent_cli/src/main.rs | 34 +++++++++++++++++++----- packages/agent_cli/src/service/daemon.rs | 5 +++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1757dc67..f730e542 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ name = "playit-cli" version = "0.17.1" dependencies = [ + "chrono", "clap", "crossterm", "dirs 6.0.0", diff --git a/packages/agent_cli/Cargo.toml b/packages/agent_cli/Cargo.toml index 7af06da7..79a6b28f 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/agent_cli/Cargo.toml @@ -23,6 +23,7 @@ tracing-appender = { workspace = true } uuid = { workspace = true } dirs = { workspace = true } +chrono = "0.4" clap = { version = "4.5", features = ["derive"] } urlencoding = "2.1" serde_yaml = "0.9" diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index 74889f69..4a32820a 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -26,6 +26,18 @@ use crate::ui::{UI, UISettings}; pub static API_BASE: LazyLock = LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); +/// The name of the executable as invoked by the user +pub static EXE_NAME: LazyLock = LazyLock::new(|| { + std::env::args() + .next() + .and_then(|path| { + std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "playit".to_string()) +}); + pub mod autorun; pub mod playit_secret; pub mod service; @@ -501,8 +513,9 @@ async fn run_start_command( } ServiceEvent::Log { level, target, message, timestamp } => { if stdout_mode { - // Print log directly to stdout - println!("{} [{}] {}: {}", timestamp, level.to_uppercase(), target, message); + // Print log in tracing format + let formatted_ts = format_timestamp_millis(timestamp); + println!("{} {:>5} {}: {}", formatted_ts, level.to_uppercase(), target, message); } else if let Some(log_capture) = ui.log_capture() { use crate::ui::log_capture::{LogEntry, LogLevel}; let log_level = match level.as_str() { @@ -549,7 +562,7 @@ async fn run_start_command( // Quit requested - just detach, don't stop service ui.shutdown_tui()?; println!("Detached from service. Service continues running in background."); - println!("Use 'playit-cli stop' to stop the service."); + println!("Use '{} stop' to stop the service.", *EXE_NAME); break; } Err(e) => { @@ -565,7 +578,7 @@ async fn run_start_command( ui.shutdown_tui()?; } println!("\nDetached from service. Service continues running in background."); - println!("Use 'playit-cli stop' to stop the service."); + println!("Use '{} stop' to stop the service.", *EXE_NAME); break; } } @@ -665,7 +678,7 @@ fn run_install_command(system_mode: bool) -> Result<(), CliError> { let mode_str = if system_mode { "system" } else { "user" }; println!("Service installed successfully ({} mode)", mode_str); - println!("Use 'playit-cli start' to start the service"); + println!("Use '{} start' to start the service", *EXE_NAME); Ok(()) } @@ -724,7 +737,7 @@ pub async fn claim_exchange( .claim_setup(ReqClaimSetup { code: claim_code.to_string(), agent_type, - version: format!("playit-cli {}", env!("CARGO_PKG_VERSION")), + version: format!("{} {}", *EXE_NAME, env!("CARGO_PKG_VERSION")), }) .await; @@ -852,3 +865,12 @@ impl From for CliError { CliError::TunnelSetupError(e) } } + +/// Format a timestamp in milliseconds since epoch to RFC3339 format (like tracing uses) +fn format_timestamp_millis(millis: u64) -> String { + use chrono::{DateTime, Utc}; + + DateTime::::from_timestamp_millis(millis as i64) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()) + .unwrap_or_else(|| format!("{}ms", millis)) +} diff --git a/packages/agent_cli/src/service/daemon.rs b/packages/agent_cli/src/service/daemon.rs index 6be8bc92..93aa921d 100644 --- a/packages/agent_cli/src/service/daemon.rs +++ b/packages/agent_cli/src/service/daemon.rs @@ -203,7 +203,10 @@ async fn get_secret(secret: &mut PlayitSecret) -> Result { // For daemon mode, we don't do interactive setup // The user should run the CLI to set up the secret first - Err("No valid secret found. Please run 'playit-cli' to set up the agent first.".to_string()) + Err(format!( + "No valid secret found. Please run '{}' to set up the agent first.", + *crate::EXE_NAME + )) } /// Broadcast stats at regular intervals From 8594311cd4d477aaef2138dc3f5fd625f189717b Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 15 Jan 2026 15:34:25 -0800 Subject: [PATCH 06/71] fix comp for unix --- Cargo.lock | 20 ++++++++++++++++++++ packages/agent_cli/Cargo.toml | 6 ++++++ packages/agent_cli/src/service/ipc.rs | 1 + 3 files changed, 27 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f730e542..cbd43bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,6 +1133,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", + "redox_syscall", ] [[package]] @@ -1383,6 +1384,7 @@ dependencies = [ "dotenv", "hex", "interprocess", + "libc", "playit-agent-core", "playit-agent-proto", "playit-api-client", @@ -1400,6 +1402,7 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "whoami", "winres", ] @@ -2510,6 +2513,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -2609,6 +2618,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/packages/agent_cli/Cargo.toml b/packages/agent_cli/Cargo.toml index 79a6b28f..067bbf00 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/agent_cli/Cargo.toml @@ -40,5 +40,11 @@ playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } playit-api-client = { path = "../api_client", version = "0.2.0" } # playit-ping-monitor = { path = "../ping_monitor" } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +whoami = "1.5" + [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs index 9bf69a6c..ff357ddf 100644 --- a/packages/agent_cli/src/service/ipc.rs +++ b/packages/agent_cli/src/service/ipc.rs @@ -3,6 +3,7 @@ //! Uses JSON messages delimited by newlines over local sockets. use std::io; +#[cfg(target_os = "macos")] use std::path::PathBuf; use std::sync::Arc; From 8e2d5bf3b768c1fbdf0cb596f6a1d8f72bddde01 Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 15 Jan 2026 17:51:18 -0800 Subject: [PATCH 07/71] stage changes --- packages/agent_cli/src/main.rs | 172 +++++++++++++-- packages/agent_cli/src/service/ipc.rs | 9 +- packages/agent_cli/src/service/manager.rs | 258 ++++++++++++++++------ 3 files changed, 351 insertions(+), 88 deletions(-) diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs index 4a32820a..57572a04 100644 --- a/packages/agent_cli/src/main.rs +++ b/packages/agent_cli/src/main.rs @@ -23,6 +23,19 @@ use crate::signal_handle::get_signal_handle; use crate::ui::log_capture::LogCaptureLayer; use crate::ui::{UI, UISettings}; +/// Represents the service mode selection for the start command +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceMode { + /// Auto-detect: check user service first, then system service + #[cfg(not(target_os = "linux"))] + Auto, + /// Explicitly use user-level service (not available on Linux) + #[cfg(not(target_os = "linux"))] + User, + /// Explicitly use system-level service + System, +} + pub static API_BASE: LazyLock = LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); @@ -83,10 +96,16 @@ enum Commands { /// Start the playit agent (starts service and attaches to receive updates) Start { - /// Install as system-wide service (requires admin/root) + /// Run as system-wide service (requires admin/root) + #[cfg(not(target_os = "linux"))] #[arg(long)] system: bool, + /// Run as user service (default when starting new service) + #[cfg(not(target_os = "linux"))] + #[arg(long)] + user: bool, + /// Print logs to stdout instead of using TUI #[arg(short = 's', long)] stdout: bool, @@ -95,6 +114,7 @@ enum Commands { /// Stop the background service Stop { /// Stop system-wide service (requires admin/root) + #[cfg(not(target_os = "linux"))] #[arg(long)] system: bool, }, @@ -102,6 +122,7 @@ enum Commands { /// Show the status of the background service Status { /// Check system-wide service status + #[cfg(not(target_os = "linux"))] #[arg(long)] system: bool, }, @@ -109,6 +130,7 @@ enum Commands { /// Install the playit agent as a system service Install { /// Install as system-wide service (requires admin/root) + #[cfg(not(target_os = "linux"))] #[arg(long)] system: bool, }, @@ -116,6 +138,7 @@ enum Commands { /// Uninstall the playit agent system service Uninstall { /// Uninstall system-wide service (requires admin/root) + #[cfg(not(target_os = "linux"))] #[arg(long)] system: bool, }, @@ -126,7 +149,8 @@ enum Commands { /// Internal: Run as background service daemon #[command(hide = true)] RunService { - /// Run as user service (not system-wide) + /// Run as user service (not system-wide) - not available on Linux + #[cfg(not(target_os = "linux"))] #[arg(long)] user: bool, }, @@ -255,6 +279,7 @@ async fn main() -> Result { let _ = secret.with_default_path().await; // Handle run-service command first - it sets up its own logging + #[cfg(not(target_os = "linux"))] if let Some(Commands::RunService { user }) = &cli.command { let system_mode = !user; if let Err(e) = service::run_daemon(system_mode).await { @@ -264,6 +289,16 @@ async fn main() -> Result { return Ok(std::process::ExitCode::SUCCESS); } + // On Linux, run-service always runs in system mode (no user-level service) + #[cfg(target_os = "linux")] + if let Some(Commands::RunService { .. }) = &cli.command { + if let Err(e) = service::run_daemon(true).await { + eprintln!("Daemon error: {}", e); + return Ok(std::process::ExitCode::FAILURE); + } + return Ok(std::process::ExitCode::SUCCESS); + } + let log_only = cli.stdout; let log_path = cli.log_path.as_ref(); @@ -332,23 +367,66 @@ async fn main() -> Result { match cli.command { None => { // Default behavior: start service and attach (use TUI) - run_start_command(&mut ui, false, false).await?; + #[cfg(not(target_os = "linux"))] + { + run_start_command(&mut ui, ServiceMode::Auto, false).await?; + } + #[cfg(target_os = "linux")] + { + run_start_command(&mut ui, ServiceMode::System, false).await?; + } } - Some(Commands::Start { system, stdout }) => { - run_start_command(&mut ui, system, stdout).await?; + #[cfg(not(target_os = "linux"))] + Some(Commands::Start { system, user, stdout }) => { + let mode = match (user, system) { + (true, true) => { + return Err(CliError::ServiceError( + "Cannot specify both --user and --system".to_string(), + )); + } + (true, false) => ServiceMode::User, + (false, true) => ServiceMode::System, + (false, false) => ServiceMode::Auto, + }; + run_start_command(&mut ui, mode, stdout).await?; } + #[cfg(target_os = "linux")] + Some(Commands::Start { stdout }) => { + // On Linux, only system-level service is supported + run_start_command(&mut ui, ServiceMode::System, stdout).await?; + } + #[cfg(not(target_os = "linux"))] Some(Commands::Stop { system }) => { run_stop_command(system).await?; } + #[cfg(target_os = "linux")] + Some(Commands::Stop { .. }) => { + run_stop_command(true).await?; + } + #[cfg(not(target_os = "linux"))] Some(Commands::Status { system }) => { run_status_command(system).await?; } + #[cfg(target_os = "linux")] + Some(Commands::Status { .. }) => { + run_status_command(true).await?; + } + #[cfg(not(target_os = "linux"))] Some(Commands::Install { system }) => { run_install_command(system)?; } + #[cfg(target_os = "linux")] + Some(Commands::Install { .. }) => { + run_install_command(true)?; + } + #[cfg(not(target_os = "linux"))] Some(Commands::Uninstall { system }) => { run_uninstall_command(system)?; } + #[cfg(target_os = "linux")] + Some(Commands::Uninstall { .. }) => { + run_uninstall_command(true)?; + } Some(Commands::RunEmbedded) => { // Run agent directly without TUI, printing logs to stdout let mut embedded_ui = UI::new(UISettings { @@ -451,12 +529,42 @@ async fn main() -> Result { /// Run the start command: start service and attach to receive updates async fn run_start_command( ui: &mut UI, - system_mode: bool, + mode: ServiceMode, stdout_mode: bool, ) -> Result<(), CliError> { use crate::service::ipc::{IpcClient, ServiceEvent}; use crate::service::manager::ensure_service_running; + // Determine which service mode to use based on what's running + #[cfg(not(target_os = "linux"))] + let system_mode = match mode { + ServiceMode::User => false, + ServiceMode::System => true, + ServiceMode::Auto => { + // Check user service first, then system service + ui.write_screen("Checking for running services...").await; + + if IpcClient::is_running(false).await { + tracing::info!("Found running user service"); + false + } else if IpcClient::is_running(true).await { + tracing::info!("Found running system service"); + true + } else { + // Neither is running, default to user mode + tracing::info!("No running service found, will start user service"); + false + } + } + }; + + // On Linux, only system-level service is supported (via package manager's systemd unit) + #[cfg(target_os = "linux")] + let system_mode = { + let _ = mode; // silence unused variable warning + true + }; + // Ensure service is running ui.write_screen("Ensuring playit service is running...") .await; @@ -468,7 +576,9 @@ async fn run_start_command( ))); } - ui.write_screen("Service is running").await; + let mode_str = if system_mode { "system" } else { "user" }; + ui.write_screen(format!("Service is running ({})", mode_str)) + .await; // Connect to service via IPC ui.write_screen("Connecting to service...").await; @@ -605,15 +715,27 @@ async fn run_stop_command(system_mode: bool) -> Result<(), CliError> { } } - // Also try via service manager - match service::ServiceController::new(system_mode) { - Ok(controller) => { - if let Err(e) = controller.stop() { - tracing::warn!("Failed to stop via service manager: {}", e); - } + // On Linux, use systemctl to stop the service (only system-level is supported) + #[cfg(target_os = "linux")] + { + let _ = system_mode; // silence unused variable warning + if let Err(e) = service::manager::stop_systemd_service() { + tracing::warn!("Failed to stop via systemctl: {}", e); } - Err(e) => { - tracing::debug!("Service manager not available: {}", e); + } + + // On non-Linux, try via service manager + #[cfg(not(target_os = "linux"))] + { + match service::ServiceController::new(system_mode) { + Ok(controller) => { + if let Err(e) = controller.stop() { + tracing::warn!("Failed to stop via service manager: {}", e); + } + } + Err(e) => { + tracing::debug!("Service manager not available: {}", e); + } } } @@ -668,6 +790,16 @@ async fn run_status_command(system_mode: bool) -> Result<(), CliError> { } /// Run the install command +#[cfg(target_os = "linux")] +fn run_install_command(_system_mode: bool) -> Result<(), CliError> { + // On Linux, the service is managed by the package manager + Err(CliError::ServiceError( + "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.".to_string() + )) +} + +/// Run the install command +#[cfg(not(target_os = "linux"))] fn run_install_command(system_mode: bool) -> Result<(), CliError> { let controller = service::ServiceController::new(system_mode) .map_err(|e| CliError::ServiceError(e.to_string()))?; @@ -684,6 +816,16 @@ fn run_install_command(system_mode: bool) -> Result<(), CliError> { } /// Run the uninstall command +#[cfg(target_os = "linux")] +fn run_uninstall_command(_system_mode: bool) -> Result<(), CliError> { + // On Linux, the service is managed by the package manager + Err(CliError::ServiceError( + "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.".to_string() + )) +} + +/// Run the uninstall command +#[cfg(not(target_os = "linux"))] fn run_uninstall_command(system_mode: bool) -> Result<(), CliError> { let controller = service::ServiceController::new(system_mode) .map_err(|e| CliError::ServiceError(e.to_string()))?; diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs index ff357ddf..7aa5e3ab 100644 --- a/packages/agent_cli/src/service/ipc.rs +++ b/packages/agent_cli/src/service/ipc.rs @@ -282,14 +282,11 @@ impl ServiceEvent { /// Get the socket path for the IPC connection pub fn get_socket_path(system_mode: bool) -> String { + // On Linux, only system-level service is supported (via package manager's systemd unit) #[cfg(target_os = "linux")] { - if system_mode { - "/var/run/playit-agent.sock".to_string() - } else { - // Use abstract socket namespace on Linux - format!("@playit-agent-{}", unsafe { libc::getuid() }) - } + let _ = system_mode; // silence unused variable warning - always uses system path + "/var/run/playit-agent.sock".to_string() } #[cfg(target_os = "macos")] diff --git a/packages/agent_cli/src/service/manager.rs b/packages/agent_cli/src/service/manager.rs index c5b927d7..4fbc0429 100644 --- a/packages/agent_cli/src/service/manager.rs +++ b/packages/agent_cli/src/service/manager.rs @@ -1,10 +1,11 @@ //! Service manager integration for install/uninstall/start/stop. -use service_manager::{ - ServiceInstallCtx, ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx, - ServiceUninstallCtx, -}; +use service_manager::{ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx}; +#[cfg(not(target_os = "linux"))] +use service_manager::{ServiceInstallCtx, ServiceUninstallCtx}; +#[cfg(not(target_os = "linux"))] use std::ffi::OsString; +#[cfg(not(target_os = "linux"))] use std::path::PathBuf; /// Error type for service manager operations @@ -24,6 +25,8 @@ pub enum ServiceManagerError { NotFound, /// Generic IO error IoError(std::io::Error), + /// Service is managed by package manager (Linux) + ManagedByPackageManager, } impl std::fmt::Display for ServiceManagerError { @@ -42,6 +45,9 @@ impl std::fmt::Display for ServiceManagerError { ServiceManagerError::StopFailed(msg) => write!(f, "Failed to stop service: {}", msg), ServiceManagerError::NotFound => write!(f, "Service not found"), ServiceManagerError::IoError(e) => write!(f, "IO error: {}", e), + ServiceManagerError::ManagedByPackageManager => { + write!(f, "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.") + } } } } @@ -58,15 +64,18 @@ impl From for ServiceManagerError { pub struct ServiceController { manager: Box, label: ServiceLabel, + #[cfg(not(target_os = "linux"))] system_mode: bool, } impl ServiceController { /// Service label for playit agent const SERVICE_LABEL: &'static str = "gg.playit.agent"; + #[cfg(not(target_os = "linux"))] const USER_SERVICE_LABEL: &'static str = "gg.playit.agent.user"; /// Create a new service controller + #[cfg(not(target_os = "linux"))] pub fn new(system_mode: bool) -> Result { let manager = ::native() .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; @@ -86,53 +95,84 @@ impl ServiceController { }) } + /// Create a new service controller (Linux only supports system mode) + #[cfg(target_os = "linux")] + pub fn new(_system_mode: bool) -> Result { + let manager = ::native() + .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; + + let label: ServiceLabel = Self::SERVICE_LABEL.parse().unwrap(); + + Ok(ServiceController { manager, label }) + } + /// Get the path to the current executable + #[cfg(not(target_os = "linux"))] fn get_executable_path() -> Result { std::env::current_exe().map_err(ServiceManagerError::IoError) } /// Install the service pub fn install(&self) -> Result<(), ServiceManagerError> { - let program = Self::get_executable_path()?; - - // Build arguments for the service - let mut args = vec![OsString::from("run-service")]; - if !self.system_mode { - args.push(OsString::from("--user")); + // On Linux, the service is managed by the package manager + #[cfg(target_os = "linux")] + { + return Err(ServiceManagerError::ManagedByPackageManager); } - let ctx = ServiceInstallCtx { - label: self.label.clone(), - program, - args, - contents: None, - username: None, - working_directory: None, - environment: None, - autostart: true, - restart_policy: service_manager::RestartPolicy::OnFailure { - delay_secs: Some(5), - }, - }; - - self.manager - .install(ctx) - .map_err(|e| ServiceManagerError::InstallFailed(e.to_string()))?; - - Ok(()) + #[cfg(not(target_os = "linux"))] + { + let program = Self::get_executable_path()?; + + // Build arguments for the service + let args = if self.system_mode { + vec![OsString::from("run-service")] + } else { + vec![OsString::from("run-service"), OsString::from("--user")] + }; + + let ctx = ServiceInstallCtx { + label: self.label.clone(), + program, + args, + contents: None, + username: None, + working_directory: None, + environment: None, + autostart: true, + restart_policy: service_manager::RestartPolicy::OnFailure { + delay_secs: Some(5), + }, + }; + + self.manager + .install(ctx) + .map_err(|e| ServiceManagerError::InstallFailed(e.to_string()))?; + + Ok(()) + } } /// Uninstall the service pub fn uninstall(&self) -> Result<(), ServiceManagerError> { - let ctx = ServiceUninstallCtx { - label: self.label.clone(), - }; + // On Linux, the service is managed by the package manager + #[cfg(target_os = "linux")] + { + return Err(ServiceManagerError::ManagedByPackageManager); + } - self.manager - .uninstall(ctx) - .map_err(|e| ServiceManagerError::UninstallFailed(e.to_string()))?; + #[cfg(not(target_os = "linux"))] + { + let ctx = ServiceUninstallCtx { + label: self.label.clone(), + }; - Ok(()) + self.manager + .uninstall(ctx) + .map_err(|e| ServiceManagerError::UninstallFailed(e.to_string()))?; + + Ok(()) + } } /// Start the service @@ -174,9 +214,58 @@ impl ServiceController { } /// Check if running in system mode + #[cfg(not(target_os = "linux"))] pub fn is_system_mode(&self) -> bool { self.system_mode } + + /// Check if running in system mode (Linux always uses system mode) + #[cfg(target_os = "linux")] + pub fn is_system_mode(&self) -> bool { + true + } +} + +/// Start the playit systemd service on Linux using systemctl +#[cfg(target_os = "linux")] +fn start_systemd_service() -> Result<(), ServiceManagerError> { + use std::process::Command; + + let output = Command::new("systemctl") + .args(["start", "playit"]) + .output() + .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to run systemctl: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ServiceManagerError::StartFailed(format!( + "systemctl start playit failed: {}", + stderr + ))); + } + + Ok(()) +} + +/// Stop the playit systemd service on Linux using systemctl +#[cfg(target_os = "linux")] +pub fn stop_systemd_service() -> Result<(), ServiceManagerError> { + use std::process::Command; + + let output = Command::new("systemctl") + .args(["stop", "playit"]) + .output() + .map_err(|e| ServiceManagerError::StopFailed(format!("Failed to run systemctl: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ServiceManagerError::StopFailed(format!( + "systemctl stop playit failed: {}", + stderr + ))); + } + + Ok(()) } /// Ensure the service is running, starting it if necessary @@ -189,55 +278,90 @@ pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceMana return Ok(()); } - // Try to start via service manager first - let service_manager_result = match ServiceController::new(system_mode) { - Ok(controller) => { - tracing::info!("Starting service via service manager"); - controller.start() - } - Err(e) => { - tracing::error!("Service manager not available: {}", e); - Err(e) + // On Linux, only use systemctl to start the package-installed service (no user-level service support) + #[cfg(target_os = "linux")] + { + let _ = system_mode; // silence unused variable warning + tracing::info!("Starting playit service via systemctl"); + if let Err(e) = start_systemd_service() { + tracing::error!("Failed to start via systemctl: {}", e); + return Err(e); } - }; - // If service manager worked, wait for it to be ready - if service_manager_result.is_ok() { + // Wait for service to be ready for _ in 0..50 { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(system_mode).await { - tracing::info!("Service started via service manager"); + if IpcClient::is_running(true).await { + tracing::info!("Service started via systemctl"); return Ok(()); } } + + return Err(ServiceManagerError::StartFailed( + "Service did not start within timeout. Ensure the playit service is installed via your package manager.".to_string(), + )); } - // If service manager failed or service didn't start, spawn daemon directly - tracing::info!("Starting daemon process directly"); - spawn_daemon_process(system_mode)?; + // On non-Linux, try to start via service manager + #[cfg(not(target_os = "linux"))] + { + let service_manager_result = match ServiceController::new(system_mode) { + Ok(controller) => { + tracing::info!("Starting service via service manager"); + controller.start() + } + Err(e) => { + tracing::error!("Service manager not available: {}", e); + Err(e) + } + }; - // Wait for daemon to be ready - for _ in 0..50 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(system_mode).await { - tracing::info!("Daemon started successfully"); - return Ok(()); + // If service manager worked, wait for it to be ready + match service_manager_result { + Ok(_) => { + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if IpcClient::is_running(system_mode).await { + tracing::info!("Service started via service manager"); + return Ok(()); + } + } + } + Err(error) => { + tracing::error!(?error, "failed to start service with manager"); + } + } + + // If service manager failed or service didn't start, spawn daemon directly + tracing::info!("Starting daemon process directly"); + spawn_daemon_process(system_mode)?; + + // Wait for daemon to be ready + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if IpcClient::is_running(system_mode).await { + tracing::info!("Daemon started successfully"); + return Ok(()); + } } - } - Err(ServiceManagerError::StartFailed( - "Service did not start within timeout".to_string(), - )) + Err(ServiceManagerError::StartFailed( + "Service did not start within timeout".to_string(), + )) + } } /// Spawn the daemon process directly (without service manager) +/// Not available on Linux where only the package-managed systemd service is supported. +#[cfg(not(target_os = "linux"))] fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { let exe = std::env::current_exe().map_err(ServiceManagerError::IoError)?; - let mut args = vec!["run-service".to_string()]; - if !system_mode { - args.push("--user".to_string()); - } + let args = if system_mode { + vec!["run-service".to_string()] + } else { + vec!["run-service".to_string(), "--user".to_string()] + }; #[cfg(unix)] { From 725b57e5f7bf271beceef864518b5fede2d4cc4b Mon Sep 17 00:00:00 2001 From: Patrick Lorio Date: Mon, 30 Mar 2026 12:27:30 -0700 Subject: [PATCH 08/71] Move stuff around, improve ipc --- Cargo.lock | 75 +- Cargo.toml | 4 +- Dockerfile | 12 +- build-scripts/package-linux-deb.sh | 2 +- mac-deploy.sh | 2 +- packages/agent_cli/src/main.rs | 1018 ----------------- packages/agent_cli/src/service/daemon.rs | 357 ------ packages/agent_cli/src/service/ipc.rs | 612 ---------- packages/agent_cli/src/service/manager.rs | 403 ------- packages/agent_cli/src/service/mod.rs | 14 - packages/{agent_cli => playit-cli}/Cargo.toml | 6 +- packages/{agent_cli => playit-cli}/build.rs | 0 .../{agent_cli => playit-cli}/src/autorun.rs | 0 packages/playit-cli/src/client.rs | 362 ++++++ packages/playit-cli/src/main.rs | 598 ++++++++++ .../src/playit_secret.rs | 0 .../src/signal_handle.rs | 0 .../src/ui/log_capture.rs | 39 +- .../{agent_cli => playit-cli}/src/ui/mod.rs | 0 .../src/ui/tui_app.rs | 0 .../src/ui/widgets.rs | 0 .../{agent_cli => playit-cli}/src/util.rs | 0 .../{agent_cli => playit-cli}/wix/Banner.bmp | Bin .../{agent_cli => playit-cli}/wix/Product.ico | Bin .../{agent_cli => playit-cli}/wix/main.wxs | 4 +- packages/playit-ipc/Cargo.toml | 18 + packages/playit-ipc/src/ipc.rs | 353 ++++++ packages/playit-ipc/src/lib.rs | 2 + packages/playit-ipc/src/model.rs | 177 +++ packages/playitd/Cargo.toml | 32 + packages/playitd/src/bin/playitd.rs | 57 + packages/playitd/src/daemon.rs | 683 +++++++++++ packages/playitd/src/ipc_server.rs | 385 +++++++ packages/playitd/src/lib.rs | 9 + packages/playitd/src/logging.rs | 74 ++ packages/playitd/src/manager.rs | 143 +++ 36 files changed, 2966 insertions(+), 2475 deletions(-) delete mode 100644 packages/agent_cli/src/main.rs delete mode 100644 packages/agent_cli/src/service/daemon.rs delete mode 100644 packages/agent_cli/src/service/ipc.rs delete mode 100644 packages/agent_cli/src/service/manager.rs delete mode 100644 packages/agent_cli/src/service/mod.rs rename packages/{agent_cli => playit-cli}/Cargo.toml (92%) rename packages/{agent_cli => playit-cli}/build.rs (100%) rename packages/{agent_cli => playit-cli}/src/autorun.rs (100%) create mode 100644 packages/playit-cli/src/client.rs create mode 100644 packages/playit-cli/src/main.rs rename packages/{agent_cli => playit-cli}/src/playit_secret.rs (100%) rename packages/{agent_cli => playit-cli}/src/signal_handle.rs (100%) rename packages/{agent_cli => playit-cli}/src/ui/log_capture.rs (78%) rename packages/{agent_cli => playit-cli}/src/ui/mod.rs (100%) rename packages/{agent_cli => playit-cli}/src/ui/tui_app.rs (100%) rename packages/{agent_cli => playit-cli}/src/ui/widgets.rs (100%) rename packages/{agent_cli => playit-cli}/src/util.rs (100%) rename packages/{agent_cli => playit-cli}/wix/Banner.bmp (100%) rename packages/{agent_cli => playit-cli}/wix/Product.ico (100%) rename packages/{agent_cli => playit-cli}/wix/main.wxs (98%) create mode 100644 packages/playit-ipc/Cargo.toml create mode 100644 packages/playit-ipc/src/ipc.rs create mode 100644 packages/playit-ipc/src/lib.rs create mode 100644 packages/playit-ipc/src/model.rs create mode 100644 packages/playitd/Cargo.toml create mode 100644 packages/playitd/src/bin/playitd.rs create mode 100644 packages/playitd/src/daemon.rs create mode 100644 packages/playitd/src/ipc_server.rs create mode 100644 packages/playitd/src/lib.rs create mode 100644 packages/playitd/src/logging.rs create mode 100644 packages/playitd/src/manager.rs diff --git a/Cargo.lock b/Cargo.lock index cbd43bd3..68de3c92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -55,9 +55,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -209,9 +209,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -233,9 +233,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codepage" @@ -1383,17 +1383,17 @@ dependencies = [ "dirs 6.0.0", "dotenv", "hex", - "interprocess", "libc", "playit-agent-core", "playit-agent-proto", "playit-api-client", + "playit-ipc", + "playitd", "rand", "ratatui", "serde", "serde_json", "serde_yaml", - "service-manager", "tokio", "tokio-util", "toml 0.9.8", @@ -1406,6 +1406,41 @@ dependencies = [ "winres", ] +[[package]] +name = "playit-ipc" +version = "0.17.1" +dependencies = [ + "dirs 6.0.0", + "interprocess", + "serde", + "serde_json", + "tokio", + "whoami", +] + +[[package]] +name = "playitd" +version = "0.17.1" +dependencies = [ + "clap", + "dirs 6.0.0", + "dotenv", + "hex", + "interprocess", + "playit-agent-core", + "playit-api-client", + "playit-ipc", + "serde", + "serde_json", + "serde_yaml", + "service-manager", + "tokio", + "tokio-util", + "toml 0.9.8", + "tracing", + "tracing-subscriber", +] + [[package]] name = "plist" version = "1.8.0" @@ -1451,9 +1486,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1539,9 +1574,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2026,9 +2061,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index cb86ab05..6d2952a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] members = [ - "packages/agent_cli", + "packages/playit-cli", + "packages/playit-ipc", + "packages/playitd", "packages/agent_core", "packages/agent_proto", "packages/api_client", diff --git a/Dockerfile b/Dockerfile index f3e3fcc8..17accbbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,14 +6,16 @@ RUN apk --no-cache --update add build-base perl # Setup project structure with blank code so we can download libraries for better docker caching COPY Cargo.toml Cargo.lock ./ -RUN mkdir -p packages/agent_cli/src && mkdir -p packages/agent_core/src && mkdir -p packages/agent_proto/src && mkdir -p packages/ping_monitor/src && mkdir -p packages/api_client/src -COPY packages/agent_cli/Cargo.toml packages/agent_cli/Cargo.toml +RUN mkdir -p packages/playit-cli/src && mkdir -p packages/playit-ipc/src && mkdir -p packages/playitd/src && mkdir -p packages/agent_core/src && mkdir -p packages/agent_proto/src && mkdir -p packages/ping_monitor/src && mkdir -p packages/api_client/src +COPY packages/playit-cli/Cargo.toml packages/playit-cli/Cargo.toml +COPY packages/playit-ipc/Cargo.toml packages/playit-ipc/Cargo.toml +COPY packages/playitd/Cargo.toml packages/playitd/Cargo.toml COPY packages/agent_core/Cargo.toml packages/agent_core/Cargo.toml COPY packages/agent_proto/Cargo.toml packages/agent_proto/Cargo.toml COPY packages/api_client/Cargo.toml packages/api_client/Cargo.toml COPY packages/ping_monitor/Cargo.toml packages/ping_monitor/Cargo.toml -RUN touch packages/agent_cli/src/lib.rs && touch packages/agent_core/src/lib.rs && touch packages/agent_proto/src/lib.rs && touch packages/api_client/src/lib.rs && touch packages/ping_monitor/src/lib.rs +RUN touch packages/playit-cli/src/lib.rs && touch packages/playit-ipc/src/lib.rs && touch packages/playitd/src/lib.rs && touch packages/agent_core/src/lib.rs && touch packages/agent_proto/src/lib.rs && touch packages/api_client/src/lib.rs && touch packages/ping_monitor/src/lib.rs RUN cargo fetch # Build dep packages @@ -30,7 +32,9 @@ COPY packages/agent_core packages/agent_core RUN cargo build --release --package=playit-agent-core # Build CLI -COPY packages/agent_cli packages/agent_cli +COPY packages/playit-ipc packages/playit-ipc +COPY packages/playitd packages/playitd +COPY packages/playit-cli packages/playit-cli RUN cargo build --release --all ########## RUNTIME CONTAINER ########## diff --git a/build-scripts/package-linux-deb.sh b/build-scripts/package-linux-deb.sh index cf257eb2..685cb892 100644 --- a/build-scripts/package-linux-deb.sh +++ b/build-scripts/package-linux-deb.sh @@ -21,7 +21,7 @@ cd "${TEMP_DIR_NAME}" INSTALL_FOLDER="/opt/playit" ROOT_CARGO_FILE="${SCRIPT_DIR}/../Cargo.toml" -CARGO_FILE="${SCRIPT_DIR}/../packages/agent_cli/Cargo.toml" +CARGO_FILE="${SCRIPT_DIR}/../packages/playit-cli/Cargo.toml" VERSION=$(toml get "${ROOT_CARGO_FILE}" workspace.package.version | sed "s/\"//g") DEB_PACKAGE="playit_${DEB_ARCH}" diff --git a/mac-deploy.sh b/mac-deploy.sh index d70f58b2..2f8cf73f 100644 --- a/mac-deploy.sh +++ b/mac-deploy.sh @@ -1,7 +1,7 @@ # SHOULD BE RUN ON M1 MAC FOLDER=$(dirname "$0") -VERSION="$(toml get "${FOLDER}/packages/agent_cli/Cargo.toml" package.version | sed "s/\"//g")" +VERSION="$(toml get "${FOLDER}/packages/playit-cli/Cargo.toml" package.version | sed "s/\"//g")" bash ${FOLDER}/build-scripts/macos-app.sh diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs deleted file mode 100644 index 57572a04..00000000 --- a/packages/agent_cli/src/main.rs +++ /dev/null @@ -1,1018 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::sync::LazyLock; -use std::time::Duration; - -use clap::{Parser, Subcommand}; -use playit_agent_core::agent_control::platform::current_platform; -use playit_agent_core::agent_control::version::{help_register_version, register_platform}; -use rand::Rng; -use tracing_subscriber::EnvFilter; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use uuid::Uuid; - -use autorun::autorun; -use playit_agent_core::agent_control::errors::SetupError; -use playit_agent_core::utils::now_milli; -use playit_api_client::http_client::HttpClientError; -use playit_api_client::{PlayitApi, api::*}; -use playit_secret::PlayitSecret; - -use crate::signal_handle::get_signal_handle; -use crate::ui::log_capture::LogCaptureLayer; -use crate::ui::{UI, UISettings}; - -/// Represents the service mode selection for the start command -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ServiceMode { - /// Auto-detect: check user service first, then system service - #[cfg(not(target_os = "linux"))] - Auto, - /// Explicitly use user-level service (not available on Linux) - #[cfg(not(target_os = "linux"))] - User, - /// Explicitly use system-level service - System, -} - -pub static API_BASE: LazyLock = - LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); - -/// The name of the executable as invoked by the user -pub static EXE_NAME: LazyLock = LazyLock::new(|| { - std::env::args() - .next() - .and_then(|path| { - std::path::Path::new(&path) - .file_name() - .map(|name| name.to_string_lossy().to_string()) - }) - .unwrap_or_else(|| "playit".to_string()) -}); - -pub mod autorun; -pub mod playit_secret; -pub mod service; -pub mod signal_handle; -pub mod ui; -pub mod util; - -#[derive(Parser)] -#[command(name = "playit-cli")] -struct Cli { - /// Secret code for the agent - #[arg(long)] - secret: Option, - - /// Path to file containing secret - #[arg(long)] - secret_path: Option, - - /// Wait for secret_path file to read secret - #[arg(short = 'w', long)] - secret_wait: bool, - - /// Prints logs to stdout - #[arg(short = 's', long)] - stdout: bool, - - /// Path to write logs to - #[arg(short = 'l', long)] - log_path: Option, - - /// Overrides platform in version to be docker - #[arg(long)] - platform_docker: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Print version information - Version, - - /// Start the playit agent (starts service and attaches to receive updates) - Start { - /// Run as system-wide service (requires admin/root) - #[cfg(not(target_os = "linux"))] - #[arg(long)] - system: bool, - - /// Run as user service (default when starting new service) - #[cfg(not(target_os = "linux"))] - #[arg(long)] - user: bool, - - /// Print logs to stdout instead of using TUI - #[arg(short = 's', long)] - stdout: bool, - }, - - /// Stop the background service - Stop { - /// Stop system-wide service (requires admin/root) - #[cfg(not(target_os = "linux"))] - #[arg(long)] - system: bool, - }, - - /// Show the status of the background service - Status { - /// Check system-wide service status - #[cfg(not(target_os = "linux"))] - #[arg(long)] - system: bool, - }, - - /// Install the playit agent as a system service - Install { - /// Install as system-wide service (requires admin/root) - #[cfg(not(target_os = "linux"))] - #[arg(long)] - system: bool, - }, - - /// Uninstall the playit agent system service - Uninstall { - /// Uninstall system-wide service (requires admin/root) - #[cfg(not(target_os = "linux"))] - #[arg(long)] - system: bool, - }, - - /// Run the agent directly in foreground (for Docker/debugging) - RunEmbedded, - - /// Internal: Run as background service daemon - #[command(hide = true)] - RunService { - /// Run as user service (not system-wide) - not available on Linux - #[cfg(not(target_os = "linux"))] - #[arg(long)] - user: bool, - }, - - /// Removes the secret key on your system so the playit agent can be re-claimed - Reset, - - /// Shows the file path where the playit secret can be found - SecretPath, - - #[cfg(target_os = "linux")] - /// Setup playit for Linux service - Setup, - - /// Account management commands - Account { - #[command(subcommand)] - command: AccountCommands, - }, - - /// Setting up a new playit agent - #[command( - about = "Setting up a new playit agent", - long_about = "Provides a URL that can be visited to claim the agent and generate a secret key" - )] - Claim { - #[command(subcommand)] - command: ClaimCommands, - }, - - /// Manage tunnels - Tunnels { - #[command(subcommand)] - command: TunnelCommands, - }, -} - -#[derive(Subcommand)] -enum AccountCommands { - /// Generates a link to allow user to login - LoginUrl, -} - -#[derive(Subcommand)] -enum ClaimCommands { - /// Generates a random claim code - Generate, - - /// Print a claim URL given the code and options - Url { - /// Claim code - claim_code: String, - - /// Name for the agent - #[arg(long, default_value = "from-cli")] - name: String, - - /// The agent type - #[arg(long, default_value = "self-managed")] - r#type: String, - }, - - /// Exchanges the claim for the secret key - Exchange { - /// Claim code (see "claim generate") - claim_code: String, - - /// Number of seconds to wait (0=infinite) - #[arg(long, default_value = "0")] - wait: u32, - }, -} - -#[derive(Subcommand)] -enum TunnelCommands { - /// Create a tunnel if it doesn't exist with the parameters - Prepare { - /// Either "tcp", "udp", or "both" - port_type: String, - - /// Number of ports in a series to allocate - #[arg(default_value = "1")] - port_count: String, - - /// The tunnel type - #[arg(long)] - r#type: Option, - - /// Name of the tunnel - #[arg(long)] - name: Option, - - #[arg(long)] - exact: bool, - - #[arg(long)] - ignore_name: bool, - }, - - /// List tunnels (format "[tunnel-id] [port-type] [port-count] [public-address]") - List, -} - -#[tokio::main] -async fn main() -> Result { - let cli = Cli::parse(); - - /* register docker */ - { - let platform = if cli.platform_docker { - Platform::Docker - } else { - current_platform() - }; - - register_platform(platform); - - help_register_version( - env!("CARGO_PKG_VERSION"), - "308943e8-faef-4835-a2ba-270351f72aa3", - ); - } - - let mut secret = - PlayitSecret::from_args(cli.secret.clone(), cli.secret_path.clone(), cli.secret_wait).await; - let _ = secret.with_default_path().await; - - // Handle run-service command first - it sets up its own logging - #[cfg(not(target_os = "linux"))] - if let Some(Commands::RunService { user }) = &cli.command { - let system_mode = !user; - if let Err(e) = service::run_daemon(system_mode).await { - eprintln!("Daemon error: {}", e); - return Ok(std::process::ExitCode::FAILURE); - } - return Ok(std::process::ExitCode::SUCCESS); - } - - // On Linux, run-service always runs in system mode (no user-level service) - #[cfg(target_os = "linux")] - if let Some(Commands::RunService { .. }) = &cli.command { - if let Err(e) = service::run_daemon(true).await { - eprintln!("Daemon error: {}", e); - return Ok(std::process::ExitCode::FAILURE); - } - return Ok(std::process::ExitCode::SUCCESS); - } - - let log_only = cli.stdout; - let log_path = cli.log_path.as_ref(); - - // Check if Start command has --stdout flag - let start_stdout = matches!( - &cli.command, - Some(Commands::Start { stdout: true, .. } | Commands::RunEmbedded) - ); - - // Use log-only mode if stdout flag is set OR if a log file path is specified OR if start --stdout - let use_log_only = log_only || log_path.is_some() || start_stdout; - - // Create UI first so we can get its log capture - let mut ui = UI::new(UISettings { - auto_answer: None, - log_only: use_log_only, - }); - - /* setup logging */ - // Get log level from PLAYIT_LOG env var, defaulting to "info" - let log_filter = - EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - - let _guard = match (use_log_only, log_path) { - (true, Some(path)) => { - // Log to file - let write_path = match path.rsplit_once("/") { - Some((dir, file)) => tracing_appender::rolling::never(dir, file), - None => tracing_appender::rolling::never(".", path), - }; - - let (non_blocking, guard) = tracing_appender::non_blocking(write_path); - tracing_subscriber::fmt() - .with_ansi(false) - .with_writer(non_blocking) - .with_env_filter(log_filter) - .init(); - Some(guard) - } - (true, None) => { - // Log to stdout (for -s flag, run-embedded, or start -s) - let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); - tracing_subscriber::fmt() - .with_ansi(current_platform() == Platform::Linux) - .with_writer(non_blocking) - .with_env_filter(log_filter) - .init(); - Some(guard) - } - (false, Some(_)) => { - panic!("log_path set but use_log_only is false - this shouldn't happen"); - } - (false, None) => { - // TUI mode - set up log capture layer with filter - if let Some(log_capture) = ui.log_capture() { - let capture_layer = LogCaptureLayer::new(log_capture); - tracing_subscriber::registry() - .with(log_filter) - .with(capture_layer) - .init(); - } - None - } - }; - - match cli.command { - None => { - // Default behavior: start service and attach (use TUI) - #[cfg(not(target_os = "linux"))] - { - run_start_command(&mut ui, ServiceMode::Auto, false).await?; - } - #[cfg(target_os = "linux")] - { - run_start_command(&mut ui, ServiceMode::System, false).await?; - } - } - #[cfg(not(target_os = "linux"))] - Some(Commands::Start { system, user, stdout }) => { - let mode = match (user, system) { - (true, true) => { - return Err(CliError::ServiceError( - "Cannot specify both --user and --system".to_string(), - )); - } - (true, false) => ServiceMode::User, - (false, true) => ServiceMode::System, - (false, false) => ServiceMode::Auto, - }; - run_start_command(&mut ui, mode, stdout).await?; - } - #[cfg(target_os = "linux")] - Some(Commands::Start { stdout }) => { - // On Linux, only system-level service is supported - run_start_command(&mut ui, ServiceMode::System, stdout).await?; - } - #[cfg(not(target_os = "linux"))] - Some(Commands::Stop { system }) => { - run_stop_command(system).await?; - } - #[cfg(target_os = "linux")] - Some(Commands::Stop { .. }) => { - run_stop_command(true).await?; - } - #[cfg(not(target_os = "linux"))] - Some(Commands::Status { system }) => { - run_status_command(system).await?; - } - #[cfg(target_os = "linux")] - Some(Commands::Status { .. }) => { - run_status_command(true).await?; - } - #[cfg(not(target_os = "linux"))] - Some(Commands::Install { system }) => { - run_install_command(system)?; - } - #[cfg(target_os = "linux")] - Some(Commands::Install { .. }) => { - run_install_command(true)?; - } - #[cfg(not(target_os = "linux"))] - Some(Commands::Uninstall { system }) => { - run_uninstall_command(system)?; - } - #[cfg(target_os = "linux")] - Some(Commands::Uninstall { .. }) => { - run_uninstall_command(true)?; - } - Some(Commands::RunEmbedded) => { - // Run agent directly without TUI, printing logs to stdout - let mut embedded_ui = UI::new(UISettings { - auto_answer: Some(true), - log_only: true, - }); - autorun(&mut embedded_ui, secret).await?; - } - Some(Commands::RunService { .. }) => { - // Handled above before logging setup - unreachable!("RunService is handled before logging setup"); - } - Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), - #[cfg(target_os = "linux")] - Some(Commands::Setup) => { - let mut secret = PlayitSecret::linux_service(); - let key = secret - .ensure_valid(&mut ui) - .await? - .get_or_setup(&mut ui) - .await?; - - let api = PlayitApi::create(API_BASE.to_string(), Some(key)); - if let Ok(session) = api.login_guest().await { - ui.write_screen(format!( - "Guest login:\nhttps://playit.gg/login/guest-account/{}", - session.session_key - )) - .await; - tokio::time::sleep(Duration::from_secs(10)).await; - } - - ui.write_screen("Playit setup, secret written to /etc/playit/playit.toml") - .await; - } - Some(Commands::Reset) => loop { - let mut secrets = PlayitSecret::from_args( - cli.secret.clone(), - cli.secret_path.clone(), - cli.secret_wait, - ) - .await; - secrets.with_default_path().await; - - let path = secrets.get_path().unwrap(); - if !tokio::fs::try_exists(path).await.unwrap_or(false) { - break; - } - - tokio::fs::remove_file(path).await.unwrap(); - println!("deleted secret at: {}", path); - }, - Some(Commands::SecretPath) => { - let mut secrets = PlayitSecret::from_args( - cli.secret.clone(), - cli.secret_path.clone(), - cli.secret_wait, - ) - .await; - secrets.with_default_path().await; - let path = secrets.get_path().unwrap(); - println!("{}", path); - } - Some(Commands::Account { command }) => match command { - AccountCommands::LoginUrl => { - let api = secret.create_api().await?; - let session = api.login_guest().await?; - println!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ) - } - }, - Some(Commands::Claim { command }) => match command { - ClaimCommands::Generate => { - ui.write_screen(claim_generate()).await; - } - ClaimCommands::Url { claim_code, .. } => { - ui.write_screen(claim_url(&claim_code)?.to_string()).await; - } - ClaimCommands::Exchange { claim_code, wait } => { - let secret_key = - claim_exchange(&mut ui, &claim_code, ClaimAgentType::SelfManaged, wait).await?; - ui.write_screen(secret_key).await; - } - }, - Some(Commands::Tunnels { command }) => match command { - TunnelCommands::Prepare { .. } => { - return Err(CliError::NotImplemented); - } - TunnelCommands::List => { - return Err(CliError::NotImplemented); - } - }, - } - - Ok(std::process::ExitCode::SUCCESS) -} - -/// Run the start command: start service and attach to receive updates -async fn run_start_command( - ui: &mut UI, - mode: ServiceMode, - stdout_mode: bool, -) -> Result<(), CliError> { - use crate::service::ipc::{IpcClient, ServiceEvent}; - use crate::service::manager::ensure_service_running; - - // Determine which service mode to use based on what's running - #[cfg(not(target_os = "linux"))] - let system_mode = match mode { - ServiceMode::User => false, - ServiceMode::System => true, - ServiceMode::Auto => { - // Check user service first, then system service - ui.write_screen("Checking for running services...").await; - - if IpcClient::is_running(false).await { - tracing::info!("Found running user service"); - false - } else if IpcClient::is_running(true).await { - tracing::info!("Found running system service"); - true - } else { - // Neither is running, default to user mode - tracing::info!("No running service found, will start user service"); - false - } - } - }; - - // On Linux, only system-level service is supported (via package manager's systemd unit) - #[cfg(target_os = "linux")] - let system_mode = { - let _ = mode; // silence unused variable warning - true - }; - - // Ensure service is running - ui.write_screen("Ensuring playit service is running...") - .await; - - if let Err(e) = ensure_service_running(system_mode).await { - return Err(CliError::ServiceError(format!( - "Failed to start service: {}", - e - ))); - } - - let mode_str = if system_mode { "system" } else { "user" }; - ui.write_screen(format!("Service is running ({})", mode_str)) - .await; - - // Connect to service via IPC - ui.write_screen("Connecting to service...").await; - - let mut client = match IpcClient::connect(system_mode).await { - Ok(client) => client, - Err(e) => { - return Err(CliError::IpcError(format!( - "Failed to connect to service: {}", - e - ))); - } - }; - - // Subscribe to updates - if let Err(e) = client.subscribe().await { - return Err(CliError::IpcError(format!("Failed to subscribe: {}", e))); - } - - tracing::info!("Connected to service, receiving updates"); - - // Main loop: receive events and update UI - loop { - tokio::select! { - event_result = client.recv_event() => { - match event_result { - Ok(event) => { - match event { - ServiceEvent::AgentData { .. } => { - if !stdout_mode { - if let Some(data) = event.to_agent_data() { - ui.update_agent_data(data); - } - } - } - ServiceEvent::Stats { .. } => { - if !stdout_mode { - if let Some(stats) = event.to_connection_stats() { - ui.update_stats(stats); - } - } - } - ServiceEvent::Log { level, target, message, timestamp } => { - if stdout_mode { - // Print log in tracing format - let formatted_ts = format_timestamp_millis(timestamp); - println!("{} {:>5} {}: {}", formatted_ts, level.to_uppercase(), target, message); - } else if let Some(log_capture) = ui.log_capture() { - use crate::ui::log_capture::{LogEntry, LogLevel}; - let log_level = match level.as_str() { - "error" | "ERROR" => LogLevel::Error, - "warn" | "WARN" => LogLevel::Warn, - "info" | "INFO" => LogLevel::Info, - "debug" | "DEBUG" => LogLevel::Debug, - _ => LogLevel::Trace, - }; - log_capture.push(LogEntry { - level: log_level, - target, - message, - timestamp, - }); - } - } - ServiceEvent::Status { .. } => { - // Status updates are handled separately - } - ServiceEvent::Ack { .. } | ServiceEvent::Error { .. } => { - // Acknowledgements handled in specific commands - } - } - } - Err(e) => { - if stdout_mode { - eprintln!("Connection to service lost: {}", e); - } else { - tracing::error!("IPC error: {}", e); - ui.write_screen(format!("Connection to service lost: {}", e)).await; - } - tokio::time::sleep(Duration::from_secs(2)).await; - break; - } - } - } - // Handle TUI tick (only when not in stdout mode) - _ = tokio::time::sleep(Duration::from_millis(50)) => { - if !stdout_mode && ui.is_tui() { - match ui.tick_tui() { - Ok(true) => {} // Continue - Ok(false) => { - // Quit requested - just detach, don't stop service - ui.shutdown_tui()?; - println!("Detached from service. Service continues running in background."); - println!("Use '{} stop' to stop the service.", *EXE_NAME); - break; - } - Err(e) => { - ui.shutdown_tui()?; - return Err(e); - } - } - } - } - // Handle Ctrl+C - _ = tokio::signal::ctrl_c() => { - if !stdout_mode && ui.is_tui() { - ui.shutdown_tui()?; - } - println!("\nDetached from service. Service continues running in background."); - println!("Use '{} stop' to stop the service.", *EXE_NAME); - break; - } - } - } - - Ok(()) -} - -/// Run the stop command -async fn run_stop_command(system_mode: bool) -> Result<(), CliError> { - use crate::service::ipc::IpcClient; - - // First try to stop via IPC - if let Ok(mut client) = IpcClient::connect(system_mode).await { - match client.stop().await { - Ok(_) => { - println!("Service stop requested"); - // Wait a bit for service to stop - tokio::time::sleep(Duration::from_secs(1)).await; - } - Err(e) => { - tracing::warn!("Failed to send stop via IPC: {}", e); - } - } - } - - // On Linux, use systemctl to stop the service (only system-level is supported) - #[cfg(target_os = "linux")] - { - let _ = system_mode; // silence unused variable warning - if let Err(e) = service::manager::stop_systemd_service() { - tracing::warn!("Failed to stop via systemctl: {}", e); - } - } - - // On non-Linux, try via service manager - #[cfg(not(target_os = "linux"))] - { - match service::ServiceController::new(system_mode) { - Ok(controller) => { - if let Err(e) = controller.stop() { - tracing::warn!("Failed to stop via service manager: {}", e); - } - } - Err(e) => { - tracing::debug!("Service manager not available: {}", e); - } - } - } - - // Verify service stopped - tokio::time::sleep(Duration::from_millis(500)).await; - if !IpcClient::is_running(system_mode).await { - println!("Service stopped"); - } else { - println!("Service may still be running"); - } - - Ok(()) -} - -/// Run the status command -async fn run_status_command(system_mode: bool) -> Result<(), CliError> { - use crate::service::ipc::IpcClient; - - if !IpcClient::is_running(system_mode).await { - println!("Service is not running"); - return Ok(()); - } - - let mut client = match IpcClient::connect(system_mode).await { - Ok(client) => client, - Err(e) => { - println!("Service appears to be running but cannot connect: {}", e); - return Ok(()); - } - }; - - match client.status().await { - Ok(service::ipc::ServiceEvent::Status { - running, - pid, - uptime_secs, - }) => { - println!("Service status:"); - println!(" Running: {}", running); - println!(" PID: {}", pid); - println!(" Uptime: {} seconds", uptime_secs); - } - Ok(other) => { - println!("Unexpected response: {:?}", other); - } - Err(e) => { - println!("Failed to get status: {}", e); - } - } - - Ok(()) -} - -/// Run the install command -#[cfg(target_os = "linux")] -fn run_install_command(_system_mode: bool) -> Result<(), CliError> { - // On Linux, the service is managed by the package manager - Err(CliError::ServiceError( - "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.".to_string() - )) -} - -/// Run the install command -#[cfg(not(target_os = "linux"))] -fn run_install_command(system_mode: bool) -> Result<(), CliError> { - let controller = service::ServiceController::new(system_mode) - .map_err(|e| CliError::ServiceError(e.to_string()))?; - - controller - .install() - .map_err(|e| CliError::ServiceError(e.to_string()))?; - - let mode_str = if system_mode { "system" } else { "user" }; - println!("Service installed successfully ({} mode)", mode_str); - println!("Use '{} start' to start the service", *EXE_NAME); - - Ok(()) -} - -/// Run the uninstall command -#[cfg(target_os = "linux")] -fn run_uninstall_command(_system_mode: bool) -> Result<(), CliError> { - // On Linux, the service is managed by the package manager - Err(CliError::ServiceError( - "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.".to_string() - )) -} - -/// Run the uninstall command -#[cfg(not(target_os = "linux"))] -fn run_uninstall_command(system_mode: bool) -> Result<(), CliError> { - let controller = service::ServiceController::new(system_mode) - .map_err(|e| CliError::ServiceError(e.to_string()))?; - - // Try to stop first - let _ = controller.stop(); - - controller - .uninstall() - .map_err(|e| CliError::ServiceError(e.to_string()))?; - - println!("Service uninstalled successfully"); - - Ok(()) -} - -pub fn claim_generate() -> String { - let mut buffer = [0u8; 5]; - rand::rng().fill(&mut buffer); - hex::encode(&buffer) -} - -pub fn claim_url(code: &str) -> Result { - if hex::decode(code).is_err() { - return Err(CliError::InvalidClaimCode); - } - - Ok(format!("https://playit.gg/claim/{}", code,)) -} - -pub async fn claim_exchange( - ui: &mut UI, - claim_code: &str, - agent_type: ClaimAgentType, - wait_sec: u32, -) -> Result { - let api = PlayitApi::create(API_BASE.to_string(), None); - - let end_at = if wait_sec == 0 { - u64::MAX - } else { - now_milli() + (wait_sec as u64) * 1000 - }; - - { - let _close_guard = get_signal_handle().close_guard(); - let mut last_message = "Preparing Setup".to_string(); - - loop { - let setup_res = api - .claim_setup(ReqClaimSetup { - code: claim_code.to_string(), - agent_type, - version: format!("{} {}", *EXE_NAME, env!("CARGO_PKG_VERSION")), - }) - .await; - - let setup = match setup_res { - Ok(v) => v, - Err(error) => { - tracing::error!(?error, "Failed loading claim setup"); - ui.write_screen(format!("{}\n\nError: {:?}", last_message, error)) - .await; - tokio::time::sleep(Duration::from_secs(2)).await; - continue; - } - }; - - last_message = match setup { - ClaimSetupResponse::WaitingForUserVisit => { - format!("Visit link to setup {}", claim_url(claim_code)?) - } - ClaimSetupResponse::WaitingForUser => { - format!("Approve program at {}", claim_url(claim_code)?) - } - ClaimSetupResponse::UserAccepted => { - ui.write_screen("Program approved :). Secret code being setup.") - .await; - break; - } - ClaimSetupResponse::UserRejected => { - ui.write_screen("Program rejected :(").await; - tokio::time::sleep(Duration::from_secs(3)).await; - return Err(CliError::AgentClaimRejected); - } - }; - - ui.write_screen(&last_message).await; - tokio::time::sleep(Duration::from_millis(200)).await; - } - } - - let secret_key = loop { - match api - .claim_exchange(ReqClaimExchange { - code: claim_code.to_string(), - }) - .await - { - Ok(res) => break res.secret_key, - Err(ApiError::Fail(status)) => { - let msg = format!("code \"{}\" not ready, {:?}", claim_code, status); - ui.write_screen(msg).await; - } - Err(error) => return Err(error.into()), - }; - - if now_milli() > end_at { - ui.write_screen("you took too long to approve the program, closing") - .await; - tokio::time::sleep(Duration::from_secs(2)).await; - return Err(CliError::TimedOut); - } - - tokio::time::sleep(Duration::from_secs(2)).await; - }; - - Ok(secret_key) -} - -#[derive(Debug)] -pub enum CliError { - InvalidClaimCode, - NotImplemented, - MissingSecret, - MalformedSecret, - InvalidSecret, - RenderError(std::io::Error), - SecretFileLoadError, - SecretFileWriteError(std::io::Error), - SecretFilePathMissing, - InvalidPortType, - InvalidPortCount, - InvalidMappingOverride, - AgentClaimRejected, - InvalidConfigFile, - TunnelNotFound(Uuid), - TimedOut, - AnswerNotProvided, - TunnelOverwrittenAlready(Uuid), - ResourceNotFoundAfterCreate(Uuid), - RequestError(HttpClientError), - ApiError(ApiResponseError), - ApiFail(String), - TunnelSetupError(SetupError), - ServiceError(String), - IpcError(String), -} - -impl Error for CliError {} - -impl Display for CliError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -} - -impl From> for CliError { - fn from(e: ApiError) -> Self { - match e { - ApiError::ApiError(e) => CliError::ApiError(e), - ApiError::ClientError(e) => CliError::RequestError(e), - ApiError::Fail(fail) => CliError::ApiFail(serde_json::to_string(&fail).unwrap()), - } - } -} - -impl From> for CliError { - fn from(e: ApiErrorNoFail) -> Self { - match e { - ApiErrorNoFail::ApiError(e) => CliError::ApiError(e), - ApiErrorNoFail::ClientError(e) => CliError::RequestError(e), - } - } -} - -impl From for CliError { - fn from(e: SetupError) -> Self { - CliError::TunnelSetupError(e) - } -} - -/// Format a timestamp in milliseconds since epoch to RFC3339 format (like tracing uses) -fn format_timestamp_millis(millis: u64) -> String { - use chrono::{DateTime, Utc}; - - DateTime::::from_timestamp_millis(millis as i64) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()) - .unwrap_or_else(|| format!("{}ms", millis)) -} diff --git a/packages/agent_cli/src/service/daemon.rs b/packages/agent_cli/src/service/daemon.rs deleted file mode 100644 index 93aa921d..00000000 --- a/packages/agent_cli/src/service/daemon.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Daemon entry point for running the agent as a background service. - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use playit_agent_core::agent_control::platform::current_platform; -use playit_api_client::api::Platform; -use playit_agent_core::network::origin_lookup::{OriginLookup, OriginResource, OriginTarget}; -use playit_agent_core::network::tcp::tcp_settings::TcpSettings; -use playit_agent_core::network::udp::udp_settings::UdpSettings; -use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; -use playit_agent_core::stats::AgentStats; -use playit_agent_core::utils::now_milli; -use playit_api_client::api::AccountStatus; -use tokio::sync::broadcast; -use tokio_util::sync::CancellationToken; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; - -use crate::playit_secret::PlayitSecret; -use crate::service::ipc::{IpcError, IpcServer, ServiceEvent}; -use crate::ui::log_capture::IpcBroadcastLayer; -use crate::ui::tui_app::{ - AccountStatusInfo, AgentData, NoticeInfo, PendingTunnelInfo, TunnelInfo, -}; -use crate::API_BASE; - -/// Error type for daemon operations -#[derive(Debug)] -pub enum DaemonError { - Ipc(IpcError), - SecretError(String), - SetupError(String), -} - -impl std::fmt::Display for DaemonError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DaemonError::Ipc(e) => write!(f, "IPC error: {}", e), - DaemonError::SecretError(e) => write!(f, "Secret error: {}", e), - DaemonError::SetupError(e) => write!(f, "Setup error: {}", e), - } - } -} - -impl std::error::Error for DaemonError {} - -impl From for DaemonError { - fn from(e: IpcError) -> Self { - DaemonError::Ipc(e) - } -} - -/// Run the daemon (background service) -pub async fn run_daemon(system_mode: bool) -> Result<(), DaemonError> { - let start_time = now_milli(); - - // Create broadcast channel for IPC events (including logs) - let (event_tx, _) = broadcast::channel::(256); - - // Set up tracing with IPC broadcast layer - let log_filter = - EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - - let ipc_log_layer = IpcBroadcastLayer::new(event_tx.clone()); - - // Also log to stderr for debugging (with color on Linux) - let use_ansi = current_platform() == Platform::Linux; - - tracing_subscriber::registry() - .with(log_filter) - .with(ipc_log_layer) - .with(tracing_subscriber::fmt::layer().with_ansi(use_ansi).with_writer(std::io::stderr)) - .init(); - - tracing::info!("Starting playit daemon (system_mode={})", system_mode); - - // Shutdown signal - let cancel_token = CancellationToken::new(); - - // Create IPC server (this also enforces single-instance) - let ipc_server = Arc::new( - IpcServer::new_with_sender(system_mode, cancel_token.clone(), event_tx.clone()) - .await - .map_err(DaemonError::Ipc)?, - ); - let event_tx = ipc_server.event_sender(); - - tracing::info!("IPC server created"); - - // Load secret - let mut secret = PlayitSecret::from_args(None, None, false).await; - let _ = secret.with_default_path().await; - - // Get or wait for valid secret - let secret_code = match get_secret(&mut secret).await { - Ok(code) => code, - Err(e) => { - tracing::error!("Failed to get secret: {}", e); - return Err(DaemonError::SecretError(e)); - } - }; - - let api = playit_api_client::PlayitApi::create(API_BASE.to_string(), Some(secret_code.clone())); - - // Setup origin lookup - let lookup = Arc::new(OriginLookup::default()); - match api.v1_agents_rundata().await { - Ok(data) => lookup.update_from_run_data(&data).await, - Err(e) => { - tracing::warn!("Failed to load initial rundata: {:?}", e); - } - } - - // Create agent settings - let settings = PlayitAgentSettings { - udp_settings: UdpSettings::default(), - tcp_settings: TcpSettings::default(), - api_url: API_BASE.to_string(), - secret_key: secret_code, - }; - - // Start the agent - let (runner, stats) = match PlayitAgent::new(settings, lookup.clone()).await { - Ok(res) => { - let stats = res.stats(); - (res, stats) - } - Err(e) => { - tracing::error!("Failed to create agent: {:?}", e); - return Err(DaemonError::SetupError(format!("Failed to create agent: {:?}", e))); - } - }; - - tracing::info!("Agent created, starting tasks"); - - // Spawn the agent runner - let agent_handle = tokio::spawn(runner.run()); - - // Spawn IPC server - let ipc_handle = { - let server = ipc_server.clone(); - tokio::spawn(async move { - if let Err(e) = server.run().await { - tracing::error!("IPC server error: {}", e); - } - }) - }; - - // Spawn stats broadcaster - let stats_handle = { - let event_tx = event_tx.clone(); - let token = cancel_token.clone(); - tokio::spawn(broadcast_stats(stats, event_tx, token)) - }; - - // Spawn agent data fetcher/broadcaster - let data_handle = { - let event_tx = event_tx.clone(); - let token = cancel_token.clone(); - tokio::spawn(broadcast_agent_data(api, lookup, event_tx, token, start_time)) - }; - - // Wait for shutdown signal (Ctrl+C, stop command, or agent completion) - tokio::select! { - _ = tokio::signal::ctrl_c() => { - tracing::info!("Received Ctrl+C, shutting down"); - } - _ = cancel_token.cancelled() => { - tracing::info!("Shutdown requested via IPC"); - } - _ = agent_handle => { - tracing::info!("Agent task completed"); - } - } - - // Signal shutdown to all tasks - cancel_token.cancel(); - - // Wait for tasks to complete - let _ = tokio::time::timeout(Duration::from_secs(5), async { - let _ = ipc_handle.await; - let _ = stats_handle.await; - let _ = data_handle.await; - }) - .await; - - tracing::info!("Daemon shutdown complete"); - Ok(()) -} - -/// Get secret code, waiting if necessary -async fn get_secret(secret: &mut PlayitSecret) -> Result { - // Try to get existing secret - match secret.get().await { - Ok(code) => return Ok(code), - Err(e) => { - tracing::warn!("No valid secret found: {:?}", e); - } - } - - // For daemon mode, we don't do interactive setup - // The user should run the CLI to set up the secret first - Err(format!( - "No valid secret found. Please run '{}' to set up the agent first.", - *crate::EXE_NAME - )) -} - -/// Broadcast stats at regular intervals -async fn broadcast_stats( - stats: AgentStats, - event_tx: broadcast::Sender, - cancel_token: CancellationToken, -) { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - - loop { - tokio::select! { - _ = interval.tick() => { - let snapshot = stats.snapshot(); - let event = ServiceEvent::Stats { - bytes_in: snapshot.bytes_in, - bytes_out: snapshot.bytes_out, - active_tcp: snapshot.active_tcp, - active_udp: snapshot.active_udp, - }; - // Ignore send errors (no subscribers) - let _ = event_tx.send(event); - } - _ = cancel_token.cancelled() => { - break; - } - } - } -} - -/// Fetch and broadcast agent data at regular intervals -async fn broadcast_agent_data( - api: playit_api_client::PlayitApi, - lookup: Arc, - event_tx: broadcast::Sender, - cancel_token: CancellationToken, - start_time: u64, -) { - let mut interval = tokio::time::interval(Duration::from_secs(3)); - let mut guest_login_link: Option<(String, u64)> = None; - - loop { - tokio::select! { - _ = interval.tick() => { - match api.v1_agents_rundata().await { - Ok(mut api_data) => { - lookup.update_from_run_data(&api_data).await; - - // Build agent data - let account_status = match api_data.permissions.account_status { - AccountStatus::Guest => AccountStatusInfo::Guest, - AccountStatus::EmailNotVerified => AccountStatusInfo::EmailNotVerified, - AccountStatus::Verified => AccountStatusInfo::Verified, - }; - - // Get login link for guest accounts - let login_link = match api_data.permissions.account_status { - AccountStatus::Guest => { - let now = now_milli(); - match &guest_login_link { - Some((link, ts)) if now - *ts < 15_000 => Some(link.clone()), - _ => { - if let Ok(session) = api.login_guest().await { - let link = format!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ); - guest_login_link = Some((link.clone(), now_milli())); - Some(link) - } else { - None - } - } - } - } - _ => None, - }; - - api_data.notices.sort_by_key(|n| n.priority); - - let notices: Vec = api_data - .notices - .iter() - .map(|n| NoticeInfo { - priority: format!("{:?}", n.priority), - message: n.message.to_string(), - resolve_link: n.resolve_link.as_ref().map(|s| s.to_string()), - }) - .collect(); - - let tunnels: Vec = api_data - .tunnels - .iter() - .filter_map(|tunnel| { - let origin = OriginResource::from_agent_tunnel(tunnel)?; - - let destination = match origin.target { - OriginTarget::Https { - ip, - http_port, - https_port, - } => format!("{ip} (http: {http_port}, https: {https_port})"), - OriginTarget::Port { ip, port } => SocketAddr::new(ip, port).to_string(), - }; - - Some(TunnelInfo { - display_address: tunnel.display_address.clone(), - destination, - is_disabled: tunnel.disabled_reason.is_some(), - disabled_reason: tunnel.disabled_reason.as_ref().map(|s| s.to_string()), - }) - }) - .collect(); - - let pending_tunnels: Vec = api_data - .pending - .iter() - .map(|p| PendingTunnelInfo { - id: p.id.to_string(), - status_msg: p.status_msg.clone(), - }) - .collect(); - - let agent_data = AgentData { - version: env!("CARGO_PKG_VERSION").to_string(), - tunnels, - pending_tunnels, - notices, - account_status, - agent_id: api_data.agent_id.to_string(), - login_link, - start_time, - }; - - let event = ServiceEvent::from(&agent_data); - let _ = event_tx.send(event); - } - Err(error) => { - tracing::error!(?error, "Failed to load agent data"); - } - } - } - _ = cancel_token.cancelled() => { - break; - } - } - } -} diff --git a/packages/agent_cli/src/service/ipc.rs b/packages/agent_cli/src/service/ipc.rs deleted file mode 100644 index 7aa5e3ab..00000000 --- a/packages/agent_cli/src/service/ipc.rs +++ /dev/null @@ -1,612 +0,0 @@ -//! IPC protocol for communication between CLI and background service. -//! -//! Uses JSON messages delimited by newlines over local sockets. - -use std::io; -#[cfg(target_os = "macos")] -use std::path::PathBuf; -use std::sync::Arc; - -use interprocess::local_socket::{ - GenericFilePath, GenericNamespaced, ListenerOptions, ToFsName, ToNsName, - tokio::{Stream, prelude::*}, -}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; -use tokio::sync::broadcast; - -use crate::ui::tui_app::{ - AccountStatusInfo, AgentData, ConnectionStats, NoticeInfo, PendingTunnelInfo, TunnelInfo, -}; - -/// Error types for IPC operations -#[derive(Debug)] -pub enum IpcError { - /// Another instance is already running - AlreadyRunning, - /// Failed to bind to socket - BindFailed(io::Error), - /// Failed to connect to socket - ConnectionFailed(io::Error), - /// IO error during communication - IoError(io::Error), - /// JSON serialization/deserialization error - JsonError(serde_json::Error), - /// Service is not running - NotRunning, -} - -impl std::fmt::Display for IpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - IpcError::AlreadyRunning => write!(f, "Another instance is already running"), - IpcError::BindFailed(e) => write!(f, "Failed to bind to socket: {}", e), - IpcError::ConnectionFailed(e) => write!(f, "Failed to connect to socket: {}", e), - IpcError::IoError(e) => write!(f, "IO error: {}", e), - IpcError::JsonError(e) => write!(f, "JSON error: {}", e), - IpcError::NotRunning => write!(f, "Service is not running"), - } - } -} - -impl std::error::Error for IpcError {} - -impl From for IpcError { - fn from(e: io::Error) -> Self { - IpcError::IoError(e) - } -} - -impl From for IpcError { - fn from(e: serde_json::Error) -> Self { - IpcError::JsonError(e) - } -} - -/// Request messages from CLI to service -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ServiceRequest { - /// Subscribe to all updates (agent_data, stats, logs) - Subscribe, - /// One-shot status query - Status, - /// Request service shutdown - Stop, -} - -/// Event/response messages from service to CLI -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ServiceEvent { - /// Status response - Status { - running: bool, - pid: u32, - uptime_secs: u64, - }, - /// Agent data update (tunnels, notices, account info) - AgentData { - version: String, - agent_id: String, - account_status: String, - login_link: Option, - tunnels: Vec, - pending_tunnels: Vec, - notices: Vec, - start_time: u64, - }, - /// Connection stats update - Stats { - bytes_in: u64, - bytes_out: u64, - active_tcp: u32, - active_udp: u32, - }, - /// Log entry - Log { - level: String, - target: String, - message: String, - timestamp: u64, - }, - /// Acknowledgement (for stop command) - Ack { success: bool }, - /// Error response - Error { message: String }, -} - -/// JSON-serializable tunnel info -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TunnelInfoJson { - pub display_address: String, - pub destination: String, - pub is_disabled: bool, - pub disabled_reason: Option, -} - -/// JSON-serializable pending tunnel info -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PendingTunnelInfoJson { - pub id: String, - pub status_msg: String, -} - -/// JSON-serializable notice info -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NoticeInfoJson { - pub priority: String, - pub message: String, - pub resolve_link: Option, -} - -impl From<&TunnelInfo> for TunnelInfoJson { - fn from(t: &TunnelInfo) -> Self { - TunnelInfoJson { - display_address: t.display_address.clone(), - destination: t.destination.clone(), - is_disabled: t.is_disabled, - disabled_reason: t.disabled_reason.clone(), - } - } -} - -impl From for TunnelInfo { - fn from(t: TunnelInfoJson) -> Self { - TunnelInfo { - display_address: t.display_address, - destination: t.destination, - is_disabled: t.is_disabled, - disabled_reason: t.disabled_reason, - } - } -} - -impl From<&PendingTunnelInfo> for PendingTunnelInfoJson { - fn from(p: &PendingTunnelInfo) -> Self { - PendingTunnelInfoJson { - id: p.id.clone(), - status_msg: p.status_msg.clone(), - } - } -} - -impl From for PendingTunnelInfo { - fn from(p: PendingTunnelInfoJson) -> Self { - PendingTunnelInfo { - id: p.id, - status_msg: p.status_msg, - } - } -} - -impl From<&NoticeInfo> for NoticeInfoJson { - fn from(n: &NoticeInfo) -> Self { - NoticeInfoJson { - priority: n.priority.clone(), - message: n.message.clone(), - resolve_link: n.resolve_link.clone(), - } - } -} - -impl From for NoticeInfo { - fn from(n: NoticeInfoJson) -> Self { - NoticeInfo { - priority: n.priority, - message: n.message, - resolve_link: n.resolve_link, - } - } -} - -impl From<&AgentData> for ServiceEvent { - fn from(data: &AgentData) -> Self { - ServiceEvent::AgentData { - version: data.version.clone(), - agent_id: data.agent_id.clone(), - account_status: format!("{:?}", data.account_status), - login_link: data.login_link.clone(), - tunnels: data.tunnels.iter().map(|t| t.into()).collect(), - pending_tunnels: data.pending_tunnels.iter().map(|p| p.into()).collect(), - notices: data.notices.iter().map(|n| n.into()).collect(), - start_time: data.start_time, - } - } -} - -impl From<&ConnectionStats> for ServiceEvent { - fn from(stats: &ConnectionStats) -> Self { - ServiceEvent::Stats { - bytes_in: stats.bytes_in, - bytes_out: stats.bytes_out, - active_tcp: stats.active_tcp, - active_udp: stats.active_udp, - } - } -} - -impl ServiceEvent { - /// Convert AgentData event back to AgentData struct - pub fn to_agent_data(&self) -> Option { - match self { - ServiceEvent::AgentData { - version, - agent_id, - account_status, - login_link, - tunnels, - pending_tunnels, - notices, - start_time, - } => { - let status = match account_status.as_str() { - "Guest" => AccountStatusInfo::Guest, - "EmailNotVerified" => AccountStatusInfo::EmailNotVerified, - "Verified" => AccountStatusInfo::Verified, - _ => AccountStatusInfo::Unknown, - }; - Some(AgentData { - version: version.clone(), - agent_id: agent_id.clone(), - account_status: status, - login_link: login_link.clone(), - tunnels: tunnels.iter().cloned().map(|t| t.into()).collect(), - pending_tunnels: pending_tunnels.iter().cloned().map(|p| p.into()).collect(), - notices: notices.iter().cloned().map(|n| n.into()).collect(), - start_time: *start_time, - }) - } - _ => None, - } - } - - /// Convert Stats event back to ConnectionStats struct - pub fn to_connection_stats(&self) -> Option { - match self { - ServiceEvent::Stats { - bytes_in, - bytes_out, - active_tcp, - active_udp, - } => Some(ConnectionStats { - bytes_in: *bytes_in, - bytes_out: *bytes_out, - active_tcp: *active_tcp, - active_udp: *active_udp, - }), - _ => None, - } - } -} - -/// Get the socket path for the IPC connection -pub fn get_socket_path(system_mode: bool) -> String { - // On Linux, only system-level service is supported (via package manager's systemd unit) - #[cfg(target_os = "linux")] - { - let _ = system_mode; // silence unused variable warning - always uses system path - "/var/run/playit-agent.sock".to_string() - } - - #[cfg(target_os = "macos")] - { - if system_mode { - "/var/run/playit-agent.sock".to_string() - } else { - let data_dir = dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("playit_gg"); - let _ = std::fs::create_dir_all(&data_dir); - data_dir - .join("playit-agent.sock") - .to_string_lossy() - .to_string() - } - } - - #[cfg(target_os = "windows")] - { - if system_mode { - r"\\.\pipe\playit-agent-system".to_string() - } else { - format!(r"\\.\pipe\playit-agent-{}", whoami::username()) - } - } - - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - let _ = system_mode; - "./playit-agent.sock".to_string() - } -} - -/// Check if another instance is running by attempting to connect -pub async fn is_instance_running(system_mode: bool) -> bool { - let socket_path = get_socket_path(system_mode); - try_connect(&socket_path).await.is_ok() -} - -/// Try to connect to a socket path -async fn try_connect(socket_path: &str) -> Result { - // Try namespaced socket first (for abstract sockets on Linux) - if socket_path.starts_with('@') { - let name = socket_path[1..] - .to_ns_name::() - .map_err(|e| { - IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) - })?; - Stream::connect(name) - .await - .map_err(IpcError::ConnectionFailed) - } else { - let name = socket_path.to_fs_name::().map_err(|e| { - IpcError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) - })?; - Stream::connect(name) - .await - .map_err(IpcError::ConnectionFailed) - } -} - -/// IPC Server for the background service -pub struct IpcServer { - event_tx: broadcast::Sender, - socket_path: String, - #[allow(dead_code)] - system_mode: bool, - start_time: u64, - cancel_token: tokio_util::sync::CancellationToken, -} - -impl IpcServer { - /// Create a new IPC server - /// - /// This will fail if another instance is already running (single-instance enforcement) - pub async fn new( - system_mode: bool, - cancel_token: tokio_util::sync::CancellationToken, - ) -> Result { - let (event_tx, _) = broadcast::channel(256); - Self::new_with_sender(system_mode, cancel_token, event_tx).await - } - - /// Create a new IPC server with an existing broadcast sender - /// - /// This allows sharing the event channel with other components (like logging) - pub async fn new_with_sender( - system_mode: bool, - cancel_token: tokio_util::sync::CancellationToken, - event_tx: broadcast::Sender, - ) -> Result { - use playit_agent_core::utils::now_milli; - - let socket_path = get_socket_path(system_mode); - - // Check if another instance is running - if try_connect(&socket_path).await.is_ok() { - return Err(IpcError::AlreadyRunning); - } - - // Remove stale socket file if it exists (not needed for abstract sockets) - if !socket_path.starts_with('@') && !socket_path.starts_with(r"\\.\pipe\") { - let _ = std::fs::remove_file(&socket_path); - } - - let start_time = now_milli(); - - Ok(IpcServer { - event_tx, - socket_path, - system_mode, - start_time, - cancel_token, - }) - } - - /// Get a sender for broadcasting events to subscribers - pub fn event_sender(&self) -> broadcast::Sender { - self.event_tx.clone() - } - - /// Run the IPC server accept loop - pub async fn run(self: Arc) -> Result<(), IpcError> { - let listener = self.create_listener().await?; - - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok(stream) => { - let server = self.clone(); - tokio::spawn(async move { - if let Err(e) = server.handle_client(stream).await { - tracing::warn!("Client connection error: {}", e); - } - }); - } - Err(e) => { - tracing::error!("Accept error: {}", e); - } - } - } - _ = self.cancel_token.cancelled() => { - tracing::info!("IPC server shutting down"); - break; - } - } - } - - Ok(()) - } - - async fn create_listener( - &self, - ) -> Result { - if self.socket_path.starts_with('@') { - let name = self.socket_path[1..] - .to_ns_name::() - .map_err(|e| { - IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) - })?; - ListenerOptions::new() - .name(name) - .create_tokio() - .map_err(IpcError::BindFailed) - } else { - let name = self - .socket_path - .clone() - .to_fs_name::() - .map_err(|e| { - IpcError::BindFailed(io::Error::new(io::ErrorKind::InvalidInput, e)) - })?; - ListenerOptions::new() - .name(name) - .create_tokio() - .map_err(IpcError::BindFailed) - } - } - - async fn handle_client(&self, stream: Stream) -> Result<(), IpcError> { - let (reader, writer) = stream.split(); - let mut reader = BufReader::new(reader); - let mut writer = BufWriter::new(writer); - let mut line = String::new(); - let mut event_rx = self.event_tx.subscribe(); - - loop { - tokio::select! { - // Read requests from client - read_result = reader.read_line(&mut line) => { - match read_result { - Ok(0) => break, // Connection closed - Ok(_) => { - let request: ServiceRequest = serde_json::from_str(line.trim())?; - line.clear(); - - match request { - ServiceRequest::Subscribe => { - // Client wants to receive events - handled by event_rx - tracing::debug!("Client subscribed to events"); - } - ServiceRequest::Status => { - use playit_agent_core::utils::now_milli; - let uptime_ms = now_milli().saturating_sub(self.start_time); - let uptime_secs = uptime_ms / 1000; - let event = ServiceEvent::Status { - running: true, - pid: std::process::id(), - uptime_secs, - }; - self.send_event(&mut writer, &event).await?; - } - ServiceRequest::Stop => { - self.send_event(&mut writer, &ServiceEvent::Ack { success: true }).await?; - tracing::info!("Stop request received, initiating shutdown"); - // Trigger daemon shutdown - self.cancel_token.cancel(); - } - } - } - Err(e) => return Err(e.into()), - } - } - // Forward events to client - event_result = event_rx.recv() => { - match event_result { - Ok(event) => { - self.send_event(&mut writer, &event).await?; - } - Err(broadcast::error::RecvError::Lagged(_)) => { - // Client is too slow, skip some events - tracing::warn!("Client lagged behind, some events dropped"); - } - Err(broadcast::error::RecvError::Closed) => { - break; - } - } - } - } - } - - Ok(()) - } - - async fn send_event( - &self, - writer: &mut BufWriter, - event: &ServiceEvent, - ) -> Result<(), IpcError> { - let json = serde_json::to_string(event)?; - writer.write_all(json.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - Ok(()) - } -} - -/// IPC Client for connecting to the background service -pub struct IpcClient { - reader: BufReader, - writer: BufWriter, -} - -impl IpcClient { - /// Connect to the background service - pub async fn connect(system_mode: bool) -> Result { - let socket_path = get_socket_path(system_mode); - let stream = try_connect(&socket_path).await?; - let (reader, writer) = stream.split(); - - Ok(IpcClient { - reader: BufReader::new(reader), - writer: BufWriter::new(writer), - }) - } - - /// Check if the service is running (without maintaining connection) - pub async fn is_running(system_mode: bool) -> bool { - is_instance_running(system_mode).await - } - - /// Send a request to the service - pub async fn send_request(&mut self, request: &ServiceRequest) -> Result<(), IpcError> { - let json = serde_json::to_string(request)?; - self.writer.write_all(json.as_bytes()).await?; - self.writer.write_all(b"\n").await?; - self.writer.flush().await?; - Ok(()) - } - - /// Receive an event from the service - pub async fn recv_event(&mut self) -> Result { - let mut line = String::new(); - let bytes_read = self.reader.read_line(&mut line).await?; - if bytes_read == 0 { - return Err(IpcError::IoError(io::Error::new( - io::ErrorKind::UnexpectedEof, - "Connection closed", - ))); - } - let event = serde_json::from_str(line.trim())?; - Ok(event) - } - - /// Subscribe to events and return a stream of events - pub async fn subscribe(&mut self) -> Result<(), IpcError> { - self.send_request(&ServiceRequest::Subscribe).await - } - - /// Request service status - pub async fn status(&mut self) -> Result { - self.send_request(&ServiceRequest::Status).await?; - self.recv_event().await - } - - /// Request service stop - pub async fn stop(&mut self) -> Result { - self.send_request(&ServiceRequest::Stop).await?; - self.recv_event().await - } -} diff --git a/packages/agent_cli/src/service/manager.rs b/packages/agent_cli/src/service/manager.rs deleted file mode 100644 index 4fbc0429..00000000 --- a/packages/agent_cli/src/service/manager.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Service manager integration for install/uninstall/start/stop. - -use service_manager::{ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx}; -#[cfg(not(target_os = "linux"))] -use service_manager::{ServiceInstallCtx, ServiceUninstallCtx}; -#[cfg(not(target_os = "linux"))] -use std::ffi::OsString; -#[cfg(not(target_os = "linux"))] -use std::path::PathBuf; - -/// Error type for service manager operations -#[derive(Debug)] -pub enum ServiceManagerError { - /// Service manager not available on this platform - NotAvailable(String), - /// Failed to install service - InstallFailed(String), - /// Failed to uninstall service - UninstallFailed(String), - /// Failed to start service - StartFailed(String), - /// Failed to stop service - StopFailed(String), - /// Service not found - NotFound, - /// Generic IO error - IoError(std::io::Error), - /// Service is managed by package manager (Linux) - ManagedByPackageManager, -} - -impl std::fmt::Display for ServiceManagerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ServiceManagerError::NotAvailable(msg) => { - write!(f, "Service manager not available: {}", msg) - } - ServiceManagerError::InstallFailed(msg) => { - write!(f, "Failed to install service: {}", msg) - } - ServiceManagerError::UninstallFailed(msg) => { - write!(f, "Failed to uninstall service: {}", msg) - } - ServiceManagerError::StartFailed(msg) => write!(f, "Failed to start service: {}", msg), - ServiceManagerError::StopFailed(msg) => write!(f, "Failed to stop service: {}", msg), - ServiceManagerError::NotFound => write!(f, "Service not found"), - ServiceManagerError::IoError(e) => write!(f, "IO error: {}", e), - ServiceManagerError::ManagedByPackageManager => { - write!(f, "The playit service is managed by the package manager. Use your system package manager to install or uninstall the service.") - } - } - } -} - -impl std::error::Error for ServiceManagerError {} - -impl From for ServiceManagerError { - fn from(e: std::io::Error) -> Self { - ServiceManagerError::IoError(e) - } -} - -/// Service controller for managing the playit agent service -pub struct ServiceController { - manager: Box, - label: ServiceLabel, - #[cfg(not(target_os = "linux"))] - system_mode: bool, -} - -impl ServiceController { - /// Service label for playit agent - const SERVICE_LABEL: &'static str = "gg.playit.agent"; - #[cfg(not(target_os = "linux"))] - const USER_SERVICE_LABEL: &'static str = "gg.playit.agent.user"; - - /// Create a new service controller - #[cfg(not(target_os = "linux"))] - pub fn new(system_mode: bool) -> Result { - let manager = ::native() - .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; - - let label_str = if system_mode { - Self::SERVICE_LABEL - } else { - Self::USER_SERVICE_LABEL - }; - - let label: ServiceLabel = label_str.parse().unwrap(); - - Ok(ServiceController { - manager, - label, - system_mode, - }) - } - - /// Create a new service controller (Linux only supports system mode) - #[cfg(target_os = "linux")] - pub fn new(_system_mode: bool) -> Result { - let manager = ::native() - .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; - - let label: ServiceLabel = Self::SERVICE_LABEL.parse().unwrap(); - - Ok(ServiceController { manager, label }) - } - - /// Get the path to the current executable - #[cfg(not(target_os = "linux"))] - fn get_executable_path() -> Result { - std::env::current_exe().map_err(ServiceManagerError::IoError) - } - - /// Install the service - pub fn install(&self) -> Result<(), ServiceManagerError> { - // On Linux, the service is managed by the package manager - #[cfg(target_os = "linux")] - { - return Err(ServiceManagerError::ManagedByPackageManager); - } - - #[cfg(not(target_os = "linux"))] - { - let program = Self::get_executable_path()?; - - // Build arguments for the service - let args = if self.system_mode { - vec![OsString::from("run-service")] - } else { - vec![OsString::from("run-service"), OsString::from("--user")] - }; - - let ctx = ServiceInstallCtx { - label: self.label.clone(), - program, - args, - contents: None, - username: None, - working_directory: None, - environment: None, - autostart: true, - restart_policy: service_manager::RestartPolicy::OnFailure { - delay_secs: Some(5), - }, - }; - - self.manager - .install(ctx) - .map_err(|e| ServiceManagerError::InstallFailed(e.to_string()))?; - - Ok(()) - } - } - - /// Uninstall the service - pub fn uninstall(&self) -> Result<(), ServiceManagerError> { - // On Linux, the service is managed by the package manager - #[cfg(target_os = "linux")] - { - return Err(ServiceManagerError::ManagedByPackageManager); - } - - #[cfg(not(target_os = "linux"))] - { - let ctx = ServiceUninstallCtx { - label: self.label.clone(), - }; - - self.manager - .uninstall(ctx) - .map_err(|e| ServiceManagerError::UninstallFailed(e.to_string()))?; - - Ok(()) - } - } - - /// Start the service - pub fn start(&self) -> Result<(), ServiceManagerError> { - let ctx = ServiceStartCtx { - label: self.label.clone(), - }; - - self.manager - .start(ctx) - .map_err(|e| ServiceManagerError::StartFailed(e.to_string()))?; - - Ok(()) - } - - /// Stop the service - pub fn stop(&self) -> Result<(), ServiceManagerError> { - let ctx = ServiceStopCtx { - label: self.label.clone(), - }; - - self.manager - .stop(ctx) - .map_err(|e| ServiceManagerError::StopFailed(e.to_string()))?; - - Ok(()) - } - - /// Check if the service is installed - pub fn is_installed(&self) -> bool { - // Try to query the service - if it fails, it's not installed - // This is a heuristic since service-manager doesn't have a direct "is_installed" method - true // For now, assume it might be installed - } - - /// Get the service label - pub fn label(&self) -> &ServiceLabel { - &self.label - } - - /// Check if running in system mode - #[cfg(not(target_os = "linux"))] - pub fn is_system_mode(&self) -> bool { - self.system_mode - } - - /// Check if running in system mode (Linux always uses system mode) - #[cfg(target_os = "linux")] - pub fn is_system_mode(&self) -> bool { - true - } -} - -/// Start the playit systemd service on Linux using systemctl -#[cfg(target_os = "linux")] -fn start_systemd_service() -> Result<(), ServiceManagerError> { - use std::process::Command; - - let output = Command::new("systemctl") - .args(["start", "playit"]) - .output() - .map_err(|e| ServiceManagerError::StartFailed(format!("Failed to run systemctl: {}", e)))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(ServiceManagerError::StartFailed(format!( - "systemctl start playit failed: {}", - stderr - ))); - } - - Ok(()) -} - -/// Stop the playit systemd service on Linux using systemctl -#[cfg(target_os = "linux")] -pub fn stop_systemd_service() -> Result<(), ServiceManagerError> { - use std::process::Command; - - let output = Command::new("systemctl") - .args(["stop", "playit"]) - .output() - .map_err(|e| ServiceManagerError::StopFailed(format!("Failed to run systemctl: {}", e)))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(ServiceManagerError::StopFailed(format!( - "systemctl stop playit failed: {}", - stderr - ))); - } - - Ok(()) -} - -/// Ensure the service is running, starting it if necessary -pub async fn ensure_service_running(system_mode: bool) -> Result<(), ServiceManagerError> { - use crate::service::ipc::IpcClient; - - // First check if service is already running via IPC - if IpcClient::is_running(system_mode).await { - tracing::info!("Service is already running"); - return Ok(()); - } - - // On Linux, only use systemctl to start the package-installed service (no user-level service support) - #[cfg(target_os = "linux")] - { - let _ = system_mode; // silence unused variable warning - tracing::info!("Starting playit service via systemctl"); - if let Err(e) = start_systemd_service() { - tracing::error!("Failed to start via systemctl: {}", e); - return Err(e); - } - - // Wait for service to be ready - for _ in 0..50 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(true).await { - tracing::info!("Service started via systemctl"); - return Ok(()); - } - } - - return Err(ServiceManagerError::StartFailed( - "Service did not start within timeout. Ensure the playit service is installed via your package manager.".to_string(), - )); - } - - // On non-Linux, try to start via service manager - #[cfg(not(target_os = "linux"))] - { - let service_manager_result = match ServiceController::new(system_mode) { - Ok(controller) => { - tracing::info!("Starting service via service manager"); - controller.start() - } - Err(e) => { - tracing::error!("Service manager not available: {}", e); - Err(e) - } - }; - - // If service manager worked, wait for it to be ready - match service_manager_result { - Ok(_) => { - for _ in 0..50 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(system_mode).await { - tracing::info!("Service started via service manager"); - return Ok(()); - } - } - } - Err(error) => { - tracing::error!(?error, "failed to start service with manager"); - } - } - - // If service manager failed or service didn't start, spawn daemon directly - tracing::info!("Starting daemon process directly"); - spawn_daemon_process(system_mode)?; - - // Wait for daemon to be ready - for _ in 0..50 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(system_mode).await { - tracing::info!("Daemon started successfully"); - return Ok(()); - } - } - - Err(ServiceManagerError::StartFailed( - "Service did not start within timeout".to_string(), - )) - } -} - -/// Spawn the daemon process directly (without service manager) -/// Not available on Linux where only the package-managed systemd service is supported. -#[cfg(not(target_os = "linux"))] -fn spawn_daemon_process(system_mode: bool) -> Result<(), ServiceManagerError> { - let exe = std::env::current_exe().map_err(ServiceManagerError::IoError)?; - - let args = if system_mode { - vec!["run-service".to_string()] - } else { - vec!["run-service".to_string(), "--user".to_string()] - }; - - #[cfg(unix)] - { - use std::process::{Command, Stdio}; - - // Spawn detached process - Command::new(&exe) - .args(&args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| { - ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)) - })?; - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - use std::process::{Command, Stdio}; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - const DETACHED_PROCESS: u32 = 0x00000008; - - Command::new(&exe) - .args(&args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) - .spawn() - .map_err(|e| { - ServiceManagerError::StartFailed(format!("Failed to spawn daemon: {}", e)) - })?; - } - - Ok(()) -} diff --git a/packages/agent_cli/src/service/mod.rs b/packages/agent_cli/src/service/mod.rs deleted file mode 100644 index 5ccb15f9..00000000 --- a/packages/agent_cli/src/service/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Background service management for playit agent. -//! -//! This module provides: -//! - IPC protocol for communication between CLI and background service -//! - Daemon entry point for running the agent as a background service -//! - Service manager integration for install/uninstall/start/stop - -pub mod daemon; -pub mod ipc; -pub mod manager; - -pub use daemon::run_daemon; -pub use ipc::{IpcClient, IpcServer, ServiceEvent, ServiceRequest}; -pub use manager::ServiceController; diff --git a/packages/agent_cli/Cargo.toml b/packages/playit-cli/Cargo.toml similarity index 92% rename from packages/agent_cli/Cargo.toml rename to packages/playit-cli/Cargo.toml index 067bbf00..48eb50df 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/playit-cli/Cargo.toml @@ -31,11 +31,9 @@ crossterm = "0.28" ratatui = "0.29" dotenv = "0.15.0" -# Service management and IPC -service-manager = "0.10" -interprocess = { version = "2.2", features = ["tokio"] } - playit-agent-core = { path = "../agent_core", version = "0.17.1" } +playit-ipc = { path = "../playit-ipc", version = "0.17.1" } +playitd = { path = "../playitd", version = "0.17.1" } playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } playit-api-client = { path = "../api_client", version = "0.2.0" } # playit-ping-monitor = { path = "../ping_monitor" } diff --git a/packages/agent_cli/build.rs b/packages/playit-cli/build.rs similarity index 100% rename from packages/agent_cli/build.rs rename to packages/playit-cli/build.rs diff --git a/packages/agent_cli/src/autorun.rs b/packages/playit-cli/src/autorun.rs similarity index 100% rename from packages/agent_cli/src/autorun.rs rename to packages/playit-cli/src/autorun.rs diff --git a/packages/playit-cli/src/client.rs b/packages/playit-cli/src/client.rs new file mode 100644 index 00000000..a396c20d --- /dev/null +++ b/packages/playit-cli/src/client.rs @@ -0,0 +1,362 @@ +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use playit_ipc::ipc::IpcClient; +use playit_ipc::model::{ + AccountStatus as ServiceAccountStatus, AgentLifecycle, AgentState as ServiceAgentState, + ConnectionStats as ServiceConnectionStats, LogLevel as ServiceLogLevel, ServicePhase, + ServiceStatus, ServiceUpdate, +}; +use playitd::manager::ensure_service_running; +#[cfg(not(target_os = "linux"))] +use playitd::manager::ServiceController; + +use crate::ui::log_capture::{LogEntry, LogLevel as UiLogLevel}; +use crate::ui::tui_app::{ + AccountStatusInfo, AgentData, ConnectionStats, NoticeInfo, PendingTunnelInfo, TunnelInfo, +}; +use crate::ui::UI; +use crate::{CliError, EXE_NAME}; + +pub async fn run_start_command( + ui: &mut UI, + stdout_mode: bool, + socket_path: Option<&str>, +) -> Result<(), CliError> { + ui.write_screen("Ensuring installed playitd service is running...") + .await; + ensure_service_running(socket_path) + .await + .map_err(|e| CliError::ServiceError(format!("Failed to start service: {e}")))?; + + ui.write_screen("Installed playitd service is running").await; + ui.write_screen("Connecting to playitd...").await; + + let mut client = IpcClient::connect_with_path(socket_path, true) + .await + .map_err(|e| CliError::IpcError(format!("Failed to connect to service: {e}")))?; + + let snapshot = client + .subscribe() + .await + .map_err(|e| CliError::IpcError(format!("Failed to subscribe: {e}")))?; + + if !stdout_mode { + apply_status(ui, snapshot.snapshot.status.clone(), false).await; + apply_lifecycle(ui, snapshot.snapshot.lifecycle.clone()).await; + ui.update_stats(snapshot.snapshot.stats.into()); + } + + loop { + tokio::select! { + update_result = client.recv_update() => { + match update_result { + Ok(update) => apply_update(ui, update, stdout_mode).await, + Err(error) => { + if stdout_mode { + eprintln!("Connection to service lost: {error}"); + } else { + tracing::error!("IPC error: {error}"); + ui.write_screen(format!("Connection to service lost: {error}")).await; + } + tokio::time::sleep(Duration::from_secs(2)).await; + break; + } + } + } + _ = tokio::time::sleep(Duration::from_millis(50)) => { + if !stdout_mode && ui.is_tui() { + match ui.tick_tui() { + Ok(true) => {} + Ok(false) => { + ui.shutdown_tui()?; + println!("Detached from service. Service continues running in background."); + println!("Use '{} stop' to stop the service.", *EXE_NAME); + break; + } + Err(error) => { + ui.shutdown_tui()?; + return Err(error); + } + } + } + } + _ = tokio::signal::ctrl_c() => { + if !stdout_mode && ui.is_tui() { + ui.shutdown_tui()?; + } + println!("\nDetached from service. Service continues running in background."); + println!("Use '{} stop' to stop the service.", *EXE_NAME); + break; + } + } + } + + Ok(()) +} + +pub async fn run_stop_command(socket_path: Option<&str>) -> Result<(), CliError> { + if let Ok(mut client) = IpcClient::connect_with_path(socket_path, true).await { + if let Err(error) = client.stop().await { + tracing::warn!("Failed to send stop via IPC: {error}"); + } else { + println!("playitd service stop requested"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + #[cfg(target_os = "linux")] + { + if let Err(error) = playitd::manager::stop_systemd_service() { + tracing::warn!("Failed to stop via systemctl: {error}"); + } + } + + #[cfg(not(target_os = "linux"))] + { + match ServiceController::new() { + Ok(controller) => { + if let Err(error) = controller.stop() { + tracing::warn!("Failed to stop via service manager: {error}"); + } + } + Err(error) => tracing::debug!("Service manager not available: {error}"), + } + } + + tokio::time::sleep(Duration::from_millis(500)).await; + if !IpcClient::is_running_with_path(socket_path, true).await { + println!("playitd service stopped"); + } else { + println!("playitd service may still be running"); + } + + Ok(()) +} + +pub async fn run_status_command(socket_path: Option<&str>) -> Result<(), CliError> { + if !IpcClient::is_running_with_path(socket_path, true).await { + println!("playitd service is not running"); + return Ok(()); + } + + let mut client = match IpcClient::connect_with_path(socket_path, true).await { + Ok(client) => client, + Err(error) => { + println!("playitd service appears to be running but cannot connect: {error}"); + return Ok(()); + } + }; + + match client.status().await { + Ok(status) => { + println!("playitd service status:"); + println!(" Phase: {}", format_service_phase(&status.phase)); + println!(" PID: {}", status.pid); + println!(" Uptime: {} seconds", status.uptime_secs); + println!(" Version: {}", status.version); + println!(" Socket: {}", status.socket_path); + println!(" Config: {}", status.config_path); + println!(" Secret configured: {}", status.has_secret); + println!(" Protocol version: {}", status.protocol.version); + if !status.protocol.capabilities.is_empty() { + println!(" Capabilities: {:?}", status.protocol.capabilities); + } + if let Some(error) = status.last_error { + println!(" Last error: {}", error.message); + } + } + Err(error) => println!("Failed to get status: {error}"), + } + + Ok(()) +} + +pub async fn provision_service_secret( + socket_path: Option<&str>, + secret: &str, +) -> Result<(), CliError> { + ensure_service_running(socket_path) + .await + .map_err(|e| CliError::ServiceError(format!("Failed to start service: {e}")))?; + + let mut client = IpcClient::connect_with_path(socket_path, true) + .await + .map_err(|e| CliError::IpcError(format!("Failed to connect to service: {e}")))?; + + let response = client + .set_secret(secret) + .await + .map_err(|e| CliError::IpcError(format!("Failed to provision secret: {e}")))?; + + if !response.accepted { + return Err(CliError::IpcError( + response + .message + .unwrap_or_else(|| "playitd rejected the secret".to_string()), + )); + } + + Ok(()) +} + +async fn apply_update(ui: &mut UI, update: ServiceUpdate, stdout_mode: bool) { + match update { + ServiceUpdate::Lifecycle(state) => { + if !stdout_mode { + apply_lifecycle(ui, state).await; + } + } + ServiceUpdate::Status(status) => apply_status(ui, status, stdout_mode).await, + ServiceUpdate::Stats(stats) => { + if !stdout_mode { + ui.update_stats(stats.into()); + } + } + ServiceUpdate::Log(entry) => { + if stdout_mode { + println!( + "{} {:>5} {}: {}", + format_timestamp_millis(entry.timestamp), + format_log_level(&entry.level), + entry.target, + entry.message + ); + } else if let Some(log_capture) = ui.log_capture() { + let level = match entry.level { + ServiceLogLevel::Error => UiLogLevel::Error, + ServiceLogLevel::Warn => UiLogLevel::Warn, + ServiceLogLevel::Info => UiLogLevel::Info, + ServiceLogLevel::Debug => UiLogLevel::Debug, + ServiceLogLevel::Trace => UiLogLevel::Trace, + }; + + log_capture.push(LogEntry { + level, + target: entry.target, + message: entry.message, + timestamp: entry.timestamp, + }); + } + } + } +} + +async fn apply_lifecycle(ui: &mut UI, lifecycle: AgentLifecycle) { + match lifecycle { + AgentLifecycle::Running(state) => ui.update_agent_data(state.into()), + AgentLifecycle::WaitingForSecret => { + ui.write_screen("playitd is waiting for a secret to be provisioned").await; + } + AgentLifecycle::Starting => { + ui.write_screen("playitd is starting the agent").await; + } + AgentLifecycle::Stopping => { + ui.write_screen("playitd is stopping").await; + } + AgentLifecycle::Error(error) => { + ui.write_screen(format!("playitd reported an error: {}", error.message)) + .await; + } + } +} + +async fn apply_status(ui: &mut UI, status: ServiceStatus, stdout_mode: bool) { + if stdout_mode { + return; + } + + if let Some(error) = status.last_error { + ui.write_screen(format!( + "playitd status: {} ({})", + format_service_phase(&status.phase), + error.message + )) + .await; + return; + } + + ui.write_screen(format!("playitd status: {}", format_service_phase(&status.phase))) + .await; +} + +fn format_timestamp_millis(millis: u64) -> String { + DateTime::::from_timestamp_millis(millis as i64) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()) + .unwrap_or_else(|| format!("{millis}ms")) +} + +fn format_service_phase(phase: &ServicePhase) -> &'static str { + match phase { + ServicePhase::WaitingForSecret => "waiting_for_secret", + ServicePhase::Starting => "starting", + ServicePhase::Running => "running", + ServicePhase::Stopping => "stopping", + ServicePhase::Error => "error", + } +} + +fn format_log_level(level: &ServiceLogLevel) -> &'static str { + match level { + ServiceLogLevel::Trace => "TRACE", + ServiceLogLevel::Debug => "DEBUG", + ServiceLogLevel::Info => "INFO", + ServiceLogLevel::Warn => "WARN", + ServiceLogLevel::Error => "ERROR", + } +} + +impl From for AgentData { + fn from(data: ServiceAgentState) -> Self { + Self { + version: data.version, + tunnels: data + .tunnels + .into_iter() + .map(|t| TunnelInfo { + display_address: t.display_address, + destination: t.destination, + is_disabled: t.is_disabled, + disabled_reason: t.disabled_reason, + }) + .collect(), + pending_tunnels: data + .pending_tunnels + .into_iter() + .map(|p| PendingTunnelInfo { + id: p.id, + status_msg: p.status_msg, + }) + .collect(), + notices: data + .notices + .into_iter() + .map(|n| NoticeInfo { + priority: n.priority, + message: n.message, + resolve_link: n.resolve_link, + }) + .collect(), + account_status: match data.account_status { + ServiceAccountStatus::Guest => AccountStatusInfo::Guest, + ServiceAccountStatus::EmailNotVerified => AccountStatusInfo::EmailNotVerified, + ServiceAccountStatus::Verified => AccountStatusInfo::Verified, + ServiceAccountStatus::Unknown => AccountStatusInfo::Unknown, + }, + agent_id: data.agent_id, + login_link: data.login_link, + start_time: data.start_time, + } + } +} + +impl From for ConnectionStats { + fn from(stats: ServiceConnectionStats) -> Self { + Self { + bytes_in: stats.bytes_in, + bytes_out: stats.bytes_out, + active_tcp: stats.active_tcp, + active_udp: stats.active_udp, + } + } +} diff --git a/packages/playit-cli/src/main.rs b/packages/playit-cli/src/main.rs new file mode 100644 index 00000000..84de95ac --- /dev/null +++ b/packages/playit-cli/src/main.rs @@ -0,0 +1,598 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::sync::LazyLock; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use client::{provision_service_secret, run_start_command, run_status_command, run_stop_command}; +use playit_agent_core::agent_control::platform::current_platform; +use playit_agent_core::agent_control::version::{help_register_version, register_platform}; +use rand::Rng; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use uuid::Uuid; + +use autorun::autorun; +use playit_agent_core::agent_control::errors::SetupError; +use playit_agent_core::utils::now_milli; +use playit_api_client::http_client::HttpClientError; +use playit_api_client::{PlayitApi, api::*}; +use playit_secret::PlayitSecret; + +use crate::signal_handle::get_signal_handle; +use crate::ui::log_capture::LogCaptureLayer; +use crate::ui::{UI, UISettings}; + +pub static API_BASE: LazyLock = + LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); + +/// The name of the executable as invoked by the user +pub static EXE_NAME: LazyLock = LazyLock::new(|| { + std::env::args() + .next() + .and_then(|path| { + std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "playit".to_string()) +}); + +mod client; +pub mod autorun; +pub mod playit_secret; +pub mod signal_handle; +pub mod ui; +pub mod util; + +#[derive(Parser)] +#[command(name = "playit-cli")] +struct Cli { + /// Secret code for the agent + #[arg(long)] + secret: Option, + + /// Path to file containing secret + #[arg(long)] + secret_path: Option, + + /// Wait for secret_path file to read secret + #[arg(short = 'w', long)] + secret_wait: bool, + + /// Prints logs to stdout + #[arg(short = 's', long)] + stdout: bool, + + /// Path to write logs to + #[arg(short = 'l', long)] + log_path: Option, + + /// Override the IPC socket or named pipe used to reach playitd + #[arg(long)] + socket_path: Option, + + /// Overrides platform in version to be docker + #[arg(long)] + platform_docker: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Print version information + Version, + + /// Start the installed playitd service and attach + Start { + /// Print logs to stdout instead of using TUI + #[arg(short = 's', long)] + stdout: bool, + }, + + /// Stop the installed playitd service + Stop, + + /// Show the status of the installed playitd service + Status, + + /// Install the playit agent as a system service + Install, + + /// Uninstall the playit agent system service + Uninstall, + + /// Run the agent directly in foreground (for Docker/debugging) + RunEmbedded, + + /// Removes the secret key on your system so the playit agent can be re-claimed + Reset, + + /// Shows the file path where the playit secret can be found + SecretPath, + + #[cfg(target_os = "linux")] + /// Setup playit for Linux service + Setup, + + /// Account management commands + Account { + #[command(subcommand)] + command: AccountCommands, + }, + + /// Setting up a new playit agent + #[command( + about = "Setting up a new playit agent", + long_about = "Provides a URL that can be visited to claim the agent and generate a secret key" + )] + Claim { + #[command(subcommand)] + command: ClaimCommands, + }, + + /// Manage tunnels + Tunnels { + #[command(subcommand)] + command: TunnelCommands, + }, +} + +#[derive(Subcommand)] +enum AccountCommands { + /// Generates a link to allow user to login + LoginUrl, +} + +#[derive(Subcommand)] +enum ClaimCommands { + /// Generates a random claim code + Generate, + + /// Print a claim URL given the code and options + Url { + /// Claim code + claim_code: String, + + /// Name for the agent + #[arg(long, default_value = "from-cli")] + name: String, + + /// The agent type + #[arg(long, default_value = "self-managed")] + r#type: String, + }, + + /// Exchanges the claim for the secret key + Exchange { + /// Claim code (see "claim generate") + claim_code: String, + + /// Number of seconds to wait (0=infinite) + #[arg(long, default_value = "0")] + wait: u32, + }, +} + +#[derive(Subcommand)] +enum TunnelCommands { + /// Create a tunnel if it doesn't exist with the parameters + Prepare { + /// Either "tcp", "udp", or "both" + port_type: String, + + /// Number of ports in a series to allocate + #[arg(default_value = "1")] + port_count: String, + + /// The tunnel type + #[arg(long)] + r#type: Option, + + /// Name of the tunnel + #[arg(long)] + name: Option, + + #[arg(long)] + exact: bool, + + #[arg(long)] + ignore_name: bool, + }, + + /// List tunnels (format "[tunnel-id] [port-type] [port-count] [public-address]") + List, +} + +#[tokio::main] +async fn main() -> Result { + let cli = Cli::parse(); + + /* register docker */ + { + let platform = if cli.platform_docker { + Platform::Docker + } else { + current_platform() + }; + + register_platform(platform); + + help_register_version( + env!("CARGO_PKG_VERSION"), + "308943e8-faef-4835-a2ba-270351f72aa3", + ); + } + + let log_only = cli.stdout; + let log_path = cli.log_path.as_ref(); + + // Check if Start command has --stdout flag + let start_stdout = matches!( + &cli.command, + Some(Commands::Start { stdout: true, .. } | Commands::RunEmbedded) + ); + + // Use log-only mode if stdout flag is set OR if a log file path is specified OR if start --stdout + let use_log_only = log_only || log_path.is_some() || start_stdout; + + // Create UI first so we can get its log capture + let mut ui = UI::new(UISettings { + auto_answer: None, + log_only: use_log_only, + }); + + /* setup logging */ + // Get log level from PLAYIT_LOG env var, defaulting to "info" + let log_filter = + EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + + let _guard = match (use_log_only, log_path) { + (true, Some(path)) => { + // Log to file + let write_path = match path.rsplit_once("/") { + Some((dir, file)) => tracing_appender::rolling::never(dir, file), + None => tracing_appender::rolling::never(".", path), + }; + + let (non_blocking, guard) = tracing_appender::non_blocking(write_path); + tracing_subscriber::fmt() + .with_ansi(false) + .with_writer(non_blocking) + .with_env_filter(log_filter) + .init(); + Some(guard) + } + (true, None) => { + // Log to stdout (for -s flag, run-embedded, or start -s) + let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); + tracing_subscriber::fmt() + .with_ansi(current_platform() == Platform::Linux) + .with_writer(non_blocking) + .with_env_filter(log_filter) + .init(); + Some(guard) + } + (false, Some(_)) => { + panic!("log_path set but use_log_only is false - this shouldn't happen"); + } + (false, None) => { + // TUI mode - set up log capture layer with filter + if let Some(log_capture) = ui.log_capture() { + let capture_layer = LogCaptureLayer::new(log_capture); + tracing_subscriber::registry() + .with(log_filter) + .with(capture_layer) + .init(); + } + None + } + }; + + match cli.command { + None => { + run_start_command(&mut ui, false, cli.socket_path.as_deref()).await?; + } + Some(Commands::Start { stdout }) => { + run_start_command(&mut ui, stdout, cli.socket_path.as_deref()).await?; + } + Some(Commands::Stop) => { + run_stop_command(cli.socket_path.as_deref()).await?; + } + Some(Commands::Status) => { + run_status_command(cli.socket_path.as_deref()).await?; + } + Some(Commands::Install) => { + run_install_command()?; + } + Some(Commands::Uninstall) => { + run_uninstall_command()?; + } + Some(Commands::RunEmbedded) => { + // Run agent directly without TUI, printing logs to stdout + let mut embedded_ui = UI::new(UISettings { + auto_answer: Some(true), + log_only: true, + }); + let secret = load_cli_secret(&cli).await; + autorun(&mut embedded_ui, secret).await?; + } + Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), + #[cfg(target_os = "linux")] + Some(Commands::Setup) => { + let claim_code = claim_generate(); + ui.write_screen(format!("Visit link to setup {}", claim_url(&claim_code)?)) + .await; + + let key = claim_exchange(&mut ui, &claim_code, ClaimAgentType::Assignable, 0).await?; + provision_service_secret(cli.socket_path.as_deref(), &key).await?; + + let api = PlayitApi::create(API_BASE.to_string(), Some(key)); + if let Ok(session) = api.login_guest().await { + ui.write_screen(format!( + "Guest login:\nhttps://playit.gg/login/guest-account/{}", + session.session_key + )) + .await; + tokio::time::sleep(Duration::from_secs(10)).await; + } + + ui.write_screen("Playit setup complete, secret provisioned to playitd") + .await; + } + Some(Commands::Reset) => loop { + let mut secrets = PlayitSecret::from_args( + cli.secret.clone(), + cli.secret_path.clone(), + cli.secret_wait, + ) + .await; + secrets.with_default_path().await; + + let path = secrets.get_path().unwrap(); + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + break; + } + + tokio::fs::remove_file(path).await.unwrap(); + println!("deleted secret at: {}", path); + }, + Some(Commands::SecretPath) => { + let mut secrets = PlayitSecret::from_args( + cli.secret.clone(), + cli.secret_path.clone(), + cli.secret_wait, + ) + .await; + secrets.with_default_path().await; + let path = secrets.get_path().unwrap(); + println!("{}", path); + } + Some(Commands::Account { ref command }) => match command { + AccountCommands::LoginUrl => { + let secret = load_cli_secret(&cli).await; + let api = secret.create_api().await?; + let session = api.login_guest().await?; + println!( + "https://playit.gg/login/guest-account/{}", + session.session_key + ) + } + }, + Some(Commands::Claim { command }) => match command { + ClaimCommands::Generate => { + ui.write_screen(claim_generate()).await; + } + ClaimCommands::Url { claim_code, .. } => { + ui.write_screen(claim_url(&claim_code)?.to_string()).await; + } + ClaimCommands::Exchange { claim_code, wait } => { + let secret_key = + claim_exchange(&mut ui, &claim_code, ClaimAgentType::SelfManaged, wait).await?; + ui.write_screen(secret_key).await; + } + }, + Some(Commands::Tunnels { command }) => match command { + TunnelCommands::Prepare { .. } => { + return Err(CliError::NotImplemented); + } + TunnelCommands::List => { + return Err(CliError::NotImplemented); + } + }, + } + + Ok(std::process::ExitCode::SUCCESS) +} + +/// Background service setup is owned by the platform installer. +fn background_service_setup_message() -> String { + "Background service setup is handled by the platform installer. Use your installer or package manager to install or remove the playitd service.".to_string() +} + +async fn load_cli_secret(cli: &Cli) -> PlayitSecret { + let mut secret = + PlayitSecret::from_args(cli.secret.clone(), cli.secret_path.clone(), cli.secret_wait).await; + let _ = secret.with_default_path().await; + secret +} + +/// Run the install command +fn run_install_command() -> Result<(), CliError> { + Err(CliError::ServiceError(background_service_setup_message())) +} + +/// Run the uninstall command +fn run_uninstall_command() -> Result<(), CliError> { + Err(CliError::ServiceError(background_service_setup_message())) +} + +pub fn claim_generate() -> String { + let mut buffer = [0u8; 5]; + rand::rng().fill(&mut buffer); + hex::encode(&buffer) +} + +pub fn claim_url(code: &str) -> Result { + if hex::decode(code).is_err() { + return Err(CliError::InvalidClaimCode); + } + + Ok(format!("https://playit.gg/claim/{}", code,)) +} + +pub async fn claim_exchange( + ui: &mut UI, + claim_code: &str, + agent_type: ClaimAgentType, + wait_sec: u32, +) -> Result { + let api = PlayitApi::create(API_BASE.to_string(), None); + + let end_at = if wait_sec == 0 { + u64::MAX + } else { + now_milli() + (wait_sec as u64) * 1000 + }; + + { + let _close_guard = get_signal_handle().close_guard(); + let mut last_message = "Preparing Setup".to_string(); + + loop { + let setup_res = api + .claim_setup(ReqClaimSetup { + code: claim_code.to_string(), + agent_type, + version: format!("{} {}", *EXE_NAME, env!("CARGO_PKG_VERSION")), + }) + .await; + + let setup = match setup_res { + Ok(v) => v, + Err(error) => { + tracing::error!(?error, "Failed loading claim setup"); + ui.write_screen(format!("{}\n\nError: {:?}", last_message, error)) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + last_message = match setup { + ClaimSetupResponse::WaitingForUserVisit => { + format!("Visit link to setup {}", claim_url(claim_code)?) + } + ClaimSetupResponse::WaitingForUser => { + format!("Approve program at {}", claim_url(claim_code)?) + } + ClaimSetupResponse::UserAccepted => { + ui.write_screen("Program approved :). Secret code being setup.") + .await; + break; + } + ClaimSetupResponse::UserRejected => { + ui.write_screen("Program rejected :(").await; + tokio::time::sleep(Duration::from_secs(3)).await; + return Err(CliError::AgentClaimRejected); + } + }; + + ui.write_screen(&last_message).await; + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + let secret_key = loop { + match api + .claim_exchange(ReqClaimExchange { + code: claim_code.to_string(), + }) + .await + { + Ok(res) => break res.secret_key, + Err(ApiError::Fail(status)) => { + let msg = format!("code \"{}\" not ready, {:?}", claim_code, status); + ui.write_screen(msg).await; + } + Err(error) => return Err(error.into()), + }; + + if now_milli() > end_at { + ui.write_screen("you took too long to approve the program, closing") + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + return Err(CliError::TimedOut); + } + + tokio::time::sleep(Duration::from_secs(2)).await; + }; + + Ok(secret_key) +} + +#[derive(Debug)] +pub enum CliError { + InvalidClaimCode, + NotImplemented, + MissingSecret, + MalformedSecret, + InvalidSecret, + RenderError(std::io::Error), + SecretFileLoadError, + SecretFileWriteError(std::io::Error), + SecretFilePathMissing, + InvalidPortType, + InvalidPortCount, + InvalidMappingOverride, + AgentClaimRejected, + InvalidConfigFile, + TunnelNotFound(Uuid), + TimedOut, + AnswerNotProvided, + TunnelOverwrittenAlready(Uuid), + ResourceNotFoundAfterCreate(Uuid), + RequestError(HttpClientError), + ApiError(ApiResponseError), + ApiFail(String), + TunnelSetupError(SetupError), + ServiceError(String), + IpcError(String), +} + +impl Error for CliError {} + +impl Display for CliError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From> for CliError { + fn from(e: ApiError) -> Self { + match e { + ApiError::ApiError(e) => CliError::ApiError(e), + ApiError::ClientError(e) => CliError::RequestError(e), + ApiError::Fail(fail) => CliError::ApiFail(serde_json::to_string(&fail).unwrap()), + } + } +} + +impl From> for CliError { + fn from(e: ApiErrorNoFail) -> Self { + match e { + ApiErrorNoFail::ApiError(e) => CliError::ApiError(e), + ApiErrorNoFail::ClientError(e) => CliError::RequestError(e), + } + } +} + +impl From for CliError { + fn from(e: SetupError) -> Self { + CliError::TunnelSetupError(e) + } +} + diff --git a/packages/agent_cli/src/playit_secret.rs b/packages/playit-cli/src/playit_secret.rs similarity index 100% rename from packages/agent_cli/src/playit_secret.rs rename to packages/playit-cli/src/playit_secret.rs diff --git a/packages/agent_cli/src/signal_handle.rs b/packages/playit-cli/src/signal_handle.rs similarity index 100% rename from packages/agent_cli/src/signal_handle.rs rename to packages/playit-cli/src/signal_handle.rs diff --git a/packages/agent_cli/src/ui/log_capture.rs b/packages/playit-cli/src/ui/log_capture.rs similarity index 78% rename from packages/agent_cli/src/ui/log_capture.rs rename to packages/playit-cli/src/ui/log_capture.rs index e5ad10c7..5d5e8f49 100644 --- a/packages/agent_cli/src/ui/log_capture.rs +++ b/packages/playit-cli/src/ui/log_capture.rs @@ -1,12 +1,9 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; -use tokio::sync::broadcast; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::Context; use tracing_subscriber::Layer; -use crate::service::ipc::ServiceEvent; - /// A log entry captured from tracing #[derive(Clone, Debug)] pub struct LogEntry { @@ -139,7 +136,7 @@ impl tracing::field::Visit for MessageVisitor { fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { if field.name() == "message" { self.message = format!("{:?}", value); - } else if self.message.is_empty() { + } else { // Fallback: use any debug value if no message field if !self.message.is_empty() { self.message.push_str(", "); @@ -160,37 +157,3 @@ impl tracing::field::Visit for MessageVisitor { } } -/// Tracing layer that broadcasts log events via IPC -pub struct IpcBroadcastLayer { - event_tx: broadcast::Sender, -} - -impl IpcBroadcastLayer { - pub fn new(event_tx: broadcast::Sender) -> Self { - IpcBroadcastLayer { event_tx } - } -} - -impl Layer for IpcBroadcastLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - use playit_agent_core::utils::now_milli; - - let metadata = event.metadata(); - let level = LogLevel::from(metadata.level()); - let target = metadata.target().to_string(); - - // Extract the message from the event - let mut visitor = MessageVisitor::default(); - event.record(&mut visitor); - - let log_event = ServiceEvent::Log { - level: level.as_str().to_string(), - target, - message: visitor.message, - timestamp: now_milli(), - }; - - // Ignore send errors (no subscribers connected) - let _ = self.event_tx.send(log_event); - } -} diff --git a/packages/agent_cli/src/ui/mod.rs b/packages/playit-cli/src/ui/mod.rs similarity index 100% rename from packages/agent_cli/src/ui/mod.rs rename to packages/playit-cli/src/ui/mod.rs diff --git a/packages/agent_cli/src/ui/tui_app.rs b/packages/playit-cli/src/ui/tui_app.rs similarity index 100% rename from packages/agent_cli/src/ui/tui_app.rs rename to packages/playit-cli/src/ui/tui_app.rs diff --git a/packages/agent_cli/src/ui/widgets.rs b/packages/playit-cli/src/ui/widgets.rs similarity index 100% rename from packages/agent_cli/src/ui/widgets.rs rename to packages/playit-cli/src/ui/widgets.rs diff --git a/packages/agent_cli/src/util.rs b/packages/playit-cli/src/util.rs similarity index 100% rename from packages/agent_cli/src/util.rs rename to packages/playit-cli/src/util.rs diff --git a/packages/agent_cli/wix/Banner.bmp b/packages/playit-cli/wix/Banner.bmp similarity index 100% rename from packages/agent_cli/wix/Banner.bmp rename to packages/playit-cli/wix/Banner.bmp diff --git a/packages/agent_cli/wix/Product.ico b/packages/playit-cli/wix/Product.ico similarity index 100% rename from packages/agent_cli/wix/Product.ico rename to packages/playit-cli/wix/Product.ico diff --git a/packages/agent_cli/wix/main.wxs b/packages/playit-cli/wix/main.wxs similarity index 98% rename from packages/agent_cli/wix/main.wxs rename to packages/playit-cli/wix/main.wxs index d346e8ad..900f9543 100644 --- a/packages/agent_cli/wix/main.wxs +++ b/packages/playit-cli/wix/main.wxs @@ -189,7 +189,7 @@ The product icon is the graphic that appears in the Add/Remove Programs control panel for the application. --> - + - +