Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 98 additions & 0 deletions packages/desktop/src-tauri/src/discover.rs
Original file line number Diff line number Diff line change
@@ -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]);
}
}
3 changes: 3 additions & 0 deletions packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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");
}
209 changes: 209 additions & 0 deletions packages/web/src/components/settings/DiscoveredInstances.tsx
Original file line number Diff line number Diff line change
@@ -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<Row[]>([]);

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 (
<div
className="rounded-2xl p-5 space-y-3"
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="w-4 h-4 shrink-0"
style={{ color: COLOR.accentText }}
strokeWidth={1.5}
/>
<div>
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
Discover local Honcho instances
</p>
<Muted className="text-xs">Scans 127.0.0.1:8000–8100 for running instances</Muted>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => void runScan()}
disabled={scanning}
className="rounded-xl px-3 py-2"
title="Rescan"
>
{scanning ? (
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
>
<Loader className="w-4 h-4" strokeWidth={1.5} />
</motion.div>
) : (
<RefreshCw className="w-4 h-4" strokeWidth={1.5} />
)}
<span className="hidden sm:inline ml-1.5 text-xs">
{scanning ? "Scanning…" : "Rescan"}
</span>
</Button>
</div>

{hasScanned && !scanning && rows.length === 0 && (
<Muted className="text-xs">
No new instances found. Add one manually below if you know its URL.
</Muted>
)}

{rows.length > 0 && (
<>
<div className="space-y-1.5">
{rows.map((row) => (
<DiscoveredRow
key={row.discovered.port}
row={row}
onCheck={(c) => setRowChecked(row.discovered.port, c)}
onRename={(n) => setRowName(row.discovered.port, n)}
/>
))}
</div>
<Button
type="button"
variant="accent"
onClick={addSelected}
disabled={selectedCount === 0}
className="w-full rounded-xl py-2.5"
>
{selectedCount === 0
? "Select at least one"
: `Add ${selectedCount} instance${selectedCount === 1 ? "" : "s"}`}
</Button>
</>
)}
</div>
);
}

interface DiscoveredRowProps {
row: Row;
onCheck: (checked: boolean) => void;
onRename: (name: string) => void;
}

function DiscoveredRow({ row, onCheck, onRename }: DiscoveredRowProps) {
return (
<div
className="flex items-center gap-2.5 rounded-xl px-3 py-2"
style={{
background: row.checked ? "var(--surface)" : "transparent",
border: `1px solid ${row.checked ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<input
type="checkbox"
checked={row.checked}
onChange={(e) => onCheck(e.target.checked)}
className="w-4 h-4 shrink-0 cursor-pointer"
aria-label={`Select ${row.suggestedName}`}
/>
<input
type="text"
value={row.suggestedName}
onChange={(e) => 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}
/>
<span className="text-xs font-mono shrink-0" style={{ color: "var(--text-4)" }}>
:{row.discovered.port}
</span>
</div>
);
}
Loading
Loading