Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
318 lines
10 KiB
Rust
318 lines
10 KiB
Rust
use anyhow::{Context, Result};
|
|
#[cfg(all(feature = "local_fs", unix))]
|
|
use async_fs::unix::PermissionsExt;
|
|
use serde::Deserialize;
|
|
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(feature = "local_fs")] {
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::PathBuf;
|
|
}
|
|
}
|
|
|
|
use crate::language_server_candidate::LanguageServerMetadata;
|
|
|
|
const GITHUB_API_URL: &str = "https://api.github.com";
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct GithubRelease {
|
|
tag_name: String,
|
|
assets: Vec<GithubReleaseAsset>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct GithubReleaseAsset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
digest: Option<String>,
|
|
}
|
|
|
|
/// Finds a named asset in a release and returns its download URL and optional SHA256 digest.
|
|
fn resolve_asset(
|
|
assets: Vec<GithubReleaseAsset>,
|
|
asset_name: &str,
|
|
) -> Result<(String, Option<String>)> {
|
|
let asset = assets
|
|
.into_iter()
|
|
.find(|a| a.name == asset_name)
|
|
.with_context(|| format!("Asset '{asset_name}' not found in release"))?;
|
|
|
|
// Strip the "sha256:" prefix from the digest if present
|
|
let digest = asset
|
|
.digest
|
|
.map(|d| d.strip_prefix("sha256:").unwrap_or(&d).to_string());
|
|
|
|
Ok((asset.browser_download_url, digest))
|
|
}
|
|
|
|
async fn fetch_latest_release_from_github(
|
|
client: &http_client::Client,
|
|
repo_owner: &str,
|
|
repo_name: &str,
|
|
) -> Result<GithubRelease> {
|
|
let url = format!(
|
|
"{}/repos/{}/{}/releases/latest",
|
|
GITHUB_API_URL, repo_owner, repo_name
|
|
);
|
|
|
|
let response = client
|
|
.get(&url)
|
|
// GitHub API recommends specifying these parameters in the header.
|
|
// See: https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api#user-agent
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("User-Agent", "warp-terminal")
|
|
.send()
|
|
.await
|
|
.context("Failed to fetch latest release from GitHub")?;
|
|
|
|
if !response.status().is_success() {
|
|
anyhow::bail!(
|
|
"GitHub API returned status {}: {}",
|
|
response.status(),
|
|
response.text().await.unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
response
|
|
.json()
|
|
.await
|
|
.context("Failed to parse GitHub release response")
|
|
}
|
|
|
|
/// Fetches the latest release metadata from a GitHub repository.
|
|
///
|
|
/// # Arguments
|
|
/// * `client` - The HTTP client to use for the request
|
|
/// * `repo_owner` - The owner of the GitHub repository (e.g. "rust-lang")
|
|
/// * `repo_name` - The name of the GitHub repository (e.g. "rust-analyzer")
|
|
/// * `asset_name` - The name of the asset to find in the release. If None, no asset
|
|
/// lookup is performed and url/digest will be None (useful for servers like gopls
|
|
/// that don't provide prebuilt binaries).
|
|
///
|
|
/// # Returns
|
|
/// A `LanguageServerMetadata` containing the version, optional download URL, and optional SHA256 digest.
|
|
pub async fn fetch_latest_metadata_from_github(
|
|
client: &http_client::Client,
|
|
repo_owner: &str,
|
|
repo_name: &str,
|
|
asset_name: Option<&str>,
|
|
) -> Result<LanguageServerMetadata> {
|
|
let release = fetch_latest_release_from_github(client, repo_owner, repo_name).await?;
|
|
|
|
// If an asset name is provided, look up the asset details
|
|
let (download_url, digest) = if let Some(asset_name) = asset_name {
|
|
let (url, digest) = resolve_asset(release.assets, asset_name)?;
|
|
(Some(url), digest)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
Ok(LanguageServerMetadata {
|
|
version: release.tag_name,
|
|
url: download_url,
|
|
digest,
|
|
})
|
|
}
|
|
|
|
/// Fetches the latest release metadata from a GitHub repository and resolves
|
|
/// an asset name dynamically based on the latest release tag.
|
|
///
|
|
/// This is useful for projects where asset names include the tag itself, e.g.
|
|
/// `clangd-mac-v21.0.0.zip`.
|
|
pub async fn fetch_latest_metadata_from_github_dynamic_asset<F>(
|
|
client: &http_client::Client,
|
|
repo_owner: &str,
|
|
repo_name: &str,
|
|
asset_name_for_tag: F,
|
|
) -> Result<LanguageServerMetadata>
|
|
where
|
|
F: FnOnce(&str) -> String,
|
|
{
|
|
let release = fetch_latest_release_from_github(client, repo_owner, repo_name).await?;
|
|
let asset_name = asset_name_for_tag(&release.tag_name);
|
|
let (url, digest) = resolve_asset(release.assets, &asset_name)?;
|
|
|
|
Ok(LanguageServerMetadata {
|
|
version: release.tag_name,
|
|
url: Some(url),
|
|
digest,
|
|
})
|
|
}
|
|
|
|
/// The type of archive for a GitHub release asset.
|
|
#[cfg(feature = "local_fs")]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AssetKind {
|
|
/// A gzip-compressed file (e.g., `rust-analyzer-aarch64-apple-darwin.gz`)
|
|
Gz,
|
|
/// A gzip-compressed tar archive (e.g., `tombi-cli-1.4.1-aarch64-apple-darwin.tar.gz`)
|
|
TarGz,
|
|
/// A zip archive (e.g., `rust-analyzer-x86_64-pc-windows-msvc.zip`)
|
|
Zip,
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
impl AssetKind {
|
|
/// Determines the asset kind from a file name based on its extension.
|
|
pub fn from_filename(filename: &str) -> Option<Self> {
|
|
if filename.ends_with(".tar.gz") {
|
|
Some(AssetKind::TarGz)
|
|
} else if filename.ends_with(".gz") {
|
|
Some(AssetKind::Gz)
|
|
} else if filename.ends_with(".zip") {
|
|
Some(AssetKind::Zip)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Downloads and installs a language server binary from GitHub.
|
|
///
|
|
/// This function:
|
|
/// 1. Downloads the binary from the provided URL
|
|
/// 2. Verifies the SHA256 checksum if provided
|
|
/// 3. Extracts/decompresses the archive based on its type
|
|
/// 4. Makes the binary executable (on Unix systems)
|
|
///
|
|
/// # Arguments
|
|
/// * `client` - The HTTP client to use for downloading
|
|
/// * `metadata` - The server metadata containing version, URL, and optional digest
|
|
/// * `server_name` - The name of the server (e.g., "rust-analyzer") used for the destination path
|
|
/// * `asset_kind` - The type of archive (Gz or Zip)
|
|
/// * `binary_finder` - Optional callback to locate the binary after extraction. When provided,
|
|
/// the full archive is extracted (no filter) and this function is called to find the binary.
|
|
/// When `None`, a name-based filter is used during extraction and the binary is assumed to be
|
|
/// at `{install_dir}/{server_name}`.
|
|
///
|
|
/// # Returns
|
|
/// The path to the installed binary on success.
|
|
#[cfg(feature = "local_fs")]
|
|
pub async fn install_from_github(
|
|
client: &http_client::Client,
|
|
metadata: &LanguageServerMetadata,
|
|
server_name: &str,
|
|
asset_kind: AssetKind,
|
|
binary_finder: Option<fn(&std::path::Path) -> Option<PathBuf>>,
|
|
) -> Result<PathBuf> {
|
|
let url = metadata
|
|
.url
|
|
.as_ref()
|
|
.context("No download URL provided in metadata")?;
|
|
|
|
// Create the destination directory: {data_dir}/{server_name}/{version}
|
|
// If it already exists, remove it first to ensure a clean installation
|
|
let install_dir = galaxy_core::paths::data_dir()
|
|
.join(server_name)
|
|
.join(&metadata.version);
|
|
if install_dir.exists() {
|
|
async_fs::remove_dir_all(&install_dir)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to remove existing install directory: {:?}",
|
|
install_dir
|
|
)
|
|
})?;
|
|
}
|
|
async_fs::create_dir_all(&install_dir)
|
|
.await
|
|
.with_context(|| format!("Failed to create install directory: {:?}", install_dir))?;
|
|
|
|
// The binary name is the server name (with .exe on Windows)
|
|
let binary_name = if cfg!(windows) {
|
|
format!("{server_name}.exe")
|
|
} else {
|
|
server_name.to_string()
|
|
};
|
|
|
|
// Download the file
|
|
log::info!("Downloading {server_name} from {url}");
|
|
let response = client
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("Failed to download from {url}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
anyhow::bail!(
|
|
"Download failed with status {}: {}",
|
|
response.status(),
|
|
response.text().await.unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.context("Failed to read response body")?;
|
|
|
|
// Verify checksum if provided
|
|
if let Some(expected_digest) = &metadata.digest {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&bytes);
|
|
let actual_digest = format!("{:x}", hasher.finalize());
|
|
|
|
if actual_digest != *expected_digest {
|
|
anyhow::bail!(
|
|
"SHA256 checksum mismatch for {server_name}. Expected: {expected_digest}, Got: {actual_digest}"
|
|
);
|
|
}
|
|
log::info!("Checksum verified for {server_name}");
|
|
}
|
|
|
|
// Extract the archive based on type
|
|
match asset_kind {
|
|
AssetKind::Gz => {
|
|
let binary_path = install_dir.join(&binary_name);
|
|
node_runtime::extract_gz(&bytes, &binary_path).await?;
|
|
}
|
|
AssetKind::TarGz => {
|
|
node_runtime::extract_tar_gz(&bytes, &install_dir)?;
|
|
}
|
|
AssetKind::Zip => {
|
|
if binary_finder.is_some() {
|
|
// Extract the full archive when using a custom binary finder
|
|
let no_filter: Option<fn(&str) -> bool> = None;
|
|
node_runtime::extract_zip(&bytes, &install_dir, no_filter).await?;
|
|
} else {
|
|
// Use a filter to extract only the specific binary we need
|
|
let binary_name_clone = binary_name.clone();
|
|
node_runtime::extract_zip(
|
|
&bytes,
|
|
&install_dir,
|
|
Some(move |file_name: &str| {
|
|
file_name.ends_with(&binary_name_clone)
|
|
|| file_name.ends_with(&format!("/{binary_name_clone}"))
|
|
}),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Locate the binary
|
|
let binary_path = if let Some(finder) = binary_finder {
|
|
finder(&install_dir)
|
|
.with_context(|| format!("Failed to locate {server_name} binary after extraction"))?
|
|
} else {
|
|
install_dir.join(&binary_name)
|
|
};
|
|
|
|
// Make the binary executable on Unix systems
|
|
#[cfg(unix)]
|
|
{
|
|
let mut perms = async_fs::metadata(&binary_path)
|
|
.await
|
|
.with_context(|| format!("Failed to get metadata for {:?}", binary_path))?
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
async_fs::set_permissions(&binary_path, perms)
|
|
.await
|
|
.with_context(|| format!("Failed to set permissions for {:?}", binary_path))?;
|
|
}
|
|
|
|
log::info!("Successfully installed {server_name} to {:?}", binary_path);
|
|
Ok(binary_path)
|
|
}
|