Files
openidea/src-tauri/src/remote_dev.rs
T
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* 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>
2026-11-05 15:37:19 +08:00

771 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()));
}
}