feat(desktop): auto-discover Honcho instances on localhost (#1)
* feat(desktop): auto-discover Honcho instances on localhost
First-launch and on-demand discovery of running Honcho instances on
127.0.0.1:8000-8100. Desktop-only — the browser can't port-scan due to
CORS, so the scan runs in the Tauri Rust shell.
- New Rust command `discover_honcho_instances(start_port?, end_port?)`
in src-tauri/src/discover.rs: parallel TCP probe of each port with
150ms connect timeout + 250ms total request budget, looking for a
/health endpoint returning `{"status":"ok"}`.
- New TS helper `discoverHonchoInstances()` in src/lib/discovery.ts:
detects Tauri runtime; web build degrades to an empty result.
- `suggestNameForInstance()` fetches the first workspace and derives a
friendly name from its id ("neo-personal" -> "Neo"). Used to seed the
Name field for each discovered instance.
- `DiscoveredInstances` component: list of found instances with
editable suggested names + per-row checkbox + "Add N instances"
button. Rows are pre-checked by default and filter out already-
configured baseUrls.
- Wired into `InstancesManager`:
- First launch (no instances): renders above the connection-type
chooser with autoRun=true. User sees results immediately.
- With instances: adds a "Discover instances" button to the list
view that opens the discovery flow on demand.
- Both paths gate on `isTauri()`; the web build keeps its existing
behaviour unchanged.
Adds tokio (with net + io-util + time + rt + macros) and futures to
the Tauri shell to get async TCP + join_all.
Tests:
- deriveNameFromWorkspaceId handles hyphenated, multi-segment, and
no-hyphen ids
* test(desktop): add probe tests including live Hermes stack integration
- rejects_inverted_port_range
- ignores_ports_with_no_listener
- finds_live_hermes_stacks (#[ignore]d; opt-in with --ignored)
The ignored test exercises discover_honcho_instances against ports
8000-8010, expecting exactly [8001, 8002, 8003, 8004, 8005]. Verified
locally — the probe finds all 5 stacks in <10ms.
---------
Co-authored-by: Agents <agents@Jarviss-Mac-mini.local>
This commit is contained in:
committed by
Offending Commit
parent
6960bf4ffe
commit
7355884051
19
packages/desktop/src-tauri/Cargo.lock
generated
19
packages/desktop/src-tauri/Cargo.lock
generated
@@ -923,6 +923,21 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -930,6 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -984,6 +1000,7 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -2268,6 +2285,7 @@ dependencies = [
|
||||
name = "openconcho"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2275,6 +2293,7 @@ dependencies = [
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -17,3 +17,5 @@ tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "io-util", "time", "rt", "macros"] }
|
||||
futures = "0.3"
|
||||
|
||||
98
packages/desktop/src-tauri/src/discover.rs
Normal file
98
packages/desktop/src-tauri/src/discover.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Localhost Honcho instance discovery.
|
||||
//!
|
||||
//! Probes a range of ports on 127.0.0.1 for a Honcho `/health` endpoint
|
||||
//! that returns `{"status":"ok"}`. Desktop-only feature: the browser
|
||||
//! can't port-scan due to CORS, so this lives in the Tauri Rust shell.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const DEFAULT_START_PORT: u16 = 8000;
|
||||
const DEFAULT_END_PORT: u16 = 8100;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 150;
|
||||
const REQUEST_TIMEOUT_MS: u64 = 250;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct DiscoveredInstance {
|
||||
pub port: u16,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
async fn probe_port(port: u16) -> Option<DiscoveredInstance> {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
let connect = TcpStream::connect(&addr);
|
||||
let stream = tokio::time::timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), connect)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
|
||||
let mut stream = stream;
|
||||
let req = b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
|
||||
|
||||
let io = async {
|
||||
stream.write_all(req).await.ok()?;
|
||||
let mut buf = Vec::with_capacity(512);
|
||||
stream.read_to_end(&mut buf).await.ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let buf = tokio::time::timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), io)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let body = String::from_utf8_lossy(&buf);
|
||||
if body.contains("\"status\":\"ok\"") {
|
||||
Some(DiscoveredInstance {
|
||||
port,
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discover_honcho_instances(
|
||||
start_port: Option<u16>,
|
||||
end_port: Option<u16>,
|
||||
) -> Vec<DiscoveredInstance> {
|
||||
let start = start_port.unwrap_or(DEFAULT_START_PORT);
|
||||
let end = end_port.unwrap_or(DEFAULT_END_PORT);
|
||||
if end < start {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let probes: Vec<_> = (start..=end).map(probe_port).collect();
|
||||
let results = futures::future::join_all(probes).await;
|
||||
let mut found: Vec<DiscoveredInstance> = results.into_iter().flatten().collect();
|
||||
found.sort_by_key(|d| d.port);
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_inverted_port_range() {
|
||||
let found = discover_honcho_instances(Some(9000), Some(8000)).await;
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_ports_with_no_listener() {
|
||||
// Port 1 should reliably have no listener — connect fails fast.
|
||||
let result = probe_port(1).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live Honcho stacks on 8001-8005"]
|
||||
async fn finds_live_hermes_stacks() {
|
||||
let found = discover_honcho_instances(Some(8000), Some(8010)).await;
|
||||
let ports: Vec<u16> = found.iter().map(|d| d.port).collect();
|
||||
assert_eq!(ports, vec![8001, 8002, 8003, 8004, 8005]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
mod discover;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.invoke_handler(tauri::generate_handler![discover::discover_honcho_instances])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user