Files
openidea/src-tauri/src/remote_wsl.rs
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 <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 15:37:19 +08:00

283 lines
12 KiB
Rust

//! WSL transport of remote development: `openidea-server --stdio` started with
//! `wsl.exe --exec` inside a distro, instead of over SSH.
//!
//! Every command goes through `--exec`, which runs the program directly without a shell on
//! the Windows side; only fixed scripts reach `sh -c` inside the distro and everything variable
//! (paths, versions) is passed as positional arguments, never spliced into the script.
use crate::remote_dev::{PROBE_MARK, Progress, RemoteStage, SERVER_BIN, SERVER_DIR};
use ide_core::{IdeError, IdeResult};
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
/// Bound for the short probes; `wsl.exe` can wait on a distro that is still starting.
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
const INSTALL_TIMEOUT: Duration = Duration::from_secs(300);
const UPLOAD_CHUNK: usize = 256 * 1024;
/// Output lines of the server kept for the error shown when it dies early.
const KEPT_LINES: usize = 20;
/// What the probe learned about a distro.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Probe {
pub home: String,
/// `uname -sm`.
pub uname: String,
/// Installed builds of this version (paths of `openidea-server`).
pub installed: Vec<String>,
}
fn checked(distro: &str) -> IdeResult<&str> {
if ide_core::wsl::is_valid_distro(distro) {
Ok(distro)
} else {
Err(IdeError::invalid(format!("'{distro}' is not a valid WSL distribution name")))
}
}
/// `wsl.exe -d <distro> --exec sh -c <script> sh <args>…`.
fn sh(distro: &str, script: &str, args: &[&str]) -> IdeResult<tokio::process::Command> {
let mut cmd = tokio::process::Command::new("wsl.exe");
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(["-d", checked(distro)?, "--exec", "sh", "-c", script, "sh"])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
Ok(cmd)
}
/// The reason a `wsl.exe` call failed, as text (its own messages are UTF-16).
fn failure(distro: &str, out: &std::process::Output) -> IdeError {
let mut text = ide_core::wsl::decode_output(&out.stderr);
if text.trim().is_empty() {
text = ide_core::wsl::decode_output(&out.stdout);
}
let text: String = text.trim().chars().filter(|c| !c.is_control() || *c == '\n').take(400).collect();
IdeError::unavailable(format!("cannot run a command in WSL distribution '{distro}': {text}"))
}
async fn output(distro: &str, mut cmd: tokio::process::Command, limit: Duration) -> IdeResult<std::process::Output> {
let out = tokio::time::timeout(limit, cmd.output())
.await
.map_err(|_| IdeError::unavailable(format!("WSL distribution '{distro}' did not answer in time")))?
.map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))?;
if out.status.success() { Ok(out) } else { Err(failure(distro, &out)) }
}
/// Splits the probe output (after its marker) into [`Probe`].
fn parse_probe(text: &str) -> IdeResult<Probe> {
// Between markers: `wsl.exe` may print notices to stdout before the script runs.
let text = text.split_once(PROBE_MARK).map_or("", |(_, rest)| rest);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let home = lines.next().unwrap_or_default().to_owned();
// The path is spliced into nothing, but it must be one clean absolute path.
if !home.starts_with('/') || home.chars().any(char::is_control) {
return Err(IdeError::unavailable("cannot find the home directory in the WSL distribution"));
}
let uname = lines.next().unwrap_or_default().to_owned();
Ok(Probe { home, uname, installed: lines.map(str::to_owned).collect() })
}
/// Home directory, platform and installed server builds of `distro` (starts it if stopped).
pub(crate) async fn probe(distro: &str, version: &str) -> IdeResult<Probe> {
let script = format!(
r#"echo {PROBE_MARK}; echo "$HOME"; uname -sm; ls -d "$HOME/{SERVER_DIR}/$1"/*/{SERVER_BIN} 2>/dev/null; true"#
);
let out = output(distro, sh(distro, &script, &[version])?, PROBE_TIMEOUT).await?;
parse_probe(&String::from_utf8_lossy(&out.stdout))
}
/// Where the builds of `version` live in a distro whose home is `home`.
pub(crate) fn version_dir(home: &str, version: &str) -> String {
format!("{}/{SERVER_DIR}/{version}", home.trim_end_matches('/'))
}
/// Streams `data` into `<version_dir>/<build>/openidea-server` of `distro` and removes the other
/// builds of the version. Returns the installed path.
pub(crate) async fn install(
distro: &str,
version_dir: &str,
build: &str,
data: &[u8],
progress: &Arc<Progress>,
) -> IdeResult<String> {
let build_dir = format!("{version_dir}/{build}");
// Per install: another window may be installing the same build right now.
let script = format!(
r#"set -e; mkdir -p "$1"; p="$1/{SERVER_BIN}.part.$$"; trap 'rm -f "$p"' EXIT; cat > "$p"; chmod 755 "$p"; mv -f "$p" "$1/{SERVER_BIN}"; find "$2" -mindepth 1 -maxdepth 1 ! -name "$3" -exec rm -rf {{}} +"#
);
let mut cmd = sh(distro, &script, &[&build_dir, version_dir, build])?;
cmd.stdin(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))?;
let mut stdin = child.stdin.take().ok_or_else(|| IdeError::internal("wsl.exe stdin unavailable"))?;
// `stdin` drops at the end of the upload: that is the EOF `cat` waits for.
let upload = async move {
for (i, chunk) in data.chunks(UPLOAD_CHUNK).enumerate() {
stdin.write_all(chunk).await?;
progress(RemoteStage::Uploading, Some(((i + 1) * UPLOAD_CHUNK).min(data.len()) as f64 / data.len() as f64));
}
stdin.flush().await
};
let result = tokio::time::timeout(INSTALL_TIMEOUT, async {
let uploaded = upload.await;
(uploaded, child.wait_with_output().await)
})
.await
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in '{distro}' timed out")))?;
match result {
(Ok(()), Ok(out)) if out.status.success() => Ok(format!("{build_dir}/{SERVER_BIN}")),
(_, Ok(out)) => Err(failure(distro, &out)),
(_, Err(e)) => Err(IdeError::unavailable(format!("installing {SERVER_BIN} failed: {e}"))),
}
}
/// The last lines the server (or `wsl.exe`) wrote outside the protocol.
#[derive(Clone, Default)]
pub(crate) struct Tail(Arc<Mutex<VecDeque<String>>>);
impl Tail {
fn push(&self, line: String) {
let mut lines = self.0.lock();
if lines.len() == KEPT_LINES {
lines.pop_front();
}
lines.push_back(line);
}
pub fn text(&self) -> String {
self.0.lock().iter().cloned().collect::<Vec<_>>().join("\n")
}
}
/// Starts `bin --stdio` in `distro` and speaks the protocol over its stdio. The process dies
/// with the connection.
pub(crate) fn spawn(distro: &str, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
spawn_with(ide_core::wsl::EXE, distro, bin, handler, tail)
}
/// [`spawn`] through `launcher` instead of `wsl.exe` (tests put a stand-in there).
fn spawn_with(launcher: &str, distro: &str, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
let args: Vec<String> = ["-d", checked(distro)?, "--exec", bin, "--stdio"].map(str::to_owned).into();
let tail = tail.clone();
Peer::spawn(launcher, &args, &std::env::temp_dir(), &[], handler, Box::new(move |line| tail.push(line)))
.map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))
}
/// Makes sure this version of the server is installed in `distro`; returns its path there.
pub(crate) async fn ensure_server(
state: &crate::state::AppState,
distro: &str,
progress: Arc<Progress>,
) -> IdeResult<String> {
use crate::remote_dev::{LocalServer, local_server, platform};
progress(RemoteStage::Checking, None);
let version = env!("CARGO_PKG_VERSION");
let probe = probe(distro, version).await?;
let (os, arch) = platform(&probe.uname);
if os != "linux" {
return Err(IdeError::unavailable(format!("'{distro}' reports {os} instead of linux")));
}
let (local, build) = match local_server(state, version, &os, &arch, &probe.installed, progress.clone()).await? {
LocalServer::Installed(bin) => return Ok(bin),
LocalServer::Binary(local, build) => (local, build),
};
if let Some(bin) = probe.installed.iter().find(|b| b.ends_with(&format!("/{build}/{SERVER_BIN}"))) {
return Ok(bin.clone());
}
let started = std::time::Instant::now();
progress(RemoteStage::Uploading, Some(0.0));
let data = tokio::fs::read(&local).await?;
let bin = install(distro, &version_dir(&probe.home, version), &build, &data, &progress).await?;
tracing::info!("installed {SERVER_BIN} in WSL '{distro}' in {:?}", started.elapsed());
Ok(bin)
}
#[cfg(test)]
mod tests {
use super::*;
const HOME: &str = "/home/me";
const UNAME: &str = "Linux x86_64";
#[test]
fn probe_output_is_read_after_the_marker() {
let installed = format!("{}/ab/{SERVER_BIN}", version_dir(HOME, "1"));
let text = format!("wsl: notice\n{PROBE_MARK}\n{HOME}\n{UNAME}\n{installed}\n");
let p = parse_probe(&text).unwrap();
assert_eq!(p, Probe { home: HOME.into(), uname: UNAME.into(), installed: vec![installed] });
}
#[test]
fn a_probe_without_a_clean_home_is_refused() {
assert!(parse_probe(&format!("{HOME}\n{UNAME}\n")).is_err(), "no marker");
assert!(parse_probe(&format!("{PROBE_MARK}\nrelative\n{UNAME}\n")).is_err());
assert!(parse_probe(&format!("{PROBE_MARK}\n")).is_err());
}
#[test]
fn invalid_distro_names_never_reach_wsl_exe() {
for bad in ["", "--exec", "a b", "a;b"] {
assert!(sh(bad, "true", &[]).is_err(), "{bad}");
}
assert!(sh("Ubuntu-24.04", "true", &[]).is_ok());
}
#[test]
fn version_dir_ignores_a_trailing_slash() {
assert_eq!(version_dir(&format!("{HOME}/"), "1.2.3"), format!("{HOME}/{SERVER_DIR}/1.2.3"));
}
struct Silent;
impl Handler for Silent {
fn on_notification(&self, _: &str, _: serde_json::Value) {}
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
/// A stand-in for `wsl.exe` that checks the arguments it is given, prints a UTF-16 notice
/// the way the real one does, then answers one request.
#[cfg(unix)]
#[tokio::test]
async fn speaks_the_protocol_through_the_launcher_after_its_utf16_notice() {
use std::os::unix::fs::PermissionsExt;
let (distro, bin) = ("Debian-12", "/home/me/server");
let dir = tempfile::tempdir().unwrap();
let launcher = dir.path().join("fake-wsl");
let script = format!(
r#"#!/bin/sh
[ "$*" = "-d {distro} --exec {bin} --stdio" ] || {{ echo "unexpected arguments: $*" >&2; exit 2; }}
printf '\377\376N\000o\000\r\000\n\000'
while read -r line; do
line=${{line%?}}
case "$line" in Content-Length:*) len=${{line#*: }} ;; "") break ;; esac
done
head -c "$len" >/dev/null
body='{{"jsonrpc":"2.0","id":1,"result":"pong"}}'
printf 'Content-Length: %s\r\n\r\n%s' "${{#body}}" "$body"
"#
);
std::fs::write(&launcher, script).unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let tail = Tail::default();
let peer = spawn_with(launcher.to_str().unwrap(), distro, bin, Arc::new(Silent), &tail).unwrap();
let reply: String = peer.request("ping", serde_json::Value::Null, None).await.unwrap_or_else(|e| {
panic!("{e:?}; launcher said: {}", tail.text());
});
assert_eq!(reply, "pong");
}
#[tokio::test]
async fn a_bad_distro_name_never_starts_the_launcher() {
let err = spawn_with("does-not-exist", "-d", "/x", Arc::new(Silent), &Tail::default()).err().unwrap();
assert!(err.message.contains("not a valid"), "{}", err.message);
}
}