diff --git a/nebula_app/src/atomic_file.rs b/nebula_app/src/atomic_file.rs
index 8e8e35c..6c1dc11 100644
--- a/nebula_app/src/atomic_file.rs
+++ b/nebula_app/src/atomic_file.rs
@@ -4,7 +4,7 @@
//! so a crash cannot leave a half-written JSON document. A best-effort lock
//! prevents two Nebula processes from compacting the same store at once.
-use std::fs::OpenOptions;
+use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -64,6 +64,50 @@ pub(crate) fn try_lock(path: &Path) -> io::Result> {
}
}
+/// Try to own a lock for the full lifetime of a resident process.
+///
+/// 与短状态写锁不同,这里依赖 OS 句柄锁:进程崩溃时内核立即释放,健康进程
+/// 无论运行多久都不会被“超时回收”。锁文件本身可以保留,所有权只看句柄。
+pub(crate) fn try_lifetime_lock(path: &Path) -> io::Result > {
+ let lock_path = lock_path(path);
+ if let Some(parent) = lock_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ #[cfg(windows)]
+ let mut file = {
+ use std::os::windows::fs::OpenOptionsExt as _;
+
+ match OpenOptions::new().read(true).write(true).create(true).share_mode(0).open(&lock_path)
+ {
+ Ok(file) => file,
+ Err(error) if matches!(error.raw_os_error(), Some(32) | Some(33)) => return Ok(None),
+ Err(error) => return Err(error),
+ }
+ };
+
+ #[cfg(unix)]
+ let mut file = {
+ use std::os::fd::AsRawFd as _;
+
+ let file = OpenOptions::new().read(true).write(true).create(true).open(&lock_path)?;
+ let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
+ if result != 0 {
+ let error = io::Error::last_os_error();
+ if error.kind() == io::ErrorKind::WouldBlock {
+ return Ok(None);
+ }
+ return Err(error);
+ }
+ file
+ };
+
+ file.set_len(0)?;
+ write!(file, "{}", std::process::id())?;
+ file.sync_data()?;
+ Ok(Some(LifetimeFileLock { file }))
+}
+
fn create_lock(path: &Path) -> io::Result {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
@@ -99,6 +143,20 @@ impl Drop for FileLock {
}
}
+pub(crate) struct LifetimeFileLock {
+ file: File,
+}
+
+impl Drop for LifetimeFileLock {
+ fn drop(&mut self) {
+ #[cfg(unix)]
+ {
+ use std::os::fd::AsRawFd as _;
+ let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
+ }
+ }
+}
+
#[cfg(windows)]
fn replace(source: &Path, destination: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
@@ -131,7 +189,7 @@ fn replace(source: &Path, destination: &Path) -> io::Result<()> {
#[cfg(test)]
mod tests {
- use super::{try_lock, write};
+ use super::{try_lifetime_lock, try_lock, write};
#[test]
fn atomic_write_replaces_and_lock_is_exclusive() {
@@ -145,5 +203,10 @@ mod tests {
assert!(try_lock(&path).unwrap().is_none());
drop(first);
assert!(try_lock(&path).unwrap().is_some());
+
+ let owner = try_lifetime_lock(&path).unwrap().expect("first lifetime lock");
+ assert!(try_lifetime_lock(&path).unwrap().is_none());
+ drop(owner);
+ assert!(try_lifetime_lock(&path).unwrap().is_some());
}
}
diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs
index 2aafd79..bcaffbd 100644
--- a/nebula_app/src/gpui_shell/mod.rs
+++ b/nebula_app/src/gpui_shell/mod.rs
@@ -15,9 +15,9 @@ pub mod config;
pub mod doc_tabs;
pub mod http;
pub mod math_view;
+pub mod network_settings;
pub mod prelude;
pub mod session_restore;
-pub mod network_settings;
pub mod settings_pane;
pub mod ssh_hosts;
pub mod ssh_settings;
@@ -64,7 +64,7 @@ pub fn run_shell() {
});
// 驻留进程写 runtime.port:第二份 `nebula --gpui` 经 ATTACH + tab.new
// 并入当前窗口,而不是再拉一套进程。Drop 会删 port 文件。
- let _runtime_server = crate::runtime_api::RuntimeServer::spawn_callback(
+ let runtime_server = crate::runtime_api::RuntimeServer::spawn_callback(
{
let tx = shell_tx;
move |callback| {
@@ -79,6 +79,18 @@ pub fn run_shell() {
},
runtime_hub.clone(),
);
+ if runtime_server.is_none() {
+ // 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及
+ // 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。
+ for _ in 0..40 {
+ if crate::runtime_api::try_open_default_tab_existing() {
+ crate::tray::shutdown();
+ return;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(25));
+ }
+ }
+ let _runtime_server = runtime_server;
gpui::Application::new().with_assets(Assets).run(move |cx| {
// GPUI is the only event loop in `--gpui` mode, so it owns the same
// per-process hook pipe before the first TerminalView spawns.
@@ -173,9 +185,8 @@ fn open_main_window(
};
cx.open_window(options, move |window, cx| {
- let workspace = cx.new(|cx| {
- NebulaWorkspace::new(window, ai_events, shell_events, 1, runtime_hub, cx)
- });
+ let workspace =
+ cx.new(|cx| NebulaWorkspace::new(window, ai_events, shell_events, 1, runtime_hub, cx));
// 调试/验收后门:启动即打开指定文档(见 open_document_at_startup)。
if let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC")
&& !path.is_empty()
diff --git a/nebula_app/src/input/terminal_input.rs b/nebula_app/src/input/terminal_input.rs
index a7cabcd..2645f26 100644
--- a/nebula_app/src/input/terminal_input.rs
+++ b/nebula_app/src/input/terminal_input.rs
@@ -282,33 +282,10 @@ pub(crate) fn build_runtime_sequence(
result
}
-/// Encode text injected by the Runtime API for the pane's active input protocol.
-///
-/// ConPTY's Win32 input mode deserializes `CSI Vk;Sc;Uc;Kd;Cs;Rc_` into
-/// `KEY_EVENT_RECORD`s. Mixing raw UTF-8 text and a serialized Enter record in
-/// one pipe batch can leave the control record in an interactive TUI's text
-/// input batch. Encode the text as `VK_PACKET` records as well, so a submitted
-/// prompt is one homogeneous protocol stream and needs no timing boundary.
-pub(crate) fn build_runtime_text_sequence(text: &str, mode: TermMode) -> Vec {
- if use_win32_input_mode(mode) {
- #[cfg(target_os = "windows")]
- {
- use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_PACKET;
-
- let mut sequence = String::with_capacity(text.len().saturating_mul(40));
- for unicode_char in text.encode_utf16() {
- for key_down in [1, 0] {
- // VK_PACKET is the documented virtual key for Unicode
- // supplied by a non-keyboard input source. A zero scan
- // code and the UTF-16 unit preserve arbitrary prompt text.
- let _ =
- write!(sequence, "\x1b[{};0;{};{key_down};0;1_", VK_PACKET, unicode_char);
- }
- }
- return sequence.into_bytes();
- }
- }
-
+/// Runtime 文本始终走终端的原生文本路径;ConPTY 会在 `WriteString` 中按当前
+/// Console/键盘布局生成事件。强制伪造成 `VK_PACKET` 会被旧 PSReadLine 丢弃。
+/// Enter 等控制键由 submit barrier 在下一批按当前协议单独编码。
+pub(crate) fn build_runtime_text_sequence(text: &str, _mode: TermMode) -> Vec {
text.as_bytes().to_vec()
}
@@ -1122,28 +1099,14 @@ mod vt_tests {
#[cfg(target_os = "windows")]
#[test]
- fn runtime_win32_text_and_enter_form_one_record_stream() {
- use windows_sys::Win32::UI::Input::KeyboardAndMouse::VK_PACKET;
-
+ fn runtime_win32_text_uses_conpty_native_text_path() {
let mode = TermMode::WIN32_INPUT_MODE;
- let mut bytes = build_runtime_text_sequence("A中", mode);
- bytes.extend(build_runtime_sequence(
- RuntimeKey::Enter,
- RuntimeKeyModifiers::default(),
- 1,
- mode,
- ));
+ assert_eq!(build_runtime_text_sequence("A中", mode), "A中".as_bytes());
- let text = String::from_utf8(bytes).unwrap();
- let records: Vec<_> = text.split_inclusive('_').collect();
- assert_eq!(records.len(), 6);
- assert_eq!(records[0], format!("\x1b[{VK_PACKET};0;65;1;0;1_"));
- assert_eq!(records[1], format!("\x1b[{VK_PACKET};0;65;0;0;1_"));
- assert_eq!(records[2], format!("\x1b[{VK_PACKET};0;20013;1;0;1_"));
- assert_eq!(records[3], format!("\x1b[{VK_PACKET};0;20013;0;0;1_"));
- assert!(records[4].starts_with("\x1b[13;"));
- assert!(records[4].contains(";13;1;0;1_"));
- assert!(records[5].contains(";13;0;0;1_"));
+ let enter =
+ build_runtime_sequence(RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1, mode);
+ assert_ne!(enter, b"\r");
+ assert!(String::from_utf8(enter).unwrap().starts_with("\x1b[13;"));
}
#[test]
diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs
index 1313e60..c21e13e 100644
--- a/nebula_app/src/runtime_api.rs
+++ b/nebula_app/src/runtime_api.rs
@@ -8,12 +8,22 @@
mod agent_api;
mod cli;
mod command;
+mod server;
#[cfg(test)]
mod tests;
pub use cli::run_cli;
-pub(crate) use command::{capture_process_tree, capture_terminal_tail, validate_command_line, validate_prompt};
-use command::{RuntimeWaitState, SubscribeParams, WaitParams, default_read_lines, default_true, parse_params, validate_agent_name, validate_agent_selector, wait_matches};
+use command::{
+ RuntimeWaitState, SubscribeParams, WaitParams, default_read_lines, default_true, parse_params,
+ validate_agent_name, validate_agent_selector, wait_matches,
+};
+pub(crate) use command::{
+ capture_process_tree, capture_terminal_tail, validate_command_line, validate_prompt,
+};
+use server::{Endpoint, endpoint_addr, read_endpoint};
+pub use server::{
+ RuntimeServer, dispatch_prompt, try_open_default_tab_existing, try_open_directory_existing,
+};
use std::error::Error;
use std::fmt;
@@ -1379,164 +1389,6 @@ fn run_result_or_capability_error(result: RuntimeRunResult) -> Result PathBuf {
- crate::display::nebula_data_dir().join("runtime.port")
-}
-
-fn legacy_port_file() -> PathBuf {
- crate::display::nebula_data_dir().join("mux.port")
-}
-
-fn read_endpoint() -> Option {
- read_endpoint_from(port_file())
-}
-
-fn read_endpoint_from(path: PathBuf) -> Option {
- let data = std::fs::read_to_string(path).ok()?;
- let mut parts = data.split_whitespace();
- Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() })
-}
-
-fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr {
- SocketAddr::from((Ipv4Addr::LOCALHOST, endpoint.port))
-}
-
-fn fresh_token() -> String {
- use std::hash::{BuildHasher, Hasher, RandomState};
- let mut a = RandomState::new().build_hasher();
- let mut b = RandomState::new().build_hasher();
- a.write_u32(std::process::id());
- b.write_u128(
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|duration| duration.as_nanos())
- .unwrap_or(0),
- );
- format!("{:016x}{:016x}", a.finish(), b.finish())
-}
-
-/// 普通二次启动并入驻留实例:先恢复/聚焦窗口,再新建一个默认 shell 标签页。
-pub fn try_open_default_tab_existing() -> bool {
- try_open_tab_existing(None)
-}
-
-/// 后台任务把一行文本作为输入敲进某个 pane(不回车)。复用 runtime Prompt
-/// 的窗口定位与写入路径(`window_id=None` 按 pane 自动定位);响应通道即弃
-/// ——调用方不关心结果,失败在事件线程侧留日志即可。SFTP 图片粘贴用它回粘
-/// 远端路径。
-pub fn dispatch_prompt(proxy: &EventLoopProxy, pane_id: u64, text: String) {
- let (dispatch, _receiver) = RuntimeDispatch::new(RuntimeCommand::Prompt {
- window_id: None,
- pane_id,
- text,
- submit: false,
- });
- if proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_err() {
- warn!("prompt dispatch failed: event loop is gone");
- }
-}
-
-/// Explorer 右键「在 Nebula 中打开」/ CLI 带 `--working-directory` 的启动并入
-/// 驻留实例:先经 ATTACH 恢复或聚焦既有窗口(与平启动同一套交接,detached
-/// 的标签因此先回来),再请求在该窗口打开定目录标签。任一步失败都返回
-/// false,调用方回落为独立启动——绝不吞掉用户的手势。
-pub fn try_open_directory_existing(dir: &std::path::Path) -> bool {
- try_open_tab_existing(Some(dir))
-}
-
-fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool {
- if legacy_request("ATTACH").is_none() {
- return false;
- }
- // ATTACH 与 tab.new 都落到同一条 winit 事件队列上,先后有序:窗口先
- // 恢复,新标签随后落在恢复出来的窗口里。
- let params = dir.map_or_else(|| json!({}), |dir| json!({ "cwd": dir }));
- cli::request_once("tab.new", params, IO_TIMEOUT).map(|response| response.ok).unwrap_or(false)
-}
-
-fn legacy_request(verb: &str) -> Option<()> {
- read_endpoint()
- .and_then(|endpoint| legacy_request_to(verb, &endpoint))
- // Upgrade compatibility: an already-running pre-v1 Nebula still
- // publishes mux.port. A plain launch can attach to it and exit; a new
- // resident process writes runtime.port after the old process ends.
- .or_else(|| {
- read_endpoint_from(legacy_port_file())
- .and_then(|endpoint| legacy_request_to(verb, &endpoint))
- })
-}
-
-fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> {
- let mut stream = TcpStream::connect_timeout(&endpoint_addr(&endpoint), CONNECT_TIMEOUT).ok()?;
- stream.set_read_timeout(Some(IO_TIMEOUT)).ok()?;
- stream.set_write_timeout(Some(IO_TIMEOUT)).ok()?;
- stream.write_all(format!("{verb} {}\n", endpoint.token).as_bytes()).ok()?;
- let mut line = String::new();
- BufReader::new(stream).read_line(&mut line).ok()?;
- (line.trim() == "OK").then_some(())
-}
-
-/// Resident versioned runtime API server.
-pub struct RuntimeServer {
- endpoint: Endpoint,
- port_file: PathBuf,
-}
-
-impl RuntimeServer {
- pub fn spawn(proxy: EventLoopProxy, hub: RuntimeHub) -> Option {
- Self::spawn_with_sink(EventSink::Winit(proxy), hub)
- }
-
- /// Same discovery file and ATTACH/PING/JSON protocol as [`Self::spawn`],
- /// but attach/control are delivered through a callback (GPUI has no winit
- /// `EventLoopProxy`).
- pub fn spawn_callback(
- on_event: impl Fn(RuntimeCallback) + Send + Sync + 'static,
- hub: RuntimeHub,
- ) -> Option {
- Self::spawn_with_sink(EventSink::Callback(Arc::new(on_event)), hub)
- }
-
- fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option {
- if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() {
- info!("Runtime API server already running; this instance stays client-only");
- return None;
- }
-
- let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
- let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() };
- let path = port_file();
- let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION);
- if crate::atomic_file::write(&path, contents.as_bytes()).is_err() {
- warn!("Runtime API: cannot write {path:?}; control plane disabled");
- return None;
- }
-
- let server_token = endpoint.token.clone();
- let spawned = std::thread::Builder::new()
- .name("nebula-runtime-api".into())
- .spawn(move || serve(listener, server_token, sink, hub))
- .is_ok();
- spawned.then(|| Self { endpoint, port_file: path })
- }
-}
-
-impl Drop for RuntimeServer {
- fn drop(&mut self) {
- // Do not delete another process's newer discovery record.
- if read_endpoint().as_ref() == Some(&self.endpoint) {
- let _ = std::fs::remove_file(&self.port_file);
- }
- }
-}
-
fn serve(listener: TcpListener, token: String, sink: EventSink, hub: RuntimeHub) {
let active = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming() {
diff --git a/nebula_app/src/runtime_api/cli.rs b/nebula_app/src/runtime_api/cli.rs
index 55086fc..3b90bb8 100644
--- a/nebula_app/src/runtime_api/cli.rs
+++ b/nebula_app/src/runtime_api/cli.rs
@@ -22,14 +22,18 @@ pub(super) fn request_once(
params: Value,
timeout: Duration,
) -> Result> {
- let endpoint =
- read_endpoint().ok_or_else(|| CliError("no resident Nebula runtime found".into()))?;
+ let endpoint = read_endpoint()
+ .ok_or_else(|| CliError::new("runtime_unavailable", "no resident Nebula runtime found"))?;
let request = ApiRequest::new(endpoint.token.clone(), method, params);
let stream = client_stream(&endpoint, &request, Some(timeout))?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
if line.is_empty() {
- return Err(CliError("runtime closed the connection without a response".into()).into());
+ return Err(CliError::new(
+ "runtime_no_response",
+ "runtime closed the connection without a response",
+ )
+ .into());
}
Ok(serde_json::from_str(&line)?)
}
@@ -45,15 +49,31 @@ fn print_response(response: &ApiResponse, pretty: bool) -> Result<(), Box Result<(), Box> {
+ let pretty = options.pretty;
+ match run_cli_inner(options) {
+ result @ Ok(_) => result,
+ Err(error) if error.downcast_ref::().is_some() => Err(error),
+ Err(error) => {
+ let code =
+ error.downcast_ref::().map_or("cli_transport_error", |error| error.code);
+ let response = ApiResponse::failure("cli", ApiError::new(code, error.to_string()));
+ print_response(&response, pretty)
+ },
+ }
+}
+
+fn run_cli_inner(options: ControlOptions) -> Result<(), Box> {
if options.timeout_ms == 0 || Duration::from_millis(options.timeout_ms) > MAX_WAIT {
- return Err(CliError("--timeout-ms must be between 1 and 86400000".into()).into());
+ return Err(
+ CliError::new("invalid_params", "--timeout-ms must be between 1 and 86400000").into()
+ );
}
let timeout = Duration::from_millis(options.timeout_ms);
match options.command {
@@ -310,8 +330,8 @@ fn wait_state_name(state: ControlWaitState) -> &'static str {
}
fn subscribe_cli(since: Option, timeout: Duration) -> Result<(), Box> {
- let endpoint =
- read_endpoint().ok_or_else(|| CliError("no resident Nebula runtime found".into()))?;
+ let endpoint = read_endpoint()
+ .ok_or_else(|| CliError::new("runtime_unavailable", "no resident Nebula runtime found"))?;
let request = ApiRequest::new(
endpoint.token.clone(),
"events.subscribe",
@@ -321,9 +341,11 @@ fn subscribe_cli(since: Option, timeout: Duration) -> Result<(), Box, timeout: Duration) -> Result<(), Box) -> Self {
+ Self { code, message: message.into() }
+ }
+}
impl fmt::Display for CliError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.message)
+ }
+}
+
+impl Error for CliError {}
+
+#[derive(Debug)]
+struct PrintedCliError(String);
+
+impl fmt::Display for PrintedCliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
-impl Error for CliError {}
+impl Error for PrintedCliError {}
diff --git a/nebula_app/src/runtime_api/server.rs b/nebula_app/src/runtime_api/server.rs
new file mode 100644
index 0000000..17c2a3c
--- /dev/null
+++ b/nebula_app/src/runtime_api/server.rs
@@ -0,0 +1,161 @@
+//! Runtime endpoint discovery and resident server ownership.
+
+use super::*;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct Endpoint {
+ pub(super) port: u16,
+ pub(super) token: String,
+}
+
+fn port_file() -> PathBuf {
+ crate::display::nebula_data_dir().join("runtime.port")
+}
+
+fn legacy_port_file() -> PathBuf {
+ crate::display::nebula_data_dir().join("mux.port")
+}
+
+pub(super) fn read_endpoint() -> Option {
+ read_endpoint_from(port_file())
+}
+
+fn read_endpoint_from(path: PathBuf) -> Option {
+ let data = std::fs::read_to_string(path).ok()?;
+ let mut parts = data.split_whitespace();
+ Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() })
+}
+
+pub(super) fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr {
+ SocketAddr::from((Ipv4Addr::LOCALHOST, endpoint.port))
+}
+
+fn fresh_token() -> String {
+ use std::hash::{BuildHasher, Hasher, RandomState};
+ let mut a = RandomState::new().build_hasher();
+ let mut b = RandomState::new().build_hasher();
+ a.write_u32(std::process::id());
+ b.write_u128(
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0),
+ );
+ format!("{:016x}{:016x}", a.finish(), b.finish())
+}
+
+/// 普通二次启动并入驻留实例:先恢复/聚焦窗口,再新建一个默认 shell 标签页。
+pub fn try_open_default_tab_existing() -> bool {
+ try_open_tab_existing(None)
+}
+
+/// 后台任务把一行文本作为输入敲进某个 pane(不回车)。
+pub fn dispatch_prompt(proxy: &EventLoopProxy, pane_id: u64, text: String) {
+ let (dispatch, _receiver) = RuntimeDispatch::new(RuntimeCommand::Prompt {
+ window_id: None,
+ pane_id,
+ text,
+ submit: false,
+ });
+ if proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_err() {
+ warn!("prompt dispatch failed: event loop is gone");
+ }
+}
+
+/// Explorer 右键或带 `--working-directory` 的启动并入驻留实例。
+pub fn try_open_directory_existing(dir: &std::path::Path) -> bool {
+ try_open_tab_existing(Some(dir))
+}
+
+fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool {
+ if legacy_request("ATTACH").is_none() {
+ return false;
+ }
+ // ATTACH 与 tab.new 落到同一事件队列,窗口先恢复,新标签随后创建。
+ let params = dir.map_or_else(|| json!({}), |dir| json!({ "cwd": dir }));
+ cli::request_once("tab.new", params, IO_TIMEOUT).map(|response| response.ok).unwrap_or(false)
+}
+
+fn legacy_request(verb: &str) -> Option<()> {
+ read_endpoint()
+ .and_then(|endpoint| legacy_request_to(verb, &endpoint))
+ // 已运行的 pre-v1 版本只发布 mux.port;升级期间仍允许普通启动交接。
+ .or_else(|| {
+ read_endpoint_from(legacy_port_file())
+ .and_then(|endpoint| legacy_request_to(verb, &endpoint))
+ })
+}
+
+fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> {
+ let mut stream = TcpStream::connect_timeout(&endpoint_addr(endpoint), CONNECT_TIMEOUT).ok()?;
+ stream.set_read_timeout(Some(IO_TIMEOUT)).ok()?;
+ stream.set_write_timeout(Some(IO_TIMEOUT)).ok()?;
+ stream.write_all(format!("{verb} {}\n", endpoint.token).as_bytes()).ok()?;
+ let mut line = String::new();
+ BufReader::new(stream).read_line(&mut line).ok()?;
+ (line.trim() == "OK").then_some(())
+}
+
+/// Resident versioned runtime API server.
+pub struct RuntimeServer {
+ endpoint: Endpoint,
+ port_file: PathBuf,
+ _owner_lock: crate::atomic_file::LifetimeFileLock,
+}
+
+impl RuntimeServer {
+ pub fn spawn(proxy: EventLoopProxy, hub: RuntimeHub) -> Option {
+ Self::spawn_with_sink(EventSink::Winit(proxy), hub)
+ }
+
+ /// GPUI variant of [`Self::spawn`], delivered through a callback.
+ pub fn spawn_callback(
+ on_event: impl Fn(RuntimeCallback) + Send + Sync + 'static,
+ hub: RuntimeHub,
+ ) -> Option {
+ Self::spawn_with_sink(EventSink::Callback(Arc::new(on_event)), hub)
+ }
+
+ fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option {
+ let path = port_file();
+ let owner_lock = match crate::atomic_file::try_lifetime_lock(&path) {
+ Ok(Some(lock)) => lock,
+ Ok(None) => {
+ info!("Runtime API owner is starting or already running; staying client-only");
+ return None;
+ },
+ Err(error) => {
+ warn!("Runtime API: cannot lock {path:?}: {error}; control plane disabled");
+ return None;
+ },
+ };
+ if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() {
+ info!("Runtime API server already running; this instance stays client-only");
+ return None;
+ }
+
+ let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
+ let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() };
+ let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION);
+ if crate::atomic_file::write(&path, contents.as_bytes()).is_err() {
+ warn!("Runtime API: cannot write {path:?}; control plane disabled");
+ return None;
+ }
+
+ let server_token = endpoint.token.clone();
+ let spawned = std::thread::Builder::new()
+ .name("nebula-runtime-api".into())
+ .spawn(move || serve(listener, server_token, sink, hub))
+ .is_ok();
+ spawned.then(|| Self { endpoint, port_file: path, _owner_lock: owner_lock })
+ }
+}
+
+impl Drop for RuntimeServer {
+ fn drop(&mut self) {
+ // Do not delete another process's newer discovery record.
+ if read_endpoint().as_ref() == Some(&self.endpoint) {
+ let _ = std::fs::remove_file(&self.port_file);
+ }
+ }
+}