diff --git a/README.md b/README.md index ccce537..74afa28 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,51 @@ If you need to migrate data between the desktop app and a source-based deploymen Not really. AstrBot Desktop is intended for local desktop usage and personal workflows. If you need long-running, stable server deployment, use the upstream AstrBot source, Docker, or panel-based deployment instead. + +### How can I access the WebUI from another device on my LAN? + +AstrBot Desktop listens on `127.0.0.1:6185` by default, so only the local machine can access the WebUI. If you explicitly want LAN access, set the dashboard host to `0.0.0.0` in AstrBot's command config. + +Config file path: + +```text +~/.astrbot/data/cmd_config.json +``` + +On Windows, this is usually: + +```text +C:\Users\\.astrbot\data\cmd_config.json +``` + +Write this content: + +```json +{ + "dashboard": { + "host": "0.0.0.0", + "port": 6185 + } +} +``` + +Fully quit and restart AstrBot Desktop after saving the file, then visit this URL from another device: + +```text +http://:6185/ +``` + +To restore local-only access, set `dashboard.host` back to `127.0.0.1`, then restart the app. + +Environment variables still work as advanced overrides and take precedence over `cmd_config.json`: + +```bash +ASTRBOT_DASHBOARD_HOST=0.0.0.0 +ASTRBOT_DASHBOARD_PORT=6185 +``` + +Before enabling LAN access, make sure your system firewall allows port `6185`, and do not expose the port on untrusted networks or the public internet. + ### macOS says the app is damaged or cannot be opened diff --git a/README_zh.md b/README_zh.md index ee2af9c..1654814 100644 --- a/README_zh.md +++ b/README_zh.md @@ -93,6 +93,51 @@ AstrBot Desktop 是面向本地桌面使用的 AstrBot 打包发行版。它内 不推荐。AstrBot Desktop 更适合本地桌面使用和个人工作流体验;如果你要长期稳定运行在服务器上,更建议使用上游 AstrBot 的源码、Docker 或面板部署方式。 + +### 如何从局域网内其他设备访问 WebUI? + +桌面端默认只监听 `127.0.0.1:6185`,仅允许本机访问。如果确认需要在同一局域网内访问,可以在 AstrBot 命令配置中把 dashboard host 改为 `0.0.0.0`。 + +配置文件路径: + +```text +~/.astrbot/data/cmd_config.json +``` + +Windows 对应路径通常是: + +```text +C:\Users\<用户名>\.astrbot\data\cmd_config.json +``` + +写入以下内容: + +```json +{ + "dashboard": { + "host": "0.0.0.0", + "port": 6185 + } +} +``` + +保存后完全退出并重新启动 AstrBot Desktop,然后在其他设备访问: + +```text +http://<运行 AstrBot Desktop 的主机内网 IP>:6185/ +``` + +如需恢复默认本机访问,可把 `dashboard.host` 改回 `127.0.0.1` 后重启应用。 + +环境变量仍可作为高级覆盖项,且优先级高于 `cmd_config.json`: + +```bash +ASTRBOT_DASHBOARD_HOST=0.0.0.0 +ASTRBOT_DASHBOARD_PORT=6185 +``` + +开启局域网访问前,请确认系统防火墙允许端口 `6185`,并不要在不可信网络或公网环境暴露该端口。 + ### macOS 提示“应用已损坏”或无法打开 diff --git a/docs/environment-variables.md b/docs/environment-variables.md index cbea4c6..4493bbe 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -56,6 +56,29 @@ | `ASTRBOT_DESKTOP_CLIENT` | 标记桌面客户端环境 | 打包态启动后端时写入 `1` | | `ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH` | 桌面端写给后端启动器的 heartbeat 文件路径 | 打包态默认写到 `ASTRBOT_ROOT/data/backend-startup-heartbeat.json` | +### 局域网访问 WebUI + +桌面端默认写入 `DASHBOARD_HOST=127.0.0.1`,因此 WebUI 默认仅允许本机访问。如果需要从同一局域网内的其他设备访问,推荐编辑 AstrBot 命令配置: + +```text +~/.astrbot/data/cmd_config.json +``` + +Windows 对应路径通常为 `C:\Users\<用户名>\.astrbot\data\cmd_config.json`。 + +```json +{ + "dashboard": { + "host": "0.0.0.0", + "port": 6185 + } +} +``` + +设置后需要完全退出并重新启动 AstrBot Desktop,再通过 `http://<主机内网 IP>:6185/` 访问。开启前请确认系统防火墙允许端口 `6185`,并避免在不可信网络或公网环境暴露该端口。如需恢复默认本机访问,可将 `dashboard.host` 设回 `127.0.0.1` 后重启应用。 + +环境变量优先级高于 `cmd_config.json`。如果同时设置了 `ASTRBOT_DASHBOARD_HOST` / `DASHBOARD_HOST` 或 `ASTRBOT_DASHBOARD_PORT` / `DASHBOARD_PORT`,桌面端会优先保留环境变量值。 + ## 4. 发布/CI(GitHub Actions) | 变量 | 用途 | 默认值/行为 | diff --git a/src-tauri/src/backend/launch.rs b/src-tauri/src/backend/launch.rs index a5dd2a7..d0e15a4 100644 --- a/src-tauri/src/backend/launch.rs +++ b/src-tauri/src/backend/launch.rs @@ -1,13 +1,16 @@ use std::{ env, - ffi::OsStr, + ffi::{OsStr, OsString}, fs::{self, OpenOptions}, + path::Path, process::{Command, Stdio}, }; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; +use serde::Deserialize; +use serde_json::Value; use tauri::AppHandle; use crate::{ @@ -26,6 +29,84 @@ const ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV: &str = const DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV: &str = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"; const DEFAULT_DASHBOARD_HOST: &str = "127.0.0.1"; const DEFAULT_DASHBOARD_PORT: &str = "6185"; +const CMD_CONFIG_RELATIVE_PATH: &str = "data/cmd_config.json"; + +#[derive(Debug, Default)] +struct CmdDashboardConfig { + host: Option, + port: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct CmdConfigFile { + dashboard: Option, +} + +#[derive(Debug, Deserialize)] +struct CmdConfigDashboard { + host: Option, + port: Option, +} + +impl CmdDashboardConfig { + fn from_file_config(config: CmdConfigFile, log: &mut dyn FnMut(&str)) -> Self { + let Some(dashboard) = config.dashboard else { + return Self::default(); + }; + + let host = dashboard.host.and_then(|host| { + let trimmed = host.trim(); + if trimmed.is_empty() { + log(&format!( + "cmd_config: ignoring invalid dashboard.host value: {host:?}" + )); + None + } else { + Some(trimmed.to_string()) + } + }); + + let port = dashboard.port.and_then(|value| match value { + Value::Number(port) => match port.as_u64() { + Some(port) if (1..=65535).contains(&port) => Some(port.to_string()), + Some(port) => { + log(&format!( + "cmd_config: ignoring invalid dashboard.port value: {port}" + )); + None + } + None => { + log("cmd_config: ignoring non-u64 dashboard.port number"); + None + } + }, + Value::String(port) => { + let trimmed = port.trim(); + match trimmed.parse::() { + Ok(port) if (1..=65535).contains(&port) => Some(port.to_string()), + Ok(port) => { + log(&format!( + "cmd_config: ignoring invalid dashboard.port value: {port}" + )); + None + } + Err(_) => { + log(&format!( + "cmd_config: ignoring invalid dashboard.port value: {port:?}" + )); + None + } + } + } + _ => { + log("cmd_config: ignoring non-number/string dashboard.port value"); + None + } + }); + + Self { host, port } + } +} fn sanitize_packaged_python_environment(command: &mut Command, log: F) where @@ -43,30 +124,107 @@ where command.env("PYTHONNOUSERSITE", "1"); } -fn configure_desktop_dashboard_environment(command: &mut Command) { +fn configure_desktop_dashboard_environment( + command: &mut Command, + root_dir: Option<&Path>, + log: &mut dyn FnMut(&str), +) { let dashboard_host_env = env::var_os(DASHBOARD_HOST_ENV); let astrbot_dashboard_host_env = env::var_os(ASTRBOT_DASHBOARD_HOST_ENV); let dashboard_port_env = env::var_os(DASHBOARD_PORT_ENV); let astrbot_dashboard_port_env = env::var_os(ASTRBOT_DASHBOARD_PORT_ENV); let astrbot_skip_auth_env = env::var_os(ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV); let legacy_skip_auth_env = env::var_os(DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV); + let cmd_config = read_cmd_dashboard_config(root_dir, log); + let (has_host_env, effective_host) = resolve_dashboard_value( + dashboard_host_env, + astrbot_dashboard_host_env, + cmd_config.host, + DEFAULT_DASHBOARD_HOST, + ); + let (has_port_env, effective_port) = resolve_dashboard_value( + dashboard_port_env, + astrbot_dashboard_port_env, + cmd_config.port, + DEFAULT_DASHBOARD_PORT, + ); - let effective_host = dashboard_host_env - .as_deref() - .or(astrbot_dashboard_host_env.as_deref()); let has_explicit_skip_auth = astrbot_skip_auth_env.is_some() || legacy_skip_auth_env.is_some(); - if dashboard_host_env.is_none() && astrbot_dashboard_host_env.is_none() { - command.env(DASHBOARD_HOST_ENV, DEFAULT_DASHBOARD_HOST); + if !has_host_env { + command.env(DASHBOARD_HOST_ENV, &effective_host); } - if dashboard_port_env.is_none() && astrbot_dashboard_port_env.is_none() { - command.env(DASHBOARD_PORT_ENV, DEFAULT_DASHBOARD_PORT); + if !has_port_env { + command.env(DASHBOARD_PORT_ENV, &effective_port); } - if should_skip_default_password_auth(has_explicit_skip_auth, effective_host) { + if should_skip_default_password_auth(has_explicit_skip_auth, Some(effective_host.as_os_str())) { command.env(ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV, "true"); } } +fn resolve_dashboard_value( + primary_env: Option, + legacy_env: Option, + config: Option, + default: &str, +) -> (bool, OsString) { + if let Some(value) = + non_blank_env_value(primary_env).or_else(|| non_blank_env_value(legacy_env)) + { + return (true, value); + } + let effective = config + .map(OsString::from) + .unwrap_or_else(|| OsString::from(default)); + (false, effective) +} + +fn non_blank_env_value(value: Option) -> Option { + value.filter(|value| { + value + .to_str() + .map(|value| !value.trim().is_empty()) + .unwrap_or(true) + }) +} + +fn read_cmd_dashboard_config( + root_dir: Option<&Path>, + log: &mut dyn FnMut(&str), +) -> CmdDashboardConfig { + let Some(root_dir) = root_dir else { + return CmdDashboardConfig::default(); + }; + let config_path = root_dir.join(CMD_CONFIG_RELATIVE_PATH); + if !config_path.is_file() { + return CmdDashboardConfig::default(); + } + + let raw = match fs::read_to_string(&config_path) { + Ok(raw) => raw, + Err(error) => { + log(&format!( + "failed to read cmd_config {}: {}", + config_path.display(), + error + )); + return CmdDashboardConfig::default(); + } + }; + let parsed: CmdConfigFile = match serde_json::from_str(&raw) { + Ok(parsed) => parsed, + Err(error) => { + log(&format!( + "failed to parse cmd_config {}: {}", + config_path.display(), + error + )); + return CmdDashboardConfig::default(); + } + }; + CmdDashboardConfig::from_file_config(parsed, log) +} + fn should_skip_default_password_auth( has_explicit_skip_auth: bool, effective_host: Option<&OsStr>, @@ -164,7 +322,8 @@ impl BackendState { if let Some(path_override) = backend_path_override() { command.env("PATH", path_override); } - configure_desktop_dashboard_environment(&mut command); + let mut log = |message: &str| append_desktop_log(message); + configure_desktop_dashboard_environment(&mut command, plan.root_dir.as_deref(), &mut log); #[cfg(target_os = "windows")] { if plan.packaged_mode { @@ -261,6 +420,8 @@ mod tests { use std::{ env, ffi::{OsStr, OsString}, + fs, + path::Path, process::Command, sync::Mutex, }; @@ -271,8 +432,9 @@ mod tests { use super::{ configure_desktop_dashboard_environment, sanitize_packaged_python_environment, ASTRBOT_DASHBOARD_HOST_ENV, ASTRBOT_DASHBOARD_PORT_ENV, - ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV, DASHBOARD_HOST_ENV, DASHBOARD_PORT_ENV, - DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV, DEFAULT_DASHBOARD_HOST, + ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV, CMD_CONFIG_RELATIVE_PATH, + DASHBOARD_HOST_ENV, DASHBOARD_PORT_ENV, DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV, + DEFAULT_DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT, }; static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -364,7 +526,7 @@ mod tests { with_clean_dashboard_env(|| { let mut command = Command::new("sh"); - configure_desktop_dashboard_environment(&mut command); + configure_desktop_dashboard_environment(&mut command, None, &mut |_| {}); assert_eq!( get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), @@ -385,7 +547,7 @@ mod tests { env::set_var(DASHBOARD_PORT_ENV, "7000"); let mut command = Command::new("sh"); - configure_desktop_dashboard_environment(&mut command); + configure_desktop_dashboard_environment(&mut command, None, &mut |_| {}); assert_eq!( get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), @@ -402,7 +564,7 @@ mod tests { env::set_var(DASHBOARD_HOST_ENV, "0.0.0.0"); let mut command = Command::new("sh"); - configure_desktop_dashboard_environment(&mut command); + configure_desktop_dashboard_environment(&mut command, None, &mut |_| {}); assert_eq!( get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), @@ -418,13 +580,126 @@ mod tests { env::set_var(DASHBOARD_HOST_ENV, std::ffi::OsString::from_vec(vec![0xff])); let mut command = Command::new("sh"); - configure_desktop_dashboard_environment(&mut command); + configure_desktop_dashboard_environment(&mut command, None, &mut |_| {}); + + assert_eq!( + get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), + None + ); + assert_eq!(get_command_env_value(&command, DASHBOARD_HOST_ENV), None); + }); + } + + fn write_cmd_config(root: &Path, contents: &str) { + let config_path = root.join(CMD_CONFIG_RELATIVE_PATH); + fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("create config dir"); + fs::write(config_path, contents).expect("write cmd config"); + } + + #[test] + fn configure_desktop_dashboard_environment_reads_cmd_config() { + with_clean_dashboard_env(|| { + let root = tempfile::tempdir().expect("temp root"); + write_cmd_config( + root.path(), + r#"{"dashboard":{"host":"0.0.0.0","port":6185}}"#, + ); + let mut command = Command::new("sh"); + + configure_desktop_dashboard_environment(&mut command, Some(root.path()), &mut |_| {}); + assert_eq!( + get_command_env_value(&command, DASHBOARD_HOST_ENV), + Some(Some("0.0.0.0".to_string())) + ); + assert_eq!( + get_command_env_value(&command, DASHBOARD_PORT_ENV), + Some(Some("6185".to_string())) + ); assert_eq!( get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), None ); + }); + } + + #[test] + fn configure_desktop_dashboard_environment_env_overrides_cmd_config() { + with_clean_dashboard_env(|| { + let root = tempfile::tempdir().expect("temp root"); + write_cmd_config( + root.path(), + r#"{"dashboard":{"host":"0.0.0.0","port":6185}}"#, + ); + env::set_var(DASHBOARD_HOST_ENV, "localhost"); + env::set_var(DASHBOARD_PORT_ENV, "7000"); + let mut command = Command::new("sh"); + + configure_desktop_dashboard_environment(&mut command, Some(root.path()), &mut |_| {}); + assert_eq!(get_command_env_value(&command, DASHBOARD_HOST_ENV), None); + assert_eq!(get_command_env_value(&command, DASHBOARD_PORT_ENV), None); + }); + } + + #[test] + fn configure_desktop_dashboard_environment_ignores_blank_dashboard_env() { + with_clean_dashboard_env(|| { + let root = tempfile::tempdir().expect("temp root"); + write_cmd_config( + root.path(), + r#"{"dashboard":{"host":"0.0.0.0","port":"7000"}}"#, + ); + env::set_var(DASHBOARD_HOST_ENV, " "); + env::set_var(DASHBOARD_PORT_ENV, ""); + let mut command = Command::new("sh"); + + configure_desktop_dashboard_environment(&mut command, Some(root.path()), &mut |_| {}); + + assert_eq!( + get_command_env_value(&command, DASHBOARD_HOST_ENV), + Some(Some("0.0.0.0".to_string())) + ); + assert_eq!( + get_command_env_value(&command, DASHBOARD_PORT_ENV), + Some(Some("7000".to_string())) + ); + assert_eq!( + get_command_env_value(&command, ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV), + None + ); + }); + } + + #[test] + fn configure_desktop_dashboard_environment_ignores_invalid_cmd_config() { + with_clean_dashboard_env(|| { + let root = tempfile::tempdir().expect("temp root"); + write_cmd_config(root.path(), r#"{"dashboard":{"host":" ","port":70000}}"#); + let mut command = Command::new("sh"); + let mut logs = Vec::new(); + + configure_desktop_dashboard_environment( + &mut command, + Some(root.path()), + &mut |message| logs.push(message.to_string()), + ); + + assert_eq!( + get_command_env_value(&command, DASHBOARD_HOST_ENV), + Some(Some(DEFAULT_DASHBOARD_HOST.to_string())) + ); + assert_eq!( + get_command_env_value(&command, DASHBOARD_PORT_ENV), + Some(Some(DEFAULT_DASHBOARD_PORT.to_string())) + ); + assert!(logs + .iter() + .any(|message| message.contains("invalid dashboard.host"))); + assert!(logs + .iter() + .any(|message| message.contains("invalid dashboard.port"))); }); } }