fix(remote): Harden deployment paths, server install and remote routing

- Deployment: reject `..` and `/` in remote names and sync paths, keep
  excluded paths root-relative, never delete a mapping's remote root,
  apply excludes to Sync steps
- SSH: connect per configuration so an unreachable host doesn't block
  others; `--` before the host in ssh arguments; read timeout on askpass
- Run targets: export the environment for shell command lines; map the
  project folder only as a whole path
- Remote development: use the server's resolved project root; give
  streaming channels connection-wide ids; per-install `.part` files;
  probe output between markers; require the server's SHA-256; unique
  download staging folders; stop runs, terminals and language servers
  when the server exits
- JSON-RPC: deliver a response after the notifications sent before it
- Release: build the aarch64 musl server with musl-gcc
This commit is contained in:
l0ng-ai
2026-10-27 20:33:09 +08:00
parent e82d766804
commit feb882ed10
18 changed files with 194 additions and 60 deletions
+7 -1
View File
@@ -156,7 +156,13 @@ jobs:
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- run: cargo build --release --locked -p ide-host --bin openidea-server --target ${{ matrix.target }}
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -1
View File
@@ -66,6 +66,10 @@ async fn run(base: PathBuf) -> i32 {
runtime: tokio::runtime::Handle::current(),
}));
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
rpc::serve(host, Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
// `process::exit` skips destructors: nothing started for the client may outlive it.
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
tracing::warn!("language servers did not shut down in time");
}
0
}
+12
View File
@@ -124,6 +124,18 @@ impl Host {
}
}
/// Stops what this host started: language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in projects {
project.agents.shutdown();
project.lsp.shutdown_all().await;
}
}
// ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ----
pub fn begin_request(&self, id: RequestId) -> CancellationToken {
+5 -1
View File
@@ -125,7 +125,11 @@ pub async fn build_sync(
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(project: &Project, pom: &str, dependency: &ArtifactKey) -> IdeResult<(std::path::PathBuf, String, Range)> {
fn declaring_pom(
project: &Project,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
+21 -11
View File
@@ -194,6 +194,14 @@ impl Handler for NoopHandler {
type Pending = HashMap<i64, oneshot::Sender<Result<Value, RpcError>>>;
/// What the delivery thread hands on, in arrival order: a response is not seen
/// before the notifications the peer sent ahead of it (a streamed result's last
/// values before its request completes).
enum Incoming {
Notification(String, Value),
Response(oneshot::Sender<Result<Value, RpcError>>, Result<Value, RpcError>),
}
struct Inner {
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
@@ -451,14 +459,21 @@ impl Peer {
});
}
// Notifications are delivered in order on a dedicated thread.
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<(String, Value)>();
// Notifications (and the responses after them) are delivered in order on a dedicated thread.
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Incoming>();
{
let handler = handler.clone();
std::thread::Builder::new()
.name("jsonrpc-notify".into())
.spawn(move || {
while let Ok((method, params)) = notif_rx.recv() {
while let Ok(incoming) = notif_rx.recv() {
let (method, params) = match incoming {
Incoming::Notification(method, params) => (method, params),
Incoming::Response(tx, result) => {
let _ = tx.send(result);
continue;
}
};
let h = handler.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
h.on_notification(&method, params)
@@ -614,12 +629,7 @@ fn parse_id(id: &Value) -> Option<i64> {
}
}
fn dispatch(
inner: &Arc<Inner>,
handler: &Arc<dyn Handler>,
notif_tx: &std::sync::mpsc::Sender<(String, Value)>,
body: &[u8],
) {
fn dispatch(inner: &Arc<Inner>, handler: &Arc<dyn Handler>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
@@ -657,7 +667,7 @@ fn dispatch(
});
}
_ => {
let _ = notif_tx.send((method, params));
let _ = notif_tx.send(Incoming::Notification(method, params));
}
}
}
@@ -688,7 +698,7 @@ fn dispatch(
}
_ => Ok(obj.remove("result").unwrap_or(Value::Null)),
};
let _ = tx.send(result);
let _ = notif_tx.send(Incoming::Response(tx, result));
}
}
}
+5 -2
View File
@@ -238,7 +238,8 @@ impl Deployment<'_> {
pub async fn delete_remote(&self, paths: &[PathBuf]) -> TransferReport {
let mut report = TransferReport::default();
for p in paths {
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else {
// Never a whole mapping (the project folder went away, or a mapped folder did).
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else {
report.skipped += 1;
continue;
};
@@ -318,7 +319,9 @@ impl Deployment<'_> {
break;
}
let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s));
let Some(remote) = self.map.remote(&local) else {
let Some(remote) =
self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local))
else {
report.skipped += 1;
continue;
};
+15 -1
View File
@@ -61,11 +61,21 @@ impl Mappings {
pub fn remote(&self, local: &Path) -> Option<String> {
self.entries.iter().find_map(|(l, r, _)| {
let rel = local.strip_prefix(l).ok()?;
// `..` would leave the mapping (`posix` drops it).
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
let rel = posix(rel);
Some(if rel.is_empty() { r.clone() } else { join(r, &rel) })
})
}
/// `remote` is the remote folder of a mapping (deleting it would wipe the deployment).
pub fn is_root(&self, remote: &str) -> bool {
let remote = remote.trim_end_matches('/');
self.entries.iter().any(|(_, r, _)| r.trim_end_matches('/') == remote)
}
/// The local path of a remote file or folder (`None`: not under any mapping).
pub fn local(&self, remote: &str) -> Option<PathBuf> {
let remote = remote.trim_end_matches('/');
@@ -85,6 +95,10 @@ impl Mappings {
}
}
let (l, rest, _) = best?;
// A `..` from the server must not leave the local folder.
if rest.split('/').any(|s| s == ".." || s == ".") {
return None;
}
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
}
@@ -126,7 +140,7 @@ impl Excludes {
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| join(remote_root, p).trim_end_matches('/').to_owned())
.map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned())
.collect();
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote }
}
+33 -4
View File
@@ -33,7 +33,14 @@ pub struct RemoteCommand {
impl RemoteCommand {
pub fn line(&self) -> String {
match &self.shell_line {
Some(line) => format!("cd {} && {line}", ide_ssh::shell::quote(&self.cwd)),
Some(line) => {
let exports: String = self
.env
.iter()
.map(|(k, v)| format!("export {}; ", ide_ssh::shell::quote(&format!("{k}={v}"))))
.collect();
format!("cd {} && {exports}{line}", ide_ssh::shell::quote(&self.cwd))
}
None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args),
}
}
@@ -51,6 +58,22 @@ impl RemoteCommand {
}
}
/// `s` with the path `root` replaced by `remote` where it appears as a whole path
/// (followed by a separator or the end of a path), not as the prefix of a longer name.
fn replace_root(s: &str, root: &str, remote: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find(root) {
let after = &rest[i + root.len()..];
let whole = after.chars().next().is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')));
out.push_str(&rest[..i]);
out.push_str(if whole { remote } else { root });
rest = after;
}
out.push_str(rest);
out
}
fn file_name(program: &str) -> &str {
program.rsplit(['/', '\\']).next().unwrap_or(program)
}
@@ -79,10 +102,10 @@ pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remot
if root_str.is_empty() {
return s.to_owned();
}
s.replace(&root_str, remote_root.trim_end_matches('/')).replace('\\', "/")
replace_root(s, &root_str, remote_root.trim_end_matches('/')).replace('\\', "/")
};
let map_text = |s: &str| -> String {
if root_str.is_empty() { s.to_owned() } else { s.replace(&root_str, remote_root.trim_end_matches('/')) }
if root_str.is_empty() { s.to_owned() } else { replace_root(s, &root_str, remote_root.trim_end_matches('/')) }
};
let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) {
Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) {
@@ -314,10 +337,16 @@ mod tests {
);
let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj");
assert_eq!(c.line(), "cd /home/me/proj && npm run dev");
assert_eq!(c.line(), "cd /home/me/proj && export DATA=/home/me/proj/data; npm run dev");
let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv");
assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv"));
let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv");
assert_eq!(c.program, "make");
}
#[test]
fn maps_the_root_only_as_a_whole_path() {
assert_eq!(replace_root("/work/proj2/x:/work/proj/y", "/work/proj", "/srv"), "/work/proj2/x:/srv/y");
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
}
}
+8 -3
View File
@@ -575,10 +575,9 @@ impl ProcessManager {
pub fn list(&self) -> Vec<RunInfo> {
self.runs.lock().values().map(|e| e.info()).collect()
}
}
impl Drop for ProcessManager {
fn drop(&mut self) {
/// Kills every running process tree at once (shutting down).
pub fn kill_all(&self) {
for entry in self.runs.lock().values() {
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = entry.pid
@@ -589,6 +588,12 @@ impl Drop for ProcessManager {
}
}
impl Drop for ProcessManager {
fn drop(&mut self) {
self.kill_all();
}
}
/// The environment a configuration's process gets on top of the inherited one: `.env`
/// files, then the configured variables (later wins).
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
+2
View File
@@ -113,6 +113,8 @@ fn serve(
on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync),
) {
let mut line = String::new();
// The helper writes its request at once: an idle connection must not hold a thread.
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return };
if reader.read_line(&mut line).is_err() {
return;
+6 -4
View File
@@ -18,8 +18,9 @@ pub struct SshManager {
entered: Mutex<HashMap<String, String>>,
configs: RwLock<Vec<SshConfiguration>>,
sessions: Mutex<HashMap<String, Arc<Session>>>,
/// Serialises connecting so concurrent users share one session.
opening: tokio::sync::Mutex<()>,
/// Serialises connecting per configuration so concurrent users share one
/// session (an unreachable host doesn't hold up the others).
opening: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Host keys awaiting the user's trust, by key (configuration id or a caller's own).
unknown: Mutex<HashMap<String, UnknownHostKey>>,
}
@@ -37,7 +38,7 @@ impl SshManager {
entered: Mutex::default(),
configs: RwLock::default(),
sessions: Mutex::default(),
opening: tokio::sync::Mutex::new(()),
opening: Mutex::default(),
unknown: Mutex::default(),
}
}
@@ -117,7 +118,8 @@ impl SshManager {
if let Some(s) = self.live(id) {
return Ok(s);
}
let _opening = self.opening.lock().await;
let lock = self.opening.lock().entry(id.to_owned()).or_default().clone();
let _opening = lock.lock().await;
if let Some(s) = self.live(id) {
return Ok(s);
}
+2 -1
View File
@@ -124,7 +124,8 @@ impl RemoteFs {
let mut out = Vec::new();
for e in read {
let name = e.file_name();
if name == "." || name == ".." {
// A name with `/` (from a hostile server) would point elsewhere once joined.
if name == "." || name == ".." || name.contains('/') {
continue;
}
let path = join(dir, &name);
+4 -2
View File
@@ -137,6 +137,8 @@ pub fn openssh_args(target: &SshTarget) -> Vec<String> {
args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]);
}
let host = target.host.trim();
// A host starting with `-` must not be read as an option.
args.push("--".into());
args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) });
args
}
@@ -174,9 +176,9 @@ mod tests {
#[test]
fn builds_openssh_arguments() {
let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() };
assert_eq!(openssh_args(&t), ["-p", "2200", "me@box"]);
assert_eq!(openssh_args(&t), ["-p", "2200", "--", "me@box"]);
let t =
SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() };
assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "box"]);
assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "--", "box"]);
}
}
+8 -3
View File
@@ -189,10 +189,9 @@ impl TerminalManager {
fn session(&self, id: TerminalId) -> IdeResult<Arc<Session>> {
self.sessions.lock().get(&id).cloned().ok_or_else(|| not_found(id))
}
}
impl Drop for TerminalManager {
fn drop(&mut self) {
/// Terminates every session (shutting down).
pub fn kill_all(&self) {
let sessions: Vec<_> = self.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
terminate(&s);
@@ -200,6 +199,12 @@ impl Drop for TerminalManager {
}
}
impl Drop for TerminalManager {
fn drop(&mut self) {
self.kill_all();
}
}
fn not_found(id: TerminalId) -> IdeError {
IdeError::not_found(format!("no such terminal: {id}"))
}
+16 -9
View File
@@ -42,19 +42,23 @@ pub fn fetch(
}
let name = asset_name(os, arch);
let url = format!("{RELEASES}/v{version}/{name}");
let dl = sources::Download {
version: version.to_owned(),
checksum_url: Some(format!("{url}.sha256")),
name,
url,
size: None,
sha256: None,
};
let dir = dest.parent().expect("cached path has a parent");
std::fs::create_dir_all(dir)?;
let staging = tempfile_dir(dir)?;
let result = (|| {
let http = Http::new(&default_search_path())?;
// The binary runs on the remote host: never install it unverified.
let checksum_url = format!("{url}.sha256");
let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name)
.ok_or_else(|| IdeError::unavailable(format!("no SHA-256 for {name} at {checksum_url}")))?;
let dl = sources::Download {
version: version.to_owned(),
checksum_url: None,
name,
url,
size: None,
sha256: Some(sha256),
};
let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?;
let unpacked = staging.join("openidea-server");
let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&archive)?);
@@ -72,7 +76,10 @@ pub fn fetch(
}
fn tempfile_dir(parent: &Path) -> IdeResult<PathBuf> {
let dir = parent.join(format!(".download-{}", std::process::id()));
// Unique per call: two windows of this process may download at once.
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = parent.join(format!(".download-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir)?;
Ok(dir)
+1 -1
View File
@@ -2,8 +2,8 @@
use crate::state::AppState;
use ide_build::compile::{BuildEvent, BuildKind, BuildResult};
use ide_build::{BuildModel, BuildTool, SyncProgress};
use ide_build::pom_edit::ArtifactKey;
use ide_build::{BuildModel, BuildTool, SyncProgress};
use ide_core::{IdeResult, Location, RequestId};
#[allow(unused_imports)]
pub use ide_host::ops::build::*;
+10 -2
View File
@@ -106,8 +106,16 @@ pub async fn lsp_references(
include_declaration: bool,
project_wide: bool,
) -> IdeResult<References> {
ide_host::ops::lsp::lsp_references(&state.host, window.label(), request, doc, pos, include_declaration, project_wide)
.await
ide_host::ops::lsp::lsp_references(
&state.host,
window.label(),
request,
doc,
pos,
include_declaration,
project_wide,
)
.await
}
#[tauri::command]
+34 -14
View File
@@ -29,11 +29,16 @@ pub struct Connection {
pub root: String,
pub host: String,
pub commands: Vec<String>,
/// Streaming arguments by id: the frontend channel and the id it expects.
channels: Arc<Mutex<HashMap<u64, Channel<ChannelFrame>>>>,
/// 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,
_session: Arc<ide_ssh::Session>,
}
type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
#[derive(Default)]
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
@@ -74,7 +79,7 @@ pub struct RemoteProject {
struct ClientHandler {
app: AppHandle,
window: String,
channels: Arc<Mutex<HashMap<u64, Channel<ChannelFrame>>>>,
channels: Channels,
}
impl Handler for ClientHandler {
@@ -91,8 +96,8 @@ impl Handler for ClientHandler {
"channel" => match serde_json::from_value::<ChannelMessage>(params) {
Ok(m) => {
let channel = self.channels.lock().get(&m.id).cloned();
if let Some(c) = channel {
let _ = c.send(ChannelFrame { id: m.id, data: m.data.to_string() });
if let Some((c, id)) = channel {
let _ = c.send(ChannelFrame { id, data: m.data.to_string() });
}
}
Err(e) => tracing::warn!("remote channel: {e}"),
@@ -162,9 +167,14 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul
let version = env!("CARGO_PKG_VERSION");
let dir = install_dir(version);
let q = ide_ssh::shell::tilde_quote;
let probe =
session.exec(&format!("uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None).await?.stdout_str();
let mut lines = probe.lines();
// Between markers: shell start-up files may print to stdout too.
const MARK: &str = "__OPENIDEA_PROBE__";
let probe = session
.exec(&format!("echo {MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
.await?
.stdout_str();
let probe = probe.split_once(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<&str> = lines.collect();
let local = match local_server_binary(state, &os, &arch) {
@@ -200,7 +210,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul
fs.close().await;
let remote_dir = remote_dir?;
let remote_bin = ide_ssh::sftp::join(&ide_ssh::sftp::join(&remote_dir, &build), "openidea-server");
let tmp = format!("{remote_bin}.part");
// 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();
session.put(&tmp, &tokio::fs::read(&local).await?).await?;
tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed());
@@ -260,7 +275,7 @@ pub async fn remote_connect(
.await
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
let (reader, writer) = tokio::io::split(stream);
let channels: Arc<Mutex<HashMap<u64, Channel<ChannelFrame>>>> = Arc::default();
let channels: Channels = Arc::default();
let handler =
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
let peer = Peer::connect(reader, writer, handler);
@@ -288,6 +303,8 @@ pub async fn remote_connect(
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!("{} [{}]", project.name, config.label()));
let connection = Arc::new(Connection {
peer,
@@ -296,6 +313,7 @@ pub async fn remote_connect(
host: config.label(),
commands: commands.clone(),
channels,
next_channel: std::sync::atomic::AtomicU64::new(1),
_session: session,
});
state.remote_dev.0.lock().insert(label, connection);
@@ -350,16 +368,18 @@ pub async fn remote_invoke(
args: String,
on_channel: Channel<ChannelFrame>,
) -> IdeResult<String> {
let args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?;
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) = &args {
for v in map.values() {
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) {
c.channels.lock().insert(id, on_channel.clone());
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 });
}
}
}