From 735588405106af59eb76bfd60a681253c76f9c1e Mon Sep 17 00:00:00 2001 From: Ben Sheridan-Edwards Date: Sun, 24 May 2026 17:01:14 +0100 Subject: [PATCH] feat(desktop): auto-discover Honcho instances on localhost (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- packages/desktop/src-tauri/Cargo.lock | 19 ++ packages/desktop/src-tauri/Cargo.toml | 2 + packages/desktop/src-tauri/src/discover.rs | 98 ++++++++ packages/desktop/src-tauri/src/lib.rs | 3 + .../settings/DiscoveredInstances.tsx | 209 ++++++++++++++++++ .../components/settings/InstancesManager.tsx | 65 +++++- packages/web/src/lib/discovery.ts | 57 +++++ packages/web/src/test/discovery.test.ts | 16 ++ 8 files changed, 463 insertions(+), 6 deletions(-) create mode 100644 packages/desktop/src-tauri/src/discover.rs create mode 100644 packages/web/src/components/settings/DiscoveredInstances.tsx create mode 100644 packages/web/src/lib/discovery.ts create mode 100644 packages/web/src/test/discovery.test.ts diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 737b360..761d75e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -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]] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 3b56d8f..1b45fa7 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/packages/desktop/src-tauri/src/discover.rs b/packages/desktop/src-tauri/src/discover.rs new file mode 100644 index 0000000..587f4a2 --- /dev/null +++ b/packages/desktop/src-tauri/src/discover.rs @@ -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 { + 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, + end_port: Option, +) -> Vec { + 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 = 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 = found.iter().map(|d| d.port).collect(); + assert_eq!(ports, vec![8001, 8002, 8003, 8004, 8005]); + } +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index b742036..6584d3b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -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"); } diff --git a/packages/web/src/components/settings/DiscoveredInstances.tsx b/packages/web/src/components/settings/DiscoveredInstances.tsx new file mode 100644 index 0000000..6bb4277 --- /dev/null +++ b/packages/web/src/components/settings/DiscoveredInstances.tsx @@ -0,0 +1,209 @@ +import { motion } from "framer-motion"; +import { Loader, RefreshCw, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Muted } from "@/components/ui/typography"; +import { useInstances } from "@/hooks/useInstances"; +import { COLOR } from "@/lib/constants"; +import { + type DiscoveredInstance, + discoverHonchoInstances, + suggestNameForInstance, +} from "@/lib/discovery"; + +interface Row { + discovered: DiscoveredInstance; + suggestedName: string; + checked: boolean; +} + +interface Props { + /** If true, scan as soon as the component mounts. */ + autoRun?: boolean; + /** Called after the user has added at least one instance. */ + onAdded?: () => void; +} + +export function DiscoveredInstances({ autoRun = false, onAdded }: Props) { + const { instances, add, activate } = useInstances(); + const [scanning, setScanning] = useState(false); + const [hasScanned, setHasScanned] = useState(false); + const [rows, setRows] = useState([]); + + const existingBaseUrls = useMemo( + () => new Set(instances.map((i) => i.baseUrl.replace(/\/+$/, "").toLowerCase())), + [instances], + ); + + const runScan = useCallback(async () => { + setScanning(true); + try { + const found = await discoverHonchoInstances(); + const fresh = found.filter( + (d) => !existingBaseUrls.has(d.base_url.replace(/\/+$/, "").toLowerCase()), + ); + const named = await Promise.all( + fresh.map(async (d) => { + const name = (await suggestNameForInstance(d.base_url)) ?? `Honcho :${d.port}`; + return { discovered: d, suggestedName: name, checked: true } satisfies Row; + }), + ); + setRows(named); + } finally { + setScanning(false); + setHasScanned(true); + } + }, [existingBaseUrls]); + + useEffect(() => { + if (autoRun) void runScan(); + }, [autoRun, runScan]); + + function setRowChecked(port: number, checked: boolean) { + setRows((r) => r.map((row) => (row.discovered.port === port ? { ...row, checked } : row))); + } + + function setRowName(port: number, suggestedName: string) { + setRows((r) => + r.map((row) => (row.discovered.port === port ? { ...row, suggestedName } : row)), + ); + } + + function addSelected() { + const selected = rows.filter((r) => r.checked); + if (selected.length === 0) return; + let firstId: string | null = null; + for (const row of selected) { + const created = add({ + name: row.suggestedName.trim() || `Honcho :${row.discovered.port}`, + baseUrl: row.discovered.base_url, + token: "", + }); + if (firstId === null) firstId = created.id; + } + if (firstId) activate(firstId); + setRows([]); + onAdded?.(); + } + + const selectedCount = rows.filter((r) => r.checked).length; + + return ( +
+
+
+ +
+

+ Discover local Honcho instances +

+ Scans 127.0.0.1:8000–8100 for running instances +
+
+ +
+ + {hasScanned && !scanning && rows.length === 0 && ( + + No new instances found. Add one manually below if you know its URL. + + )} + + {rows.length > 0 && ( + <> +
+ {rows.map((row) => ( + setRowChecked(row.discovered.port, c)} + onRename={(n) => setRowName(row.discovered.port, n)} + /> + ))} +
+ + + )} +
+ ); +} + +interface DiscoveredRowProps { + row: Row; + onCheck: (checked: boolean) => void; + onRename: (name: string) => void; +} + +function DiscoveredRow({ row, onCheck, onRename }: DiscoveredRowProps) { + return ( +
+ onCheck(e.target.checked)} + className="w-4 h-4 shrink-0 cursor-pointer" + aria-label={`Select ${row.suggestedName}`} + /> + onRename(e.target.value)} + className="flex-1 min-w-0 bg-transparent text-sm font-medium border-0 outline-none px-1 py-0.5 rounded" + style={{ color: "var(--text-1)" }} + aria-label={`Name for instance on port ${row.discovered.port}`} + disabled={!row.checked} + /> + + :{row.discovered.port} + +
+ ); +} diff --git a/packages/web/src/components/settings/InstancesManager.tsx b/packages/web/src/components/settings/InstancesManager.tsx index c707694..e793825 100644 --- a/packages/web/src/components/settings/InstancesManager.tsx +++ b/packages/web/src/components/settings/InstancesManager.tsx @@ -1,12 +1,24 @@ import { AnimatePresence, motion } from "framer-motion"; -import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react"; +import { + Check, + ChevronRight, + Cloud, + Pencil, + Plus, + Radar, + Server, + Sparkles, + Trash2, +} from "lucide-react"; import { useEffect, useState } from "react"; +import { DiscoveredInstances } from "@/components/settings/DiscoveredInstances"; import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm"; import { Button } from "@/components/ui/button"; import { Muted } from "@/components/ui/typography"; import { useInstances } from "@/hooks/useInstances"; import { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config"; import { COLOR } from "@/lib/constants"; +import { isTauri } from "@/lib/discovery"; const LOCALHOST_PROBE_URL = "http://localhost:8000"; @@ -14,7 +26,8 @@ type Mode = | { kind: "list" } | { kind: "choose-type" } | { kind: "create"; preset: ConnectionPreset } - | { kind: "edit"; id: string }; + | { kind: "edit"; id: string } + | { kind: "discover" }; interface InstancesManagerProps { onActivated?: () => void; @@ -23,16 +36,44 @@ interface InstancesManagerProps { export function InstancesManager({ onActivated }: InstancesManagerProps) { const { instances, activeId, activate, remove } = useInstances(); const isFirstRun = instances.length === 0; + const inTauri = isTauri(); const [mode, setMode] = useState(isFirstRun ? { kind: "choose-type" } : { kind: "list" }); const backFromCreate = () => setMode(isFirstRun ? { kind: "choose-type" } : { kind: "list" }); + if (mode.kind === "discover") { + return ( +
+ setMode({ kind: "list" })} /> + +
+ ); + } + if (mode.kind === "choose-type") { return ( - setMode({ kind: "create", preset })} - onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })} - /> +
+ {inTauri && ( + { + setMode({ kind: "list" }); + onActivated?.(); + }} + /> + )} + setMode({ kind: "create", preset })} + onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })} + /> +
); } @@ -82,6 +123,18 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) { ))} + {inTauri && ( + + )} +