use std::env;
use std::fs;
use std::io::{Cursor, Read};
use std::net::TcpListener;
use std::path::Component;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use serde_json::Value;
use tar::Archive;
use tonic::transport::Endpoint;
use zip::ZipArchive;
use crate::proto::{PingRequest, engine_service_client::EngineServiceClient};
use super::types::{Error, Result};
const ALLWRIGHT_AUTO_INSTALL_ENV_VAR: &str = "ALLWRIGHT_AUTO_INSTALL";
const ALLWRIGHT_CLI_PATH_ENV_VAR: &str = "ALLWRIGHT_CLI_PATH";
const ALLWRIGHT_HOME_ENV_VAR: &str = "ALLWRIGHT_HOME";
const ALLWRIGHT_REPOSITORY_ENV_VAR: &str = "ALLWRIGHT_REPOSITORY";
const ALLWRIGHT_VERSION_ENV_VAR: &str = "ALLWRIGHT_VERSION";
const DEFAULT_RELEASE_REPOSITORY: &str = "allwright-dev/allwright";
const DEFAULT_RELEASE_VERSION: &str = env!("CARGO_PKG_VERSION");
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
const PING_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(Default)]
struct BootstrapState {
managed_server_addr: Option<String>,
managed_server_requested_addr: Option<String>,
managed_server: Option<Child>,
}
struct PingStatus {
version: String,
}
static BOOTSTRAP_STATE: OnceLock<Mutex<BootstrapState>> = OnceLock::new();
pub(crate) async fn ensure_runtime_ready(server_addr: &str) -> Result<String> {
let expected_version = expected_runtime_version();
if let Some(status) = ping_server(server_addr).await? {
if status.version == expected_version {
return Ok(server_addr.to_string());
}
if !is_local_server_addr(server_addr) {
return Err(Error::new(format!(
"allwright server at {server_addr} is running version {} but this client expects {}",
display_version(&status.version),
expected_version
)));
}
}
if !is_local_server_addr(server_addr) {
return Err(Error::new(format!(
"allwright could not reach engine server at {server_addr}. Automatic startup is only supported for local addresses."
)));
}
let mut managed_addr = None;
{
let mut state = bootstrap_state()
.lock()
.map_err(|_| Error::new("bootstrap state lock is poisoned"))?;
if let Some(child) = state.managed_server.as_mut() {
match child.try_wait() {
Ok(Some(_)) => {
state.managed_server = None;
state.managed_server_addr = None;
state.managed_server_requested_addr = None;
}
Ok(None) => {
if state.managed_server_requested_addr.as_deref() == Some(server_addr) {
managed_addr = state.managed_server_addr.clone();
} else {
let mut child = state.managed_server.take().expect("managed server child");
let _ = child.kill();
let _ = child.wait();
state.managed_server_addr = None;
state.managed_server_requested_addr = None;
}
}
Err(error) => {
return Err(Error::new(format!(
"failed to inspect managed allwright server process: {error}"
)));
}
}
}
}
if let Some(managed_addr) = managed_addr {
return wait_for_server(&managed_addr, &expected_version).await;
}
let initial_status = ping_server(server_addr).await?;
let resolved_addr = match initial_status {
Some(status) if status.version != expected_version => {
allocate_managed_server_addr(server_addr)?
}
_ => server_addr.to_string(),
};
{
let expected_version_for_cli = expected_version.clone();
let cli_path =
tokio::task::spawn_blocking(move || ensure_cli_available(&expected_version_for_cli))
.await
.map_err(|error| {
Error::new(format!("allwright bootstrap task failed: {error}"))
})??;
let listen_addr = cli_listen_addr(&resolved_addr);
let child = Command::new(&cli_path)
.arg("serve")
.arg("--listen-addr")
.arg(&listen_addr)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| {
Error::new(format!(
"failed to start allwright server with {}: {error}",
cli_path.display()
))
})?;
let mut state = bootstrap_state()
.lock()
.map_err(|_| Error::new("bootstrap state lock is poisoned"))?;
state.managed_server = Some(child);
state.managed_server_addr = Some(resolved_addr.clone());
state.managed_server_requested_addr = Some(server_addr.to_string());
}
wait_for_server(&resolved_addr, &expected_version).await
}
pub(crate) fn shutdown_managed_server() -> Result<()> {
let mut state = bootstrap_state()
.lock()
.map_err(|_| Error::new("bootstrap state lock is poisoned"))?;
if let Some(mut child) = state.managed_server.take() {
let _ = child.kill();
let _ = child.wait();
}
state.managed_server_addr = None;
state.managed_server_requested_addr = None;
Ok(())
}
fn bootstrap_state() -> &'static Mutex<BootstrapState> {
BOOTSTRAP_STATE.get_or_init(|| Mutex::new(BootstrapState::default()))
}
async fn wait_for_server(server_addr: &str, expected_version: &str) -> Result<String> {
let start = Instant::now();
loop {
if let Some(status) = ping_server(server_addr).await? {
if status.version == expected_version {
return Ok(server_addr.to_string());
}
}
if start.elapsed() >= STARTUP_TIMEOUT {
let _ = shutdown_managed_server();
return Err(Error::new(format!(
"timed out waiting for allwright server at {server_addr} to become ready with version {expected_version}"
)));
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
pub(crate) fn ensure_plugins_installed(plugin_ids: &[&str]) -> Result<()> {
let expected_version = expected_runtime_version();
let cli_path = ensure_cli_available(&expected_version)?;
ensure_plugins_installed_with_cli(&cli_path, &expected_version, plugin_ids)
}
pub(crate) fn invoke_plugin(plugin_id: &str, request_json: &str) -> Result<String> {
let expected_version = expected_runtime_version();
let cli_path = ensure_cli_available(&expected_version)?;
let output = Command::new(&cli_path)
.arg("plugin")
.arg("invoke")
.arg(plugin_id)
.arg("--request-json")
.arg(request_json)
.stdin(Stdio::null())
.output()
.map_err(|error| {
Error::new(format!(
"failed to invoke allwright {plugin_id} plugin with {}: {error}",
cli_path.display()
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
return Err(Error::new(if detail.is_empty() {
format!("allwright {plugin_id} plugin invocation failed")
} else {
detail
}));
}
String::from_utf8(output.stdout)
.map_err(|error| Error::new(format!("plugin response is not valid UTF-8: {error}")))
}
async fn ping_server(server_addr: &str) -> Result<Option<PingStatus>> {
let endpoint = Endpoint::from_shared(server_addr.to_string()).map_err(|error| {
Error::new(format!(
"invalid allwright server address {server_addr}: {error}"
))
})?;
let channel = match tokio::time::timeout(PING_TIMEOUT, endpoint.connect()).await {
Ok(Ok(channel)) => channel,
Ok(Err(_)) | Err(_) => return Ok(None),
};
let mut engine = EngineServiceClient::new(channel);
let response = match tokio::time::timeout(
PING_TIMEOUT,
engine.ping(tonic::Request::new(PingRequest {})),
)
.await
{
Ok(Ok(response)) => response.into_inner(),
Ok(Err(_)) | Err(_) => return Ok(None),
};
Ok(Some(PingStatus {
version: normalize_release_version(&response.version),
}))
}
fn ensure_cli_available(expected_version: &str) -> Result<PathBuf> {
if let Some(cli_path) = resolve_existing_cli_path(expected_version)? {
return Ok(cli_path);
}
if !auto_install_enabled() {
return Err(Error::new(
"allwright CLI was not found. Install it first or set ALLWRIGHT_CLI_PATH.",
));
}
install_cli()
}
fn resolve_existing_cli_path(expected_version: &str) -> Result<Option<PathBuf>> {
if let Ok(raw) = env::var(ALLWRIGHT_CLI_PATH_ENV_VAR) {
let path = PathBuf::from(raw.trim());
if is_executable_file(&path) && cli_version_matches(&path, expected_version)? {
return Ok(Some(path));
}
}
let bundled = allwright_home()?.join("bin").join(cli_filename());
if is_executable_file(&bundled) && cli_version_matches(&bundled, expected_version)? {
return Ok(Some(bundled));
}
if let Some(candidate) = repo_local_cli_path() {
if cli_version_matches(&candidate, expected_version)? {
return Ok(Some(candidate));
}
}
if let Some(candidate) = find_in_path(cli_filename()) {
if cli_version_matches(&candidate, expected_version)? {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn install_cli() -> Result<PathBuf> {
let install_dir = allwright_home()?.join("bin");
fs::create_dir_all(&install_dir).map_err(|error| {
Error::new(format!(
"failed to create allwright CLI install directory {}: {error}",
install_dir.display()
))
})?;
let cli_path = install_dir.join(cli_filename());
let version_tag = resolve_release_tag()?;
let asset_name = cli_asset_name(&version_tag)?;
let asset_bytes = download_release_asset(&version_tag, &asset_name)?;
unpack_cli_archive(&asset_name, &asset_bytes, &cli_path)?;
if !is_executable_file(&cli_path) {
return Err(Error::new(format!(
"downloaded allwright CLI archive {asset_name} but did not produce {}",
cli_path.display()
)));
}
Ok(cli_path)
}
fn ensure_plugins_installed_with_cli(
cli_path: &Path,
expected_version: &str,
plugin_ids: &[&str],
) -> Result<()> {
for plugin_id in plugin_ids
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
let plugin_path = allwright_home()?
.join("plugins")
.join(plugin_id)
.join("lib")
.join(plugin_library_filename(plugin_id)?);
if plugin_path.exists()
&& installed_plugin_version(plugin_id)?.as_deref() == Some(expected_version)
{
continue;
}
let status = Command::new(cli_path)
.arg("plugin")
.arg("install")
.arg(plugin_id)
.arg("--version")
.arg(expected_version)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|error| {
Error::new(format!(
"failed to install the allwright `{plugin_id}` plugin with {}: {error}",
cli_path.display()
))
})?;
if !status.success() || !plugin_path.exists() {
return Err(Error::new(format!(
"allwright attempted to install the `{plugin_id}` plugin automatically, but the install did not complete successfully",
)));
}
}
Ok(())
}
fn resolve_release_tag() -> Result<String> {
let version = env::var(ALLWRIGHT_VERSION_ENV_VAR)
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_RELEASE_VERSION.to_string());
if version.trim() == "latest" {
return fetch_latest_release_tag();
}
Ok(normalize_release_tag(&version))
}
fn fetch_latest_release_tag() -> Result<String> {
let repository = env::var(ALLWRIGHT_REPOSITORY_ENV_VAR)
.unwrap_or_else(|_| DEFAULT_RELEASE_REPOSITORY.to_string());
let url = format!("https://api.github.com/repos/{repository}/releases/latest");
let response = release_client()?
.get(url)
.send()
.and_then(|response| response.error_for_status())
.map_err(|error| {
Error::new(format!(
"failed to resolve latest allwright release: {error}"
))
})?;
let payload: Value = serde_json::from_reader(response).map_err(|error| {
Error::new(format!(
"failed to decode latest allwright release metadata: {error}"
))
})?;
let tag = payload
.get("tag_name")
.and_then(Value::as_str)
.ok_or_else(|| Error::new("latest allwright release metadata did not include tag_name"))?;
Ok(tag.to_string())
}
fn cli_asset_name(version_tag: &str) -> Result<String> {
let target = match (env::consts::OS, env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("macos", "x86_64") => "x86_64-apple-darwin",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("windows", "aarch64") => "aarch64-pc-windows-msvc",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
(os, arch) => {
return Err(Error::new(format!(
"automatic allwright CLI install is not supported on os={os}, arch={arch}"
)));
}
};
let extension = if env::consts::OS == "windows" {
"zip"
} else {
"tar.gz"
};
Ok(format!("allwright-{version_tag}-{target}.{extension}"))
}
fn download_release_asset(version_tag: &str, asset_name: &str) -> Result<Vec<u8>> {
let repository = env::var(ALLWRIGHT_REPOSITORY_ENV_VAR)
.unwrap_or_else(|_| DEFAULT_RELEASE_REPOSITORY.to_string());
let url =
format!("https://github.com/{repository}/releases/download/{version_tag}/{asset_name}");
let mut response = release_client()?
.get(url)
.send()
.and_then(|response| response.error_for_status())
.map_err(|error| {
Error::new(format!(
"failed to download allwright CLI asset {asset_name}: {error}"
))
})?;
let mut bytes = Vec::new();
response.read_to_end(&mut bytes).map_err(|error| {
Error::new(format!(
"failed to read allwright CLI asset {asset_name}: {error}"
))
})?;
Ok(bytes)
}
fn unpack_cli_archive(asset_name: &str, asset_bytes: &[u8], destination: &Path) -> Result<()> {
if asset_name.ends_with(".tar.gz") {
let decoder = GzDecoder::new(Cursor::new(asset_bytes));
let mut archive = Archive::new(decoder);
for entry in archive
.entries()
.map_err(|error| Error::new(format!("failed to read CLI archive entries: {error}")))?
{
let mut entry = entry.map_err(|error| {
Error::new(format!("failed to open CLI archive entry: {error}"))
})?;
let entry_path = entry.path().map_err(|error| {
Error::new(format!("failed to read CLI archive entry path: {error}"))
})?;
if normalized_archive_path(&entry_path).as_deref()
== Some(Path::new("bin").join(cli_filename()).as_path())
{
entry.unpack(destination).map_err(|error| {
Error::new(format!(
"failed to unpack the allwright CLI into {}: {error}",
destination.display()
))
})?;
set_executable(destination)?;
return Ok(());
}
}
return Err(Error::new(
"allwright CLI archive did not contain bin/allwright",
));
}
let mut archive = ZipArchive::new(Cursor::new(asset_bytes))
.map_err(|error| Error::new(format!("failed to open CLI zip archive: {error}")))?;
let expected = Path::new("bin").join(cli_filename());
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
Error::new(format!(
"failed to inspect the downloaded CLI zip archive: {error}"
))
})?;
if normalized_archive_path(Path::new(file.name())).as_deref() != Some(expected.as_path()) {
continue;
}
let mut output = fs::File::create(destination).map_err(|error| {
Error::new(format!(
"failed to create {}: {error}",
destination.display()
))
})?;
std::io::copy(&mut file, &mut output)
.map_err(|error| Error::new(format!("failed to extract the allwright CLI: {error}")))?;
set_executable(destination)?;
return Ok(());
}
Err(Error::new(
"allwright CLI zip archive did not contain bin/allwright",
))
}
fn normalized_archive_path(path: &Path) -> Option<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(segment) => normalized.push(segment),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
if normalized.as_os_str().is_empty() {
None
} else {
Some(normalized)
}
}
fn release_client() -> Result<Client> {
Client::builder()
.timeout(Duration::from_secs(120))
.user_agent(format!("allwright-core/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| Error::new(format!("failed to build allwright release client: {error}")))
}
fn cli_listen_addr(server_addr: &str) -> String {
server_addr
.strip_prefix("http://")
.or_else(|| server_addr.strip_prefix("https://"))
.unwrap_or(server_addr)
.to_string()
}
fn normalize_release_tag(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with('v') {
trimmed.to_string()
} else {
format!("v{trimmed}")
}
}
fn normalize_release_version(raw: &str) -> String {
raw.trim().trim_start_matches('v').to_string()
}
fn expected_runtime_version() -> String {
env::var(ALLWRIGHT_VERSION_ENV_VAR)
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| normalize_release_version(&value))
.unwrap_or_else(|| normalize_release_version(DEFAULT_RELEASE_VERSION))
}
fn allwright_home() -> Result<PathBuf> {
if let Ok(home) = env::var(ALLWRIGHT_HOME_ENV_VAR) {
let trimmed = home.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = env::var("HOME")
.map_err(|_| Error::new("HOME is not set and ALLWRIGHT_HOME was not provided"))?;
Ok(PathBuf::from(home).join(".allwright"))
}
fn auto_install_enabled() -> bool {
env::var(ALLWRIGHT_AUTO_INSTALL_ENV_VAR)
.map(|value| {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no"
)
})
.unwrap_or(true)
}
fn find_in_path(filename: &str) -> Option<PathBuf> {
let path_value = env::var_os("PATH")?;
for entry in env::split_paths(&path_value) {
let candidate = entry.join(filename);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
fn repo_local_cli_path() -> Option<PathBuf> {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir.parent()?.parent()?;
["target/debug", "target/release"]
.into_iter()
.map(|dir| repo_root.join(dir).join(cli_filename()))
.find(|candidate| is_executable_file(candidate))
}
fn cli_version_matches(cli_path: &Path, expected_version: &str) -> Result<bool> {
let output = Command::new(cli_path)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.map_err(|error| {
Error::new(format!(
"failed to inspect allwright CLI version via {}: {error}",
cli_path.display()
))
})?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let version = stdout
.split_whitespace()
.find(|token| token.chars().next().is_some_and(|ch| ch.is_ascii_digit()))
.map(normalize_release_version)
.unwrap_or_default();
Ok(version == expected_version)
}
fn is_local_server_addr(server_addr: &str) -> bool {
let normalized = server_addr
.strip_prefix("http://")
.or_else(|| server_addr.strip_prefix("https://"))
.unwrap_or(server_addr);
let without_auth = normalized
.rsplit_once('@')
.map(|(_, tail)| tail)
.unwrap_or(normalized);
let host = without_auth
.rsplit_once(':')
.map(|(host, _)| host)
.unwrap_or(without_auth)
.trim_matches(['[', ']']);
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
fn installed_plugin_version(plugin_id: &str) -> Result<Option<String>> {
let manifest = allwright_home()?.join("plugins.txt");
let contents = match fs::read_to_string(&manifest) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(Error::new(format!(
"failed to read allwright plugin manifest {}: {error}",
manifest.display()
)));
}
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let mut parts = trimmed.splitn(3, '\t');
let Some(id) = parts.next() else { continue };
let _package_name = parts.next();
let Some(version) = parts.next() else {
continue;
};
if id == plugin_id {
return Ok(Some(normalize_release_version(version)));
}
}
Ok(None)
}
fn allocate_managed_server_addr(server_addr: &str) -> Result<String> {
let host = local_binding_host(server_addr);
let listener = TcpListener::bind((host.as_str(), 0)).map_err(|error| {
Error::new(format!(
"failed to reserve a local port for an allwright managed server on {host}: {error}"
))
})?;
let port = listener
.local_addr()
.map_err(|error| {
Error::new(format!(
"failed to resolve a reserved local allwright port: {error}"
))
})?
.port();
drop(listener);
if host.contains(':') {
Ok(format!("http://[{host}]:{port}"))
} else {
Ok(format!("http://{host}:{port}"))
}
}
fn local_binding_host(server_addr: &str) -> String {
let normalized = server_addr
.strip_prefix("http://")
.or_else(|| server_addr.strip_prefix("https://"))
.unwrap_or(server_addr);
let without_auth = normalized
.rsplit_once('@')
.map(|(_, tail)| tail)
.unwrap_or(normalized);
let host = without_auth
.rsplit_once(':')
.map(|(host, _)| host)
.unwrap_or(without_auth)
.trim_matches(['[', ']']);
if host == "::1" {
"::1".to_string()
} else {
"127.0.0.1".to_string()
}
}
fn display_version(version: &str) -> &str {
if version.is_empty() {
"unknown"
} else {
version
}
}
fn cli_filename() -> &'static str {
if env::consts::OS == "windows" {
"allwright.exe"
} else {
"allwright"
}
}
fn plugin_library_filename(plugin_id: &str) -> Result<&'static str> {
match (plugin_id, env::consts::OS) {
("web", "macos") => Ok("liballwright_surface_web.dylib"),
("web", "linux") => Ok("liballwright_surface_web.so"),
("web", "windows") => Ok("allwright_surface_web.dll"),
("mobile-android", "macos") => Ok("liballwright_surface_mobile_android.dylib"),
("mobile-android", "linux") => Ok("liballwright_surface_mobile_android.so"),
("mobile-android", "windows") => Ok("allwright_surface_mobile_android.dll"),
_ => Err(Error::new(format!(
"automatic install is not supported for allwright plugin `{plugin_id}` on {}",
env::consts::OS
))),
}
}
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path)
.map_err(|error| Error::new(format!("failed to inspect {}: {error}", path.display())))?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).map_err(|error| {
Error::new(format!(
"failed to mark {} executable: {error}",
path.display()
))
})
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}