mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
* perf(vfs): list directories off the main thread and reuse the repository lookup Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow because every listing ran on the main thread and walked up to the repository root and ran a repository discovery again. - fs_read_dir runs on the blocking pool. - GitignoreStack::cached keeps the repository root and the repo-level rules for a few seconds. An edit of .git/info/exclude is still seen at once (its mtime and size are checked) and every .gitignore / .ignore is read fresh. Refs #916 * feat(remote): open WSL projects through a WSL transport A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens as a remote project whose openidea-server runs inside the distro, so the terminal, SDK discovery, run, language servers and git are native Linux. - RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote launch, connection, session and recents; old recents are read as SSH and a WSL target never inherits the legacy trust migration. - remote_wsl: probe the distro, stream the server into ~/.openidea/server and start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are validated and every command goes through --exec with positional arguments. - wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route WSL folders to the distro and focus the window that already has them. - Remote Development dialog: SSH/WSL choice with the detected distros; the status bar shows `WSL: <distro>` / `SSH: <host>`. - ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of wsl.exe, which made the first message of the server disappear. Not run on Windows yet: only a stand-in for wsl.exe is tested. Refs #925, #327, #721 * fix(remote): satisfy clippy in WSL output decoding * style(run): format run types * fix: regenerate bindings and drop untranslated WSL catalog entry * test(i18n): list WSL as an untranslated product name * fix(run): pick build wrappers and shell quoting by the execution host A remote window runs commands on its Linux or macOS server, so a Windows desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it. * fix(welcome): open legacy WSL UNC recents and sessions through the distro Recents and the restored session called the local openProject for a \\wsl.localhost folder saved before WSL projects, which fails with OS error 123. They now resolve it like the other entry points. * fix(windows): keep WSL files out of remote project launches path_launch tested the WSL share before is_dir, so a first-instance CLI launch with a WSL file opened a remote project instead of LightEdit. * fix(remote): run-command OS comes from the remote host, not an assumption The server's OS was dropped after connect, so a restored session had none and isWindows() assumed every remote host is Unix. Keep it on the connection and the session, and resolve it through hostOs(). --------- Co-authored-by: NguyenBang <nguyenbang@NTBANG.local> Co-authored-by: NguyenBang <nguyenbang@Mac.lan> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
771 lines
30 KiB
Rust
771 lines
30 KiB
Rust
//! Remote development (client side): a window whose project lives on another
|
||
//! machine. The backend there is `openidea-server`, started over SSH; the
|
||
//! frontend's remote-capable commands go through [`remote_invoke`] instead of
|
||
//! running locally.
|
||
|
||
use crate::events::TauriSink;
|
||
use crate::state::AppState;
|
||
use ide_core::{EventSink, IdeError, IdeResult};
|
||
use ide_host::rpc::{ChannelMessage, EventMessage, Invoke, ServerInfo};
|
||
use ide_jsonrpc::{Handler, Peer};
|
||
use parking_lot::Mutex;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::Value;
|
||
use specta::Type;
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
use tauri::ipc::Channel;
|
||
use tauri::{AppHandle, Manager, State, Window};
|
||
|
||
/// Browse before connecting, using the desktop's SSH configuration even in a remote window.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn remote_bootstrap_list(
|
||
state: State<'_, AppState>,
|
||
ssh: String,
|
||
path: String,
|
||
) -> IdeResult<Vec<ide_ssh::RemoteEntry>> {
|
||
state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await
|
||
}
|
||
|
||
/// Where a remote project lives and how the server there is reached.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||
pub enum RemoteTarget {
|
||
/// The host of SSH configuration `config`.
|
||
Ssh { config: String },
|
||
/// A WSL distribution on this machine.
|
||
Wsl { distro: String },
|
||
}
|
||
|
||
/// Where the server is installed on the remote host (per version).
|
||
fn install_dir(version: &str) -> String {
|
||
format!("~/{SERVER_DIR}/{version}")
|
||
}
|
||
|
||
/// Under the remote home: where the server builds are kept, one folder per version.
|
||
pub(crate) const SERVER_DIR: &str = ".openidea/server";
|
||
|
||
/// File name of the server binary on every host.
|
||
pub(crate) const SERVER_BIN: &str = "openidea-server";
|
||
|
||
/// Printed before a probe's answer: shell start-up files and `wsl.exe` may print first.
|
||
pub(crate) const PROBE_MARK: &str = "__OPENIDEA_PROBE__";
|
||
|
||
/// One remote window's connection.
|
||
pub struct Connection {
|
||
peer: Peer,
|
||
pub target: RemoteTarget,
|
||
pub root: String,
|
||
pub host: String,
|
||
/// The server's `std::env::consts::OS`: where the project's commands run.
|
||
pub os: String,
|
||
pub commands: Vec<String>,
|
||
/// Streaming arguments by connection-wide id: the frontend channel and the id
|
||
/// it expects (each webview numbers its own, and a settings window shares this
|
||
/// connection with its parent).
|
||
channels: Channels,
|
||
next_channel: std::sync::atomic::AtomicU64,
|
||
/// Keeps the SSH connection alive; `None` for WSL.
|
||
session: Option<Arc<ide_ssh::Session>>,
|
||
/// Remote ports forwarded to this machine (Ports tool window), by remote port.
|
||
forwards: Mutex<Vec<(u16, ide_ssh::Tunnel)>>,
|
||
}
|
||
|
||
type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
|
||
|
||
#[derive(Default)]
|
||
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
|
||
|
||
impl Connection {
|
||
pub async fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||
if ide_host::trust::restricted_command_allowed(command) {
|
||
return Ok(());
|
||
}
|
||
let project: Option<ide_host::ops::project::ProjectInfo> = self
|
||
.peer
|
||
.request(
|
||
"invoke",
|
||
Invoke { window: window.to_owned(), cmd: "project_current".into(), args: serde_json::json!({}) },
|
||
None,
|
||
)
|
||
.await
|
||
.map_err(ide_host::rpc::error_from_reply)?;
|
||
if project.is_some_and(|p| p.trusted) {
|
||
Ok(())
|
||
} else {
|
||
Err(IdeError::unavailable("Project is in Restricted Mode"))
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Connections {
|
||
pub fn get(&self, window: &str) -> Option<Arc<Connection>> {
|
||
self.0.lock().get(window).cloned()
|
||
}
|
||
|
||
/// The window connected to folder `root` of `target`.
|
||
pub fn window_of(&self, target: &RemoteTarget, root: &str) -> Option<String> {
|
||
let root = root.trim_end_matches('/');
|
||
self.0
|
||
.lock()
|
||
.iter()
|
||
.find(|(_, c)| c.target == *target && c.root.trim_end_matches('/') == root)
|
||
.map(|(label, _)| label.clone())
|
||
}
|
||
|
||
/// Every connected window.
|
||
pub fn all(&self) -> Vec<(String, Arc<Connection>)> {
|
||
self.0.lock().iter().map(|(w, c)| (w.clone(), c.clone())).collect()
|
||
}
|
||
|
||
pub fn disconnect(&self, window: &str) {
|
||
if let Some(c) = self.0.lock().remove(window) {
|
||
c.peer.kill();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload).
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum RemoteStage {
|
||
Connecting,
|
||
Checking,
|
||
Downloading,
|
||
Uploading,
|
||
Starting,
|
||
Opening,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RemoteProgress {
|
||
pub stage: RemoteStage,
|
||
pub fraction: Option<f64>,
|
||
}
|
||
|
||
pub(crate) type Progress = dyn Fn(RemoteStage, Option<f64>) + Send + Sync;
|
||
|
||
/// A value for one streaming argument of a remote command (`data`: JSON text).
|
||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ChannelFrame {
|
||
pub id: u64,
|
||
pub data: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RemoteProject {
|
||
pub target: RemoteTarget,
|
||
pub root: String,
|
||
/// `user@host:port` of the SSH configuration; the distribution name for WSL.
|
||
pub host: String,
|
||
pub server_version: String,
|
||
pub os: String,
|
||
/// Commands the server answers (the rest run locally).
|
||
pub commands: Vec<String>,
|
||
pub project: ide_host::ops::project::ProjectInfo,
|
||
}
|
||
|
||
struct ClientHandler {
|
||
app: AppHandle,
|
||
window: String,
|
||
channels: Channels,
|
||
}
|
||
|
||
impl Handler for ClientHandler {
|
||
fn on_notification(&self, method: &str, params: Value) {
|
||
match method {
|
||
"event" => match serde_json::from_value::<EventMessage>(params) {
|
||
Ok(m) => {
|
||
// The server's windows are this window.
|
||
let _ = m.window;
|
||
TauriSink { app: self.app.clone(), window: Some(self.window.clone()) }.emit(m.event);
|
||
}
|
||
Err(e) => tracing::warn!("remote event: {e}"),
|
||
},
|
||
// Plugin code of this window's project runs on the server.
|
||
ide_host::plugins::remote_host::EVENT_NOTIFICATION => {
|
||
match serde_json::from_value::<ide_host::plugins::remote_host::PluginEventMessage>(params) {
|
||
Ok(m) if ide_host::plugins::remote_host::forwarded(&m.event) => {
|
||
crate::commands::plugin::deliver_to(&self.window, m.event)
|
||
}
|
||
Ok(_) => {}
|
||
Err(e) => tracing::warn!("remote plugin event: {e}"),
|
||
}
|
||
}
|
||
"channel" => match serde_json::from_value::<ChannelMessage>(params) {
|
||
Ok(m) => {
|
||
let channel = self.channels.lock().get(&m.id).cloned();
|
||
if let Some((c, id)) = channel {
|
||
let _ = c.send(ChannelFrame { id, data: m.data.to_string() });
|
||
}
|
||
}
|
||
Err(e) => tracing::warn!("remote channel: {e}"),
|
||
},
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||
use ide_host::plugins::remote_host::{SECRET_REQUEST, SecretRequest};
|
||
match method {
|
||
// A remote plugin's request header needs a secret from this machine's keychain.
|
||
SECRET_REQUEST => {
|
||
let r: SecretRequest =
|
||
serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?;
|
||
let state = self.app.state::<AppState>();
|
||
Ok(serde_json::to_value(state.plugins.declared_secret(&r.plugin, &r.name)).unwrap_or(Value::Null))
|
||
}
|
||
_ => Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The server binary for a remote `os`/`arch`: `OPENIDEA_SERVER_BINARY`, then
|
||
/// `<data dir>/server/<os>-<arch>/openidea-server`, then one next to this app
|
||
/// when the platforms match.
|
||
fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::path::PathBuf> {
|
||
if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||
return Some(p);
|
||
}
|
||
let bundled = state.local_data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
|
||
if bundled.is_file() {
|
||
return Some(bundled);
|
||
}
|
||
if os == std::env::consts::OS && arch == std::env::consts::ARCH {
|
||
let exe = std::env::current_exe().ok()?;
|
||
let name = if cfg!(windows) { "openidea-server.exe" } else { "openidea-server" };
|
||
let sibling = exe.parent()?.join(name);
|
||
if sibling.is_file() {
|
||
return Some(sibling);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// The release build of the server for `os`/`arch`, downloaded once into the data dir.
|
||
async fn download_server(
|
||
state: &AppState,
|
||
version: &str,
|
||
os: &str,
|
||
arch: &str,
|
||
progress: Arc<Progress>,
|
||
) -> IdeResult<std::path::PathBuf> {
|
||
let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
|
||
tokio::task::spawn_blocking(move || {
|
||
ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e {
|
||
ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"),
|
||
ide_tools::InstallEvent::Progress { fraction, .. } => progress(RemoteStage::Downloading, fraction),
|
||
})
|
||
})
|
||
.await
|
||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||
}
|
||
|
||
/// `uname -sm` → (`linux` | `macos`, `x86_64` | `aarch64`), as Rust names them.
|
||
pub(crate) fn platform(uname: &str) -> (String, String) {
|
||
let mut parts = uname.split_whitespace();
|
||
let os = match parts.next().unwrap_or_default().to_ascii_lowercase().as_str() {
|
||
"darwin" => "macos".to_owned(),
|
||
other => other.to_owned(),
|
||
};
|
||
let arch = match parts.next().unwrap_or_default() {
|
||
"arm64" => "aarch64".to_owned(),
|
||
"amd64" => "x86_64".to_owned(),
|
||
other => other.to_owned(),
|
||
};
|
||
(os, arch)
|
||
}
|
||
|
||
/// What [`local_server`] settled on.
|
||
pub(crate) enum LocalServer {
|
||
/// A server binary on this machine and its [`build_id`].
|
||
Binary(std::path::PathBuf, String),
|
||
/// No binary available (offline): this build of the version already on the host.
|
||
Installed(String),
|
||
}
|
||
|
||
/// The server binary to put on a host of `os`/`arch` whose `installed` builds are known.
|
||
pub(crate) async fn local_server(
|
||
state: &AppState,
|
||
version: &str,
|
||
os: &str,
|
||
arch: &str,
|
||
installed: &[String],
|
||
progress: Arc<Progress>,
|
||
) -> IdeResult<LocalServer> {
|
||
let local = match local_server_binary(state, os, arch) {
|
||
Some(local) => local,
|
||
None => match download_server(state, version, os, arch, progress).await {
|
||
Ok(local) => local,
|
||
// Offline: any build of this version already on the host will do.
|
||
Err(e) => {
|
||
return installed.first().cloned().map(LocalServer::Installed).ok_or_else(|| {
|
||
IdeError::unavailable(format!(
|
||
"no openidea-server {version} for {os}-{arch} ({}). Build it with \
|
||
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
|
||
(or set OPENIDEA_SERVER_BINARY)",
|
||
e.message,
|
||
state
|
||
.local_data_dir
|
||
.join("server")
|
||
.join(format!("{os}-{arch}"))
|
||
.join("openidea-server")
|
||
.display()
|
||
))
|
||
});
|
||
}
|
||
},
|
||
};
|
||
// One folder per build, so a rebuilt server of the same version is installed again.
|
||
let build = tokio::task::spawn_blocking({
|
||
let local = local.clone();
|
||
move || build_id(&local)
|
||
})
|
||
.await
|
||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||
Ok(LocalServer::Binary(local, build))
|
||
}
|
||
|
||
/// Makes sure this version of the server is installed on the host; returns its path.
|
||
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
|
||
progress(RemoteStage::Checking, None);
|
||
let version = env!("CARGO_PKG_VERSION");
|
||
let dir = install_dir(version);
|
||
let q = ide_ssh::shell::tilde_quote;
|
||
let probe = session
|
||
.exec(&format!("echo {PROBE_MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
|
||
.await?
|
||
.stdout_str();
|
||
let probe = probe.split_once(PROBE_MARK).map_or(probe.as_str(), |(_, rest)| rest);
|
||
let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty());
|
||
let (os, arch) = platform(lines.next().unwrap_or_default());
|
||
let installed: Vec<String> = lines.map(str::to_owned).collect();
|
||
let (local, build) = match local_server(state, version, &os, &arch, &installed, progress.clone()).await? {
|
||
LocalServer::Installed(bin) => return Ok(bin),
|
||
LocalServer::Binary(local, build) => (local, build),
|
||
};
|
||
if let Some(bin) = installed.iter().find(|b| b.ends_with(&format!("/{build}/openidea-server"))) {
|
||
return Ok(bin.to_string());
|
||
}
|
||
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
|
||
let remote_dir = fs.resolve(&dir).await;
|
||
fs.close().await;
|
||
let remote_dir = remote_dir?;
|
||
let remote_bin = ide_ssh::sftp::join(&ide_ssh::sftp::join(&remote_dir, &build), "openidea-server");
|
||
// Per install: another window may be installing the same build right now.
|
||
let tmp = format!(
|
||
"{remote_bin}.{}-{}.part",
|
||
std::process::id(),
|
||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
|
||
);
|
||
let started = std::time::Instant::now();
|
||
progress(RemoteStage::Uploading, Some(0.0));
|
||
session
|
||
.put_with_progress(&tmp, &tokio::fs::read(&local).await?, &|f| progress(RemoteStage::Uploading, Some(f)))
|
||
.await?;
|
||
tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed());
|
||
let q = ide_ssh::shell::quote;
|
||
// Older builds of this version go (a running one keeps its open file).
|
||
session
|
||
.exec(
|
||
&format!(
|
||
"chmod 755 {tmp} && mv -f {tmp} {bin} && find {dir} -mindepth 1 -maxdepth 1 ! -name {build} -exec rm -rf {{}} +",
|
||
tmp = q(&tmp),
|
||
bin = q(&remote_bin),
|
||
dir = q(&remote_dir),
|
||
build = q(&build),
|
||
),
|
||
None,
|
||
)
|
||
.await?
|
||
.into_stdout("installing openidea-server")?;
|
||
Ok(remote_bin)
|
||
}
|
||
|
||
/// A short content hash of the server binary (cached per path, size and time).
|
||
fn build_id(path: &std::path::Path) -> IdeResult<String> {
|
||
static CACHE: Mutex<Option<(std::path::PathBuf, u64, std::time::SystemTime, String)>> = Mutex::new(None);
|
||
let meta = std::fs::metadata(path)?;
|
||
let modified = meta.modified()?;
|
||
if let Some((p, len, time, id)) = &*CACHE.lock()
|
||
&& p == path
|
||
&& *len == meta.len()
|
||
&& *time == modified
|
||
{
|
||
return Ok(id.clone());
|
||
}
|
||
let id = blake3::hash(&std::fs::read(path)?).to_hex()[..16].to_owned();
|
||
*CACHE.lock() = Some((path.to_path_buf(), meta.len(), modified, id.clone()));
|
||
Ok(id)
|
||
}
|
||
|
||
/// File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
|
||
/// in this window (installs / starts the server as needed).
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn remote_connect(
|
||
window: Window,
|
||
state: State<'_, AppState>,
|
||
target: RemoteTarget,
|
||
path: String,
|
||
on_progress: Channel<RemoteProgress>,
|
||
) -> IdeResult<RemoteProject> {
|
||
let progress: Arc<Progress> = Arc::new(move |stage, fraction| {
|
||
let _ = on_progress.send(RemoteProgress { stage, fraction });
|
||
});
|
||
progress(RemoteStage::Connecting, None);
|
||
let label = AppState::owner_window(window.label()).to_owned();
|
||
state.remote_dev.disconnect(&label);
|
||
state.host.close_project(&label);
|
||
let channels: Channels = Arc::default();
|
||
let handler =
|
||
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
|
||
let tail = crate::remote_wsl::Tail::default();
|
||
let (peer, session, host, title_tag) = match &target {
|
||
RemoteTarget::Ssh { config: id } => {
|
||
let config = state.ssh.config(id)?;
|
||
let session = state.ssh.session(id).await?;
|
||
let bin = ensure_server(&state, &session, progress.clone()).await?;
|
||
progress(RemoteStage::Starting, None);
|
||
let stream = session
|
||
.exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin)))
|
||
.await
|
||
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
|
||
let (reader, writer) = tokio::io::split(stream);
|
||
(Peer::connect(reader, writer, handler), Some(session), config.label(), config.label())
|
||
}
|
||
RemoteTarget::Wsl { distro } => {
|
||
let bin = crate::remote_wsl::ensure_server(&state, distro, progress.clone()).await?;
|
||
progress(RemoteStage::Starting, None);
|
||
let peer = crate::remote_wsl::spawn(distro, &bin, handler, &tail)?;
|
||
(peer, None, distro.clone(), format!("WSL: {distro}"))
|
||
}
|
||
};
|
||
// A server that dies on start-up says why on stderr (`wsl.exe` prints its own errors too).
|
||
let with_output = |e: IdeError| match tail.text() {
|
||
t if t.is_empty() => e,
|
||
t => IdeError::unavailable(format!("{}: {t}", e.message)),
|
||
};
|
||
let info: ServerInfo = peer
|
||
.request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None)
|
||
.await
|
||
.map_err(|e| with_output(ide_host::rpc::error_from_reply(e)))?;
|
||
if info.protocol != ide_host::rpc::PROTOCOL_VERSION {
|
||
return Err(IdeError::unavailable(format!(
|
||
"openidea-server {} speaks protocol {}, this app {}",
|
||
info.version,
|
||
info.protocol,
|
||
ide_host::rpc::PROTOCOL_VERSION
|
||
)));
|
||
}
|
||
let commands: Vec<String> =
|
||
peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||
let root = if path.trim().is_empty() || path.trim() == "~" {
|
||
info.home.clone()
|
||
} else if let Some(rest) = path.trim().strip_prefix("~/") {
|
||
format!("{}/{rest}", info.home.trim_end_matches('/'))
|
||
} else {
|
||
path.trim().to_owned()
|
||
};
|
||
progress(RemoteStage::Opening, None);
|
||
let call = Invoke { window: label.clone(), cmd: "open_project".into(), args: serde_json::json!({ "path": root }) };
|
||
let project: ide_host::ops::project::ProjectInfo =
|
||
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
|
||
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
|
||
let _ = window.set_title(&format!("{} [{title_tag}]", project.name));
|
||
let connection = Arc::new(Connection {
|
||
peer,
|
||
target: target.clone(),
|
||
root: root.clone(),
|
||
host: host.clone(),
|
||
os: info.os.clone(),
|
||
commands: commands.clone(),
|
||
channels,
|
||
next_channel: std::sync::atomic::AtomicU64::new(1),
|
||
session,
|
||
forwards: Mutex::new(Vec::new()),
|
||
});
|
||
state.remote_dev.0.lock().insert(label.clone(), connection.clone());
|
||
let plugins = state.plugins.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
sync_plugin_languages(&plugins, &connection, &label).await;
|
||
sync_plugin_packages(&plugins, &connection, &label).await;
|
||
});
|
||
Ok(RemoteProject { target, root, host, server_version: info.version, os: info.os, commands, project })
|
||
}
|
||
|
||
/// Sends the desktop's plugin languages, with the programs granted to them, to
|
||
/// the window's server: `Workspace` contributions take effect where the project
|
||
/// is. Servers without the command are left alone.
|
||
pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &Connection, window: &str) {
|
||
use ide_host::plugins::remote::{SYNC_COMMAND, SyncReport};
|
||
if !c.commands.iter().any(|cmd| cmd == SYNC_COMMAND) {
|
||
return;
|
||
}
|
||
let call = Invoke {
|
||
window: window.to_owned(),
|
||
cmd: SYNC_COMMAND.into(),
|
||
args: serde_json::json!({ "languages": plugins.remote_languages() }),
|
||
};
|
||
match c.peer.request::<_, SyncReport>("invoke", call, None).await {
|
||
Ok(report) => {
|
||
for program in &report.unresolved {
|
||
tracing::info!("{}: plugin program {program} is not installed", c.host);
|
||
}
|
||
for rejected in &report.rejected {
|
||
tracing::warn!("{}: plugin language rejected: {rejected}", c.host);
|
||
}
|
||
}
|
||
Err(e) => tracing::warn!("{}: syncing plugin languages: {e}", c.host),
|
||
}
|
||
}
|
||
|
||
/// Sends the desktop's live code plugins to the window's server, which runs
|
||
/// them next to the project: digests and grants first,
|
||
/// then the files of the packages the server lacks, one per request.
|
||
pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>, c: &Connection, window: &str) {
|
||
use ide_host::plugins::remote_host::{PackageSyncReport, SYNC_PACKAGES};
|
||
if !c.commands.iter().any(|cmd| cmd == SYNC_PACKAGES) {
|
||
return;
|
||
}
|
||
let host = plugins.clone();
|
||
let Ok(packages) = tauri::async_runtime::spawn_blocking(move || host.remote_packages()).await else { return };
|
||
let sync = |files: serde_json::Value| Invoke {
|
||
window: window.to_owned(),
|
||
cmd: SYNC_PACKAGES.into(),
|
||
args: serde_json::json!({ "packages": packages, "files": files }),
|
||
};
|
||
let report: PackageSyncReport = match c.peer.request("invoke", sync(Value::Null), None).await {
|
||
Ok(r) => r,
|
||
Err(e) => return tracing::warn!("{}: syncing plugins: {e}", c.host),
|
||
};
|
||
let mut failed = report.failed;
|
||
for id in report.missing {
|
||
let host = plugins.clone();
|
||
let pid = id.clone();
|
||
let files = match tauri::async_runtime::spawn_blocking(move || host.remote_package_files(&pid)).await {
|
||
Ok(Ok(files)) => files,
|
||
Ok(Err(e)) => {
|
||
failed.push(format!("{id}: {e}"));
|
||
continue;
|
||
}
|
||
Err(e) => {
|
||
failed.push(format!("{id}: {e}"));
|
||
continue;
|
||
}
|
||
};
|
||
match c
|
||
.peer
|
||
.request::<_, PackageSyncReport>("invoke", sync(serde_json::json!({ (id.clone()): files })), None)
|
||
.await
|
||
{
|
||
Ok(r) => failed.extend(r.failed),
|
||
Err(e) => failed.push(format!("{id}: {e}")),
|
||
}
|
||
}
|
||
for f in failed {
|
||
tracing::warn!("{}: plugin not available remotely: {f}", c.host);
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RemoteSession {
|
||
pub target: RemoteTarget,
|
||
pub root: String,
|
||
pub host: String,
|
||
/// `linux`, `macos` or `windows`: the OS the project's commands run on.
|
||
pub os: String,
|
||
pub commands: Vec<String>,
|
||
}
|
||
|
||
/// The remote session of this window (a settings window: its parent's), if any.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option<RemoteSession> {
|
||
let c = state.remote_dev.get(AppState::owner_window(window.label()))?;
|
||
Some(RemoteSession {
|
||
target: c.target.clone(),
|
||
root: c.root.clone(),
|
||
host: c.host.clone(),
|
||
os: c.os.clone(),
|
||
commands: c.commands.clone(),
|
||
})
|
||
}
|
||
|
||
/// A port of the remote host reachable on this machine at `localhost:local_port`.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ForwardedPort {
|
||
pub remote_port: u16,
|
||
pub local_port: u16,
|
||
}
|
||
|
||
fn forwarded(c: &Connection) -> Vec<ForwardedPort> {
|
||
c.forwards.lock().iter().map(|(remote, t)| ForwardedPort { remote_port: *remote, local_port: t.port() }).collect()
|
||
}
|
||
|
||
fn connection(window: &Window, state: &AppState) -> IdeResult<Arc<Connection>> {
|
||
state
|
||
.remote_dev
|
||
.get(AppState::owner_window(window.label()))
|
||
.ok_or_else(|| IdeError::unavailable("This window is not connected to a remote host"))
|
||
}
|
||
|
||
/// The remote ports this window forwards to this machine.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn remote_forwarded_ports(window: Window, state: State<'_, AppState>) -> Vec<ForwardedPort> {
|
||
connection(&window, &state).map(|c| forwarded(&c)).unwrap_or_default()
|
||
}
|
||
|
||
/// Forwards `localhost:<remote_port>` of the remote host to the same port here when it is free,
|
||
/// else to a free one. Nothing needs to listen there yet; connections are opened per use.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn remote_forward_port(
|
||
window: Window,
|
||
state: State<'_, AppState>,
|
||
remote_port: u16,
|
||
) -> IdeResult<ForwardedPort> {
|
||
if remote_port == 0 {
|
||
return Err(IdeError::invalid("Port must be between 1 and 65535"));
|
||
}
|
||
let c = connection(&window, &state)?;
|
||
if let Some(p) = forwarded(&c).into_iter().find(|p| p.remote_port == remote_port) {
|
||
return Ok(p);
|
||
}
|
||
let session = c
|
||
.session
|
||
.clone()
|
||
.ok_or_else(|| IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))?;
|
||
let tunnel = ide_ssh::Tunnel::listen_on(session, Some(remote_port), "localhost", remote_port).await?;
|
||
let port = ForwardedPort { remote_port, local_port: tunnel.port() };
|
||
let mut forwards = c.forwards.lock();
|
||
if let Some(existing) = forwards.iter().find(|(remote, _)| *remote == remote_port) {
|
||
return Ok(ForwardedPort { remote_port, local_port: existing.1.port() });
|
||
}
|
||
forwards.push((remote_port, tunnel));
|
||
Ok(port)
|
||
}
|
||
|
||
/// Stops forwarding `remote_port`.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn remote_stop_forwarding(window: Window, state: State<'_, AppState>, remote_port: u16) -> IdeResult<()> {
|
||
connection(&window, &state)?.forwards.lock().retain(|(remote, _)| *remote != remote_port);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub fn remote_disconnect(window: Window, state: State<'_, AppState>) {
|
||
state.remote_dev.disconnect(AppState::owner_window(window.label()));
|
||
}
|
||
|
||
/// Runs a command on the window's remote server; `args` and the result are JSON
|
||
/// text. Streaming arguments are `{ "$channel": id }`; their values arrive on
|
||
/// `on_channel` tagged with the id.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn remote_invoke(
|
||
window: Window,
|
||
state: State<'_, AppState>,
|
||
cmd: String,
|
||
args: String,
|
||
on_channel: Channel<ChannelFrame>,
|
||
) -> IdeResult<String> {
|
||
let mut args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?;
|
||
let label = AppState::owner_window(window.label()).to_owned();
|
||
let c = state
|
||
.remote_dev
|
||
.get(&label)
|
||
.ok_or_else(|| IdeError::unavailable("this window is not connected to a remote host"))?;
|
||
if let Value::Object(map) = &mut args {
|
||
for v in map.values_mut() {
|
||
if let Some(id) = v.get("$channel").and_then(Value::as_u64) {
|
||
let own = c.next_channel.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
c.channels.lock().insert(own, (on_channel.clone(), id));
|
||
*v = serde_json::json!({ "$channel": own });
|
||
}
|
||
}
|
||
}
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct DocumentEdit {
|
||
doc: ide_core::DocId,
|
||
base_version: u32,
|
||
changes: ide_core::ChangeSet,
|
||
}
|
||
let mirrored = if cmd == "doc_edit" {
|
||
let peers = crate::events::document_edit_peers(window.app_handle(), window.label());
|
||
if peers.is_empty() {
|
||
None
|
||
} else {
|
||
serde_json::from_value::<DocumentEdit>(args.clone()).ok().map(|edit| (peers, edit))
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let call = Invoke { window: label, cmd, args };
|
||
let result: Value = c.peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||
if let (Some((peers, edit)), Some(version)) =
|
||
(mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok()))
|
||
{
|
||
crate::events::emit_document_edit(
|
||
window.app_handle(),
|
||
peers,
|
||
edit.doc,
|
||
edit.base_version,
|
||
version,
|
||
edit.changes.changes,
|
||
);
|
||
}
|
||
Ok(result.to_string())
|
||
}
|
||
|
||
/// A client OS file belongs to this machine even when its destination is remote.
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn remote_import_local(
|
||
window: Window,
|
||
state: State<'_, AppState>,
|
||
from: std::path::PathBuf,
|
||
to: String,
|
||
request: ide_core::RequestId,
|
||
) -> IdeResult<Vec<String>> {
|
||
let label = AppState::owner_window(window.label());
|
||
let c = state.remote_dev.get(label).ok_or_else(|| IdeError::unavailable("Remote project is disconnected"))?;
|
||
let Some(session) = c.session.clone() else {
|
||
return Err(IdeError::unavailable("Importing local files is not supported in a WSL project yet"));
|
||
};
|
||
c.authorize_command(label, "fs_copy").await?;
|
||
let cancel = state.host.begin_request(request);
|
||
let result = async {
|
||
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
|
||
let result = ide_remote::import::import_local(&fs, &from, &to, &cancel).await;
|
||
fs.close().await;
|
||
result
|
||
}
|
||
.await;
|
||
state.host.end_request(request);
|
||
result
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn maps_uname_to_rust_platform_names() {
|
||
assert_eq!(platform("Linux x86_64"), ("linux".into(), "x86_64".into()));
|
||
assert_eq!(platform("Darwin arm64"), ("macos".into(), "aarch64".into()));
|
||
assert_eq!(platform("Linux aarch64"), ("linux".into(), "aarch64".into()));
|
||
}
|
||
}
|