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
139 changes: 134 additions & 5 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use bitfun_core::service::remote_connect::{
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tokio::sync::RwLock;
Expand Down Expand Up @@ -197,6 +198,7 @@ fn is_valid_mobile_web_dir(dir: &std::path::Path) -> bool {
pub struct StartRemoteConnectRequest {
pub method: String,
pub custom_server_url: Option<String>,
pub lan_ip: Option<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -228,10 +230,18 @@ pub struct DeviceInfo {
pub mac_address: String,
}

#[derive(Debug, Serialize)]
pub struct LanNetworkInterface {
pub interface_name: String,
pub ip: String,
pub gateway_ip: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct LanNetworkInfo {
pub local_ip: String,
pub gateway_ip: Option<String>,
pub available_ips: Vec<LanNetworkInterface>,
}

fn detect_default_gateway_ip() -> Option<String> {
Expand Down Expand Up @@ -289,7 +299,100 @@ fn detect_default_gateway_ip() -> Option<String> {
None
}

// ── Tauri Commands ─────────────────────────────────────────────────
/// Detect per-interface gateway IPs by parsing the system routing table.
///
/// Returns a map keyed by interface identifier (interface name on macOS/Linux,
/// interface IP on Windows) → gateway IP. Only interfaces that have a default
/// route entry appear in the map.
fn detect_interface_gateways() -> HashMap<String, String> {
let mut map = HashMap::new();

#[cfg(target_os = "macos")]
{
if let Ok(output) = bitfun_core::util::process_manager::create_command("netstat")
.args(["-rn", "-f", "inet"])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Lines look like:
// default 192.168.1.1 UGScg en0
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 && parts[0] == "default" {
let gateway = parts[1];
let netif = parts[3];
if is_ipv4(gateway) {
map.insert(netif.to_string(), gateway.to_string());
}
}
}
}
}
}

#[cfg(target_os = "linux")]
{
if let Ok(output) = bitfun_core::util::process_manager::create_command("ip")
.args(["route", "show", "default"])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Lines look like:
// default via 192.168.1.1 dev eth0 proto dhcp metric 100
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
let mut via = None;
let mut dev = None;
for i in 0..parts.len() {
match parts[i] {
"via" if i + 1 < parts.len() => via = Some(parts[i + 1]),
"dev" if i + 1 < parts.len() => dev = Some(parts[i + 1]),
_ => {}
}
}
if let (Some(gw), Some(iface)) = (via, dev) {
if is_ipv4(gw) {
map.insert(iface.to_string(), gw.to_string());
}
}
}
}
}
}

#[cfg(target_os = "windows")]
{
if let Ok(output) = bitfun_core::util::process_manager::create_command("route")
.args(["print", "-4"])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Lines look like:
// 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.2 25
// Column 3 = gateway, column 4 = interface IP
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 && parts[0] == "0.0.0.0" && parts[1] == "0.0.0.0" {
if is_ipv4(parts[2]) && is_ipv4(parts[3]) {
// Key by interface IP so it can be matched later
map.insert(parts[3].to_string(), parts[2].to_string());
}
}
}
}
}
}

map
}

/// Quick check whether a string looks like an IPv4 address.
fn is_ipv4(s: &str) -> bool {
s.split('.').count() == 4 && s.split('.').all(|p| p.parse::<u8>().is_ok())
}

#[tauri::command]
pub async fn remote_connect_get_device_info() -> Result<DeviceInfo, String> {
Expand All @@ -312,11 +415,33 @@ pub async fn remote_connect_get_lan_ip() -> Result<String, String> {

#[tauri::command]
pub async fn remote_connect_get_lan_network_info() -> Result<LanNetworkInfo, String> {
let local_ip = lan::get_local_ip().map_err(|e| format!("get local ip: {e}"))?;
let interfaces = lan::list_local_ips().map_err(|e| format!("list local ips: {e}"))?;
let local_ip = interfaces
.first()
.map(|e| e.ip.clone())
.ok_or_else(|| "no local IPv4 addresses found".to_string())?;
let gateway_ip = detect_default_gateway_ip();
// Build per-interface gateway map once from the routing table.
let gateway_map = detect_interface_gateways();
let available_ips = interfaces
.into_iter()
.map(|e| {
// Look up by interface name (macOS/Linux) or by IP (Windows).
let gw = gateway_map
.get(&e.interface_name)
.or_else(|| gateway_map.get(&e.ip))
.cloned();
LanNetworkInterface {
gateway_ip: gw,
interface_name: e.interface_name,
ip: e.ip,
}
})
.collect();
Ok(LanNetworkInfo {
local_ip,
gateway_ip,
available_ips,
})
}

Expand All @@ -331,7 +456,7 @@ pub async fn remote_connect_get_methods() -> Result<Vec<ConnectionMethodInfo>, S
let infos = methods
.into_iter()
.map(|m| match m {
ConnectionMethod::Lan => ConnectionMethodInfo {
ConnectionMethod::Lan { .. } => ConnectionMethodInfo {
id: "lan".into(),
name: "LAN".into(),
available: true,
Expand Down Expand Up @@ -382,9 +507,12 @@ pub async fn remote_connect_get_methods() -> Result<Vec<ConnectionMethodInfo>, S
fn parse_connection_method(
method: &str,
custom_url: Option<String>,
lan_ip: Option<String>,
) -> Result<ConnectionMethod, String> {
match method {
"lan" => Ok(ConnectionMethod::Lan),
"lan" => Ok(ConnectionMethod::Lan {
ip: lan_ip.filter(|s| !s.is_empty()),
}),
"ngrok" => Ok(ConnectionMethod::Ngrok),
"bitfun_server" => Ok(ConnectionMethod::BitfunServer),
"custom_server" => Ok(ConnectionMethod::CustomServer {
Expand All @@ -402,7 +530,8 @@ pub async fn remote_connect_start(
request: StartRemoteConnectRequest,
) -> Result<ConnectionResult, String> {
ensure_service().await?;
let method = parse_connection_method(&request.method, request.custom_server_url)?;
let method =
parse_connection_method(&request.method, request.custom_server_url, request.lan_ip)?;

let holder = get_service_holder();
let guard = holder.read().await;
Expand Down
5 changes: 3 additions & 2 deletions src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,9 @@ DNA 与样例 → `references/design-styles.md`。

1. **假设 + 纲**:更新 `project.json` 的 `outline[]` / `slide_order`;顺带识别可能受益于图表、图示或更强视觉表达的页面,先不批量写 HTML。**若主题涉及技术方案/工程/系统/项目分析,必须在 outline 阶段就识别出哪些页是「系统组成」「处理流程」「多角色协作」「根因分析」,并标注用架构图/流程图/泳道图/因果链**(参考上方「技术方案类内容的特别触发」)。
2. **≥5 页先打样**:做 2 页视觉差异最大的 showcase,定 grammar 再批量(见 slide-decks.md)。
3. **逐页 HTML**:封面 `slide-01`(标题/副标题/作者或日期)→ 按 outline 生成其余页;根据内容与风格自由选择图表、图示、文字、图片或混合构图,每页完整内联 CSS。写每页时一次写对,写完即止,不回头逐页复核或返工改 HTML。
4. **改稿范围**(输入里若有 `scope`):
3. **逐页 HTML**:封面 `slide-01`(标题/副标题/作者或日期)→ 按 outline 生成其余页;根据内容与风格自由选择图表、图示、文字、图片或混合构图,每页完整内联 CSS。**写每页时一次写对**:写之前先做垂直预算心算(见「防溢出预算」),写的时候同时遵守四条 OOXML 硬约束(文字用 p/h\* 包裹、纯色无渐变、背景/边框只在 DIV、不用 background-image),写完即止。
4. **禁止事后审计**:所有页面写完后**不得**再回头逐页 Read → Edit 返工,也不得用 Grep 批量检查约束。约束在写时遵守,不事后补救。写完最后一页后直接输出完成总结即可。
5. **改稿范围**(输入里若有 `scope`):
- `deck`:可改 outline 与任意 `slides/*.html`
- `current_slide` / `slide_index`:**只改指定页**,不动其他 slide 文件

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ impl ToolStateManager {
chunks_received: *chunks_received,
},
ToolExecutionState::AwaitingConfirmation { params, timeout_at } => {
let confirmation_timeout_secs = task.options.confirmation_timeout_secs.filter(|seconds| *seconds > 0);
let confirmation_timeout_secs = task
.options
.confirmation_timeout_secs
.filter(|seconds| *seconds > 0);
ToolStateEventKind::AwaitingConfirmation {
params: params.clone(),
timeout_at: confirmation_timeout_secs.map(|_| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ use bitfun_agent_tools::{
build_user_steering_interrupted_presentation, render_tool_result_for_assistant,
truncate_raw_tool_arguments_preview, truncate_tool_arguments_preview,
validate_tool_execution_admission, ToolExecutionAdmissionRejection,
ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME,
USER_STEERING_INTERRUPTED_MESSAGE,
ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE,
};
use bitfun_runtime_ports::RoundInjectionToolPreemption;
use futures::future::join_all;
Expand Down
98 changes: 94 additions & 4 deletions src/crates/assembly/core/src/service/remote_connect/lan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,91 @@
//! The desktop runs a mini relay server, and the QR code points to the local IP.

use anyhow::{anyhow, Result};
use local_ip_address::list_afinet_netifas;
use log::info;
use std::net::IpAddr;

/// Detect the local LAN IP address.
/// A local network interface with its IPv4 address.
#[derive(Debug, Clone)]
pub struct LocalNetworkInterface {
pub interface_name: String,
pub ip: String,
}

/// List all local LAN IPv4 addresses, sorted by likely-usefulness.
///
/// Private addresses (192.168.x, 10.x, 172.16-31.x) are prioritized over
/// public addresses. Loopback (127.x) and link-local (169.254.x) are excluded.
pub fn list_local_ips() -> Result<Vec<LocalNetworkInterface>> {
let interfaces =
list_afinet_netifas().map_err(|e| anyhow!("failed to list network interfaces: {e}"))?;

let mut entries: Vec<LocalNetworkInterface> = interfaces
.into_iter()
.filter(|(_, ip)| matches!(ip, IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local()))
.filter_map(|(name, ip)| {
// Only keep IPv4 for LAN relay URLs.
let v4 = match ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => return None,
};
// Exclude loopback (127.x) and link-local (169.254.x) as a safety net
// (is_loopback / is_link_local above already cover this, but be explicit).
if v4.is_loopback() || v4.is_link_local() {
return None;
}
Some(LocalNetworkInterface {
interface_name: name,
ip: v4.to_string(),
})
})
.collect();

// Sort: 192.168.x first, 10.x second, 172.16-31.x third, other IPv4 last.
entries.sort_by(|a, b| ip_sort_key(&a.ip).cmp(&ip_sort_key(&b.ip)));

if entries.is_empty() {
return Err(anyhow!("no local IPv4 addresses found"));
}
Ok(entries)
}

/// Return a sort priority for an IPv4 string.
/// Lower value = higher priority (shown first).
fn ip_sort_key(ip: &str) -> u8 {
if ip.starts_with("192.168.") {
0
} else if ip.starts_with("10.") {
1
} else if ip.starts_with("172.") {
// 172.16.0.0 – 172.31.255.255 is private; treat all 172.x as private-tier.
2
} else {
3
}
}

/// Detect the local LAN IP address (first from the sorted list).
pub fn get_local_ip() -> Result<String> {
let ip = local_ip_address::local_ip().map_err(|e| anyhow!("failed to detect LAN IP: {e}"))?;
Ok(ip.to_string())
let ips = list_local_ips()?;
Ok(ips[0].ip.clone())
}

/// Build the relay URL for LAN mode.
/// Build the relay URL for LAN mode, auto-detecting the local IP.
pub fn build_lan_relay_url(port: u16) -> Result<String> {
let ip = get_local_ip()?;
let url = format!("http://{ip}:{port}");
info!("LAN relay URL: {url}");
Ok(url)
}

/// Build the relay URL for LAN mode using a user-selected IP.
pub fn build_lan_relay_url_with_ip(port: u16, ip: &str) -> Result<String> {
let url = format!("http://{ip}:{port}");
info!("LAN relay URL (selected): {url}");
Ok(url)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -31,4 +100,25 @@ mod tests {
assert!(!ip.is_empty());
}
}

#[test]
fn test_ip_sort_key() {
assert_eq!(ip_sort_key("192.168.1.100"), 0);
assert_eq!(ip_sort_key("10.0.0.5"), 1);
assert_eq!(ip_sort_key("172.16.0.1"), 2);
assert_eq!(ip_sort_key("8.8.8.8"), 3);
}

#[test]
fn test_list_local_ips_sorted() {
let ips = list_local_ips();
if let Ok(ips) = ips {
assert!(!ips.is_empty());
// Verify sorting: first entry should have the lowest sort key.
let keys: Vec<u8> = ips.iter().map(|e| ip_sort_key(&e.ip)).collect();
let mut sorted_keys = keys.clone();
sorted_keys.sort();
assert_eq!(keys, sorted_keys);
}
}
}
Loading
Loading