//! 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> { 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, /// 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>, /// Remote ports forwarded to this machine (Ports tool window), by remote port. forwards: Mutex>, } type Channels = Arc, u64)>>>; #[derive(Default)] pub struct Connections(Mutex>>); 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 = 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> { self.0.lock().get(window).cloned() } /// The window connected to folder `root` of `target`. pub fn window_of(&self, target: &RemoteTarget, root: &str) -> Option { 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)> { 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, } pub(crate) type Progress = dyn Fn(RemoteStage, Option) + 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, 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::(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::(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::(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 { 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::(); 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 /// `/server/-/openidea-server`, then one next to this app /// when the platforms match. fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option { 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, ) -> IdeResult { 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, ) -> IdeResult { 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) -> IdeResult { 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 = 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 { static CACHE: Mutex> = 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, ) -> IdeResult { let progress: Arc = 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 = 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, 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, } /// 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 { 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 { c.forwards.lock().iter().map(|(remote, t)| ForwardedPort { remote_port: *remote, local_port: t.port() }).collect() } fn connection(window: &Window, state: &AppState) -> IdeResult> { 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 { connection(&window, &state).map(|c| forwarded(&c)).unwrap_or_default() } /// Forwards `localhost:` 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 { 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, ) -> IdeResult { 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::(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> { 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())); } }