Skip to content

Commit aa46d47

Browse files
Merge pull request #30 from offendingcommit/feat/desktop-auto-discover
feat(desktop): auto-discover Honcho instances on localhost
2 parents 972fd83 + 7355884 commit aa46d47

8 files changed

Lines changed: 463 additions & 6 deletions

File tree

packages/desktop/src-tauri/Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/desktop/src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ tauri-plugin-shell = "2"
1717
tauri-plugin-deep-link = "2"
1818
serde = { version = "1", features = ["derive"] }
1919
serde_json = "1"
20+
tokio = { version = "1", features = ["net", "io-util", "time", "rt", "macros"] }
21+
futures = "0.3"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Localhost Honcho instance discovery.
2+
//!
3+
//! Probes a range of ports on 127.0.0.1 for a Honcho `/health` endpoint
4+
//! that returns `{"status":"ok"}`. Desktop-only feature: the browser
5+
//! can't port-scan due to CORS, so this lives in the Tauri Rust shell.
6+
7+
use serde::Serialize;
8+
use std::time::Duration;
9+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
10+
use tokio::net::TcpStream;
11+
12+
const DEFAULT_START_PORT: u16 = 8000;
13+
const DEFAULT_END_PORT: u16 = 8100;
14+
const CONNECT_TIMEOUT_MS: u64 = 150;
15+
const REQUEST_TIMEOUT_MS: u64 = 250;
16+
17+
#[derive(Serialize, Debug)]
18+
pub struct DiscoveredInstance {
19+
pub port: u16,
20+
pub base_url: String,
21+
}
22+
23+
async fn probe_port(port: u16) -> Option<DiscoveredInstance> {
24+
let addr = format!("127.0.0.1:{}", port);
25+
26+
let connect = TcpStream::connect(&addr);
27+
let stream = tokio::time::timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), connect)
28+
.await
29+
.ok()?
30+
.ok()?;
31+
32+
let mut stream = stream;
33+
let req = b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
34+
35+
let io = async {
36+
stream.write_all(req).await.ok()?;
37+
let mut buf = Vec::with_capacity(512);
38+
stream.read_to_end(&mut buf).await.ok()?;
39+
Some(buf)
40+
};
41+
let buf = tokio::time::timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), io)
42+
.await
43+
.ok()??;
44+
45+
let body = String::from_utf8_lossy(&buf);
46+
if body.contains("\"status\":\"ok\"") {
47+
Some(DiscoveredInstance {
48+
port,
49+
base_url: format!("http://127.0.0.1:{}", port),
50+
})
51+
} else {
52+
None
53+
}
54+
}
55+
56+
#[tauri::command]
57+
pub async fn discover_honcho_instances(
58+
start_port: Option<u16>,
59+
end_port: Option<u16>,
60+
) -> Vec<DiscoveredInstance> {
61+
let start = start_port.unwrap_or(DEFAULT_START_PORT);
62+
let end = end_port.unwrap_or(DEFAULT_END_PORT);
63+
if end < start {
64+
return Vec::new();
65+
}
66+
67+
let probes: Vec<_> = (start..=end).map(probe_port).collect();
68+
let results = futures::future::join_all(probes).await;
69+
let mut found: Vec<DiscoveredInstance> = results.into_iter().flatten().collect();
70+
found.sort_by_key(|d| d.port);
71+
found
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use super::*;
77+
78+
#[tokio::test]
79+
async fn rejects_inverted_port_range() {
80+
let found = discover_honcho_instances(Some(9000), Some(8000)).await;
81+
assert!(found.is_empty());
82+
}
83+
84+
#[tokio::test]
85+
async fn ignores_ports_with_no_listener() {
86+
// Port 1 should reliably have no listener — connect fails fast.
87+
let result = probe_port(1).await;
88+
assert!(result.is_none());
89+
}
90+
91+
#[tokio::test]
92+
#[ignore = "requires live Honcho stacks on 8001-8005"]
93+
async fn finds_live_hermes_stacks() {
94+
let found = discover_honcho_instances(Some(8000), Some(8010)).await;
95+
let ports: Vec<u16> = found.iter().map(|d| d.port).collect();
96+
assert_eq!(ports, vec![8001, 8002, 8003, 8004, 8005]);
97+
}
98+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
mod discover;
2+
13
#[cfg_attr(mobile, tauri::mobile_entry_point)]
24
pub fn run() {
35
tauri::Builder::default()
46
.plugin(tauri_plugin_http::init())
57
.plugin(tauri_plugin_shell::init())
68
.plugin(tauri_plugin_deep_link::init())
9+
.invoke_handler(tauri::generate_handler![discover::discover_honcho_instances])
710
.run(tauri::generate_context!())
811
.expect("error while running tauri application");
912
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { motion } from "framer-motion";
2+
import { Loader, RefreshCw, Sparkles } from "lucide-react";
3+
import { useCallback, useEffect, useMemo, useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { Muted } from "@/components/ui/typography";
6+
import { useInstances } from "@/hooks/useInstances";
7+
import { COLOR } from "@/lib/constants";
8+
import {
9+
type DiscoveredInstance,
10+
discoverHonchoInstances,
11+
suggestNameForInstance,
12+
} from "@/lib/discovery";
13+
14+
interface Row {
15+
discovered: DiscoveredInstance;
16+
suggestedName: string;
17+
checked: boolean;
18+
}
19+
20+
interface Props {
21+
/** If true, scan as soon as the component mounts. */
22+
autoRun?: boolean;
23+
/** Called after the user has added at least one instance. */
24+
onAdded?: () => void;
25+
}
26+
27+
export function DiscoveredInstances({ autoRun = false, onAdded }: Props) {
28+
const { instances, add, activate } = useInstances();
29+
const [scanning, setScanning] = useState(false);
30+
const [hasScanned, setHasScanned] = useState(false);
31+
const [rows, setRows] = useState<Row[]>([]);
32+
33+
const existingBaseUrls = useMemo(
34+
() => new Set(instances.map((i) => i.baseUrl.replace(/\/+$/, "").toLowerCase())),
35+
[instances],
36+
);
37+
38+
const runScan = useCallback(async () => {
39+
setScanning(true);
40+
try {
41+
const found = await discoverHonchoInstances();
42+
const fresh = found.filter(
43+
(d) => !existingBaseUrls.has(d.base_url.replace(/\/+$/, "").toLowerCase()),
44+
);
45+
const named = await Promise.all(
46+
fresh.map(async (d) => {
47+
const name = (await suggestNameForInstance(d.base_url)) ?? `Honcho :${d.port}`;
48+
return { discovered: d, suggestedName: name, checked: true } satisfies Row;
49+
}),
50+
);
51+
setRows(named);
52+
} finally {
53+
setScanning(false);
54+
setHasScanned(true);
55+
}
56+
}, [existingBaseUrls]);
57+
58+
useEffect(() => {
59+
if (autoRun) void runScan();
60+
}, [autoRun, runScan]);
61+
62+
function setRowChecked(port: number, checked: boolean) {
63+
setRows((r) => r.map((row) => (row.discovered.port === port ? { ...row, checked } : row)));
64+
}
65+
66+
function setRowName(port: number, suggestedName: string) {
67+
setRows((r) =>
68+
r.map((row) => (row.discovered.port === port ? { ...row, suggestedName } : row)),
69+
);
70+
}
71+
72+
function addSelected() {
73+
const selected = rows.filter((r) => r.checked);
74+
if (selected.length === 0) return;
75+
let firstId: string | null = null;
76+
for (const row of selected) {
77+
const created = add({
78+
name: row.suggestedName.trim() || `Honcho :${row.discovered.port}`,
79+
baseUrl: row.discovered.base_url,
80+
token: "",
81+
});
82+
if (firstId === null) firstId = created.id;
83+
}
84+
if (firstId) activate(firstId);
85+
setRows([]);
86+
onAdded?.();
87+
}
88+
89+
const selectedCount = rows.filter((r) => r.checked).length;
90+
91+
return (
92+
<div
93+
className="rounded-2xl p-5 space-y-3"
94+
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
95+
>
96+
<div className="flex items-center justify-between gap-3">
97+
<div className="flex items-center gap-2">
98+
<Sparkles
99+
className="w-4 h-4 shrink-0"
100+
style={{ color: COLOR.accentText }}
101+
strokeWidth={1.5}
102+
/>
103+
<div>
104+
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
105+
Discover local Honcho instances
106+
</p>
107+
<Muted className="text-xs">Scans 127.0.0.1:8000–8100 for running instances</Muted>
108+
</div>
109+
</div>
110+
<Button
111+
type="button"
112+
variant="ghost"
113+
onClick={() => void runScan()}
114+
disabled={scanning}
115+
className="rounded-xl px-3 py-2"
116+
title="Rescan"
117+
>
118+
{scanning ? (
119+
<motion.div
120+
animate={{ rotate: 360 }}
121+
transition={{
122+
duration: 1,
123+
repeat: Number.POSITIVE_INFINITY,
124+
ease: "linear",
125+
}}
126+
>
127+
<Loader className="w-4 h-4" strokeWidth={1.5} />
128+
</motion.div>
129+
) : (
130+
<RefreshCw className="w-4 h-4" strokeWidth={1.5} />
131+
)}
132+
<span className="hidden sm:inline ml-1.5 text-xs">
133+
{scanning ? "Scanning…" : "Rescan"}
134+
</span>
135+
</Button>
136+
</div>
137+
138+
{hasScanned && !scanning && rows.length === 0 && (
139+
<Muted className="text-xs">
140+
No new instances found. Add one manually below if you know its URL.
141+
</Muted>
142+
)}
143+
144+
{rows.length > 0 && (
145+
<>
146+
<div className="space-y-1.5">
147+
{rows.map((row) => (
148+
<DiscoveredRow
149+
key={row.discovered.port}
150+
row={row}
151+
onCheck={(c) => setRowChecked(row.discovered.port, c)}
152+
onRename={(n) => setRowName(row.discovered.port, n)}
153+
/>
154+
))}
155+
</div>
156+
<Button
157+
type="button"
158+
variant="accent"
159+
onClick={addSelected}
160+
disabled={selectedCount === 0}
161+
className="w-full rounded-xl py-2.5"
162+
>
163+
{selectedCount === 0
164+
? "Select at least one"
165+
: `Add ${selectedCount} instance${selectedCount === 1 ? "" : "s"}`}
166+
</Button>
167+
</>
168+
)}
169+
</div>
170+
);
171+
}
172+
173+
interface DiscoveredRowProps {
174+
row: Row;
175+
onCheck: (checked: boolean) => void;
176+
onRename: (name: string) => void;
177+
}
178+
179+
function DiscoveredRow({ row, onCheck, onRename }: DiscoveredRowProps) {
180+
return (
181+
<div
182+
className="flex items-center gap-2.5 rounded-xl px-3 py-2"
183+
style={{
184+
background: row.checked ? "var(--surface)" : "transparent",
185+
border: `1px solid ${row.checked ? "var(--accent-border)" : "var(--border)"}`,
186+
}}
187+
>
188+
<input
189+
type="checkbox"
190+
checked={row.checked}
191+
onChange={(e) => onCheck(e.target.checked)}
192+
className="w-4 h-4 shrink-0 cursor-pointer"
193+
aria-label={`Select ${row.suggestedName}`}
194+
/>
195+
<input
196+
type="text"
197+
value={row.suggestedName}
198+
onChange={(e) => onRename(e.target.value)}
199+
className="flex-1 min-w-0 bg-transparent text-sm font-medium border-0 outline-none px-1 py-0.5 rounded"
200+
style={{ color: "var(--text-1)" }}
201+
aria-label={`Name for instance on port ${row.discovered.port}`}
202+
disabled={!row.checked}
203+
/>
204+
<span className="text-xs font-mono shrink-0" style={{ color: "var(--text-4)" }}>
205+
:{row.discovered.port}
206+
</span>
207+
</div>
208+
);
209+
}

0 commit comments

Comments
 (0)