## Summary - package `packages/server` as a standalone desktop executable so Electron and Tauri no longer depend on a system-installed Node runtime in production - align Electron and Tauri startup logic around launching the packaged server, resolving binaries from the user shell, and bundling the same server resources into both desktop apps - replace the workspace instance proxy path that used `@fastify/reply-from` with a direct streaming proxy so packaged standalone builds can talk to spawned `opencode` instances correctly ## Why Desktop production builds were still depending on a user-provided Node runtime to launch `packages/server`, which made packaging less self-contained and created different behavior across machines. While moving to a standalone server executable, we also found that Bun-compiled standalone builds could start `opencode` successfully but failed when proxying requests to those instances through `reply-from`. The goal of this change is to make desktop production startup self-contained, keep Electron and Tauri behavior aligned, and restore correct communication with local `opencode` instances in packaged builds. ## What Changed - added a standalone build path for `packages/server` and bundle `codenomad-server` into desktop resources - updated Electron production startup to resolve and launch the standalone server executable - updated Tauri production startup to resolve and launch the standalone server executable with matching cwd and shell behavior - added runtime path helpers so the packaged server can reliably find its bundled UI, auth templates, config template, and package metadata - improved bare binary resolution so commands like `opencode` can be resolved from the user's login shell environment - upgraded the server stack to newer Fastify-compatible packages needed for the standalone/runtime work - replaced the workspace instance proxy implementation with a direct streaming proxy for requests to spawned `opencode` instances - updated Electron and Tauri build/prebuild scripts to generate and package the standalone server, while also repairing missing platform-specific optional binaries during packaging ## Benefits - desktop production builds no longer require Node to be installed on the user's system - Electron and Tauri now use the same packaged server model in production, reducing platform drift - packaged desktop apps can successfully create workspaces, launch `opencode`, and proxy health/session traffic to those instances - the server bundle is more self-contained and resilient to different launch environments - desktop packaging is more predictable because the required server executable is built and bundled as part of the app build flow
1388 lines
45 KiB
Rust
1388 lines
45 KiB
Rust
use dirs::home_dir;
|
|
use parking_lot::Mutex;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::VecDeque;
|
|
use std::env;
|
|
#[cfg(windows)]
|
|
use std::ffi::c_void;
|
|
use std::ffi::OsStr;
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
#[cfg(windows)]
|
|
use std::mem::{size_of, zeroed};
|
|
use std::net::TcpStream;
|
|
#[cfg(unix)]
|
|
use std::os::unix::process::CommandExt;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
|
|
|
#[cfg(windows)]
|
|
use std::os::windows::io::AsRawHandle;
|
|
#[cfg(windows)]
|
|
use std::os::windows::process::CommandExt;
|
|
#[cfg(windows)]
|
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
|
#[cfg(windows)]
|
|
use windows_sys::Win32::System::JobObjects::{
|
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
|
};
|
|
|
|
#[cfg(windows)]
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
|
|
|
|
#[cfg(windows)]
|
|
#[derive(Debug)]
|
|
struct WindowsJobObject {
|
|
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
|
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
|
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
|
handle: HANDLE,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl WindowsJobObject {
|
|
fn create() -> anyhow::Result<Self> {
|
|
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
|
if handle.is_null() {
|
|
return Err(anyhow::anyhow!(
|
|
"CreateJobObjectW failed: {}",
|
|
std::io::Error::last_os_error()
|
|
));
|
|
}
|
|
|
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
|
|
|
let ok = unsafe {
|
|
SetInformationJobObject(
|
|
handle,
|
|
JobObjectExtendedLimitInformation,
|
|
&mut info as *mut _ as *mut c_void,
|
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
|
)
|
|
};
|
|
if ok == 0 {
|
|
let err = std::io::Error::last_os_error();
|
|
unsafe {
|
|
CloseHandle(handle);
|
|
}
|
|
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
|
}
|
|
|
|
Ok(Self { handle })
|
|
}
|
|
|
|
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
|
let process_handle = child.as_raw_handle() as HANDLE;
|
|
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
|
if ok == 0 {
|
|
return Err(anyhow::anyhow!(
|
|
"AssignProcessToJobObject failed: {}",
|
|
std::io::Error::last_os_error()
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl Drop for WindowsJobObject {
|
|
fn drop(&mut self) {
|
|
if !self.handle.is_null() {
|
|
unsafe {
|
|
CloseHandle(self.handle);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
unsafe impl Send for WindowsJobObject {}
|
|
|
|
#[cfg(windows)]
|
|
unsafe impl Sync for WindowsJobObject {}
|
|
|
|
fn log_line(message: &str) {
|
|
println!("[tauri-cli] {message}");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn configure_spawn(command: &mut Command) {
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn configure_spawn(_command: &mut Command) {}
|
|
|
|
fn workspace_root() -> Option<PathBuf> {
|
|
std::env::current_dir().ok().and_then(|mut dir| {
|
|
for _ in 0..3 {
|
|
if let Some(parent) = dir.parent() {
|
|
dir = parent.to_path_buf();
|
|
}
|
|
}
|
|
Some(dir)
|
|
})
|
|
}
|
|
|
|
fn launch_cwd() -> Option<PathBuf> {
|
|
std::env::current_dir().ok()
|
|
}
|
|
|
|
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
|
|
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
|
#[cfg(windows)]
|
|
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
|
|
|
|
#[cfg(unix)]
|
|
fn configure_posix_process_group(command: &mut Command) {
|
|
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
|
// processes (login shell/tsx) without leaving the server orphaned.
|
|
unsafe {
|
|
command.pre_exec(|| {
|
|
if libc::setpgid(0, 0) != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
|
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
|
if force {
|
|
args.push("/F".to_string());
|
|
}
|
|
|
|
let mut command = Command::new("taskkill");
|
|
command.args(&args);
|
|
configure_spawn(&mut command);
|
|
|
|
match command.output() {
|
|
Ok(output) => {
|
|
if output.status.success() {
|
|
return true;
|
|
}
|
|
|
|
// If the PID is already gone, treat it as success.
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
|
let combined = format!("{stdout}\n{stderr}");
|
|
combined.contains("not found") || combined.contains("no running instance")
|
|
}
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
fn navigate_main(app: &AppHandle, url: &str) {
|
|
if let Some(win) = app.webview_windows().get("main") {
|
|
let mut display = url.to_string();
|
|
if let Some(hash_index) = display.find('#') {
|
|
display.replace_range(hash_index + 1.., "[REDACTED]");
|
|
}
|
|
log_line(&format!("navigating main to {display}"));
|
|
if let Ok(parsed) = Url::parse(url) {
|
|
let _ = win.navigate(parsed);
|
|
} else {
|
|
log_line("failed to parse URL for navigation");
|
|
}
|
|
} else {
|
|
log_line("main window not found for navigation");
|
|
}
|
|
}
|
|
|
|
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|
let prefix = format!("{name}=");
|
|
let cookie_kv = set_cookie.split(';').next()?.trim();
|
|
if !cookie_kv.starts_with(&prefix) {
|
|
return None;
|
|
}
|
|
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
|
if value.is_empty() {
|
|
return None;
|
|
}
|
|
Some(value.to_string())
|
|
}
|
|
|
|
fn exchange_bootstrap_token(
|
|
base_url: &str,
|
|
token: &str,
|
|
cookie_name: &str,
|
|
) -> anyhow::Result<Option<String>> {
|
|
let parsed = Url::parse(base_url)?;
|
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
|
|
|
// This is only used for local bootstrap; we assume plain HTTP.
|
|
let mut stream = TcpStream::connect((host, port))?;
|
|
|
|
let body = format!("{{\"token\":\"{}\"}}", token);
|
|
let request = format!(
|
|
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.as_bytes().len(),
|
|
body
|
|
);
|
|
|
|
stream.write_all(request.as_bytes())?;
|
|
stream.flush()?;
|
|
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response)?;
|
|
|
|
let (raw_headers, _rest) = response
|
|
.split_once("\r\n\r\n")
|
|
.or_else(|| response.split_once("\n\n"))
|
|
.unwrap_or((response.as_str(), ""));
|
|
|
|
let mut lines = raw_headers.lines();
|
|
let status_line = lines.next().unwrap_or("");
|
|
if !status_line.contains(" 200 ") {
|
|
return Ok(None);
|
|
}
|
|
|
|
for line in lines {
|
|
// handle case-insensitive header name
|
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
|
return Ok(Some(session_id));
|
|
}
|
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
|
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
|
return Ok(Some(session_id));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn set_session_cookie(
|
|
app: &AppHandle,
|
|
base_url: &str,
|
|
cookie_name: &str,
|
|
session_id: &str,
|
|
) -> anyhow::Result<()> {
|
|
let parsed = Url::parse(base_url)?;
|
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
|
|
|
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
|
.domain(domain)
|
|
.path("/")
|
|
.http_only(true)
|
|
.same_site(tauri::webview::cookie::SameSite::Lax)
|
|
.build();
|
|
|
|
if let Some(win) = app.webview_windows().get("main") {
|
|
win.set_cookie(cookie)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_auth_cookie_name() -> String {
|
|
let pid = std::process::id();
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_millis())
|
|
.unwrap_or(0);
|
|
|
|
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
|
}
|
|
|
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PreferencesConfig {
|
|
#[serde(rename = "listeningMode")]
|
|
listening_mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ServerConfig {
|
|
#[serde(rename = "listeningMode")]
|
|
listening_mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AppConfig {
|
|
preferences: Option<PreferencesConfig>,
|
|
server: Option<ServerConfig>,
|
|
}
|
|
|
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
|
let raw = env::var("CLI_CONFIG")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
|
|
|
let expanded = expand_home(&raw);
|
|
let lower = raw.trim().to_lowercase();
|
|
|
|
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
|
let base = expanded
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| expanded.clone());
|
|
return (expanded, base.join("config.json"));
|
|
}
|
|
|
|
if lower.ends_with(".json") {
|
|
let base = expanded
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| expanded.clone());
|
|
return (base.join("config.yaml"), expanded);
|
|
}
|
|
|
|
// Treat as directory.
|
|
(expanded.join("config.yaml"), expanded.join("config.json"))
|
|
}
|
|
|
|
fn expand_home(path: &str) -> PathBuf {
|
|
if path.starts_with("~/") {
|
|
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
|
return home.join(path.trim_start_matches("~/"));
|
|
}
|
|
}
|
|
PathBuf::from(path)
|
|
}
|
|
|
|
fn resolve_listening_mode() -> String {
|
|
let (yaml_path, json_path) = resolve_config_locations();
|
|
|
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
|
let mode = config
|
|
.server
|
|
.as_ref()
|
|
.and_then(|srv| srv.listening_mode.as_ref())
|
|
.or_else(|| {
|
|
config
|
|
.preferences
|
|
.as_ref()
|
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
});
|
|
|
|
if let Some(mode) = mode {
|
|
if mode == "local" {
|
|
return "local".to_string();
|
|
}
|
|
if mode == "all" {
|
|
return "all".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy fallback.
|
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
|
let mode = config
|
|
.server
|
|
.as_ref()
|
|
.and_then(|srv| srv.listening_mode.as_ref())
|
|
.or_else(|| {
|
|
config
|
|
.preferences
|
|
.as_ref()
|
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
});
|
|
if let Some(mode) = mode {
|
|
if mode == "local" {
|
|
return "local".to_string();
|
|
}
|
|
if mode == "all" {
|
|
return "all".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"local".to_string()
|
|
}
|
|
|
|
fn resolve_listening_host() -> String {
|
|
let mode = resolve_listening_mode();
|
|
if mode == "local" {
|
|
"127.0.0.1".to_string()
|
|
} else {
|
|
"0.0.0.0".to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CliState {
|
|
Starting,
|
|
Ready,
|
|
Error,
|
|
Stopped,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CliStatus {
|
|
pub state: CliState,
|
|
pub pid: Option<u32>,
|
|
pub port: Option<u16>,
|
|
pub url: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl Default for CliStatus {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: CliState::Stopped,
|
|
pid: None,
|
|
port: None,
|
|
url: None,
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CliProcessManager {
|
|
status: Arc<Mutex<CliStatus>>,
|
|
child: Arc<Mutex<Option<Child>>>,
|
|
#[cfg(windows)]
|
|
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
|
ready: Arc<AtomicBool>,
|
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
impl CliProcessManager {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
|
child: Arc::new(Mutex::new(None)),
|
|
#[cfg(windows)]
|
|
job: Arc::new(Mutex::new(None)),
|
|
ready: Arc::new(AtomicBool::new(false)),
|
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
|
}
|
|
}
|
|
|
|
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
|
log_line(&format!("start requested (dev={dev})"));
|
|
self.stop()?;
|
|
self.ready.store(false, Ordering::SeqCst);
|
|
*self.bootstrap_token.lock() = None;
|
|
{
|
|
let mut status = self.status.lock();
|
|
status.state = CliState::Starting;
|
|
status.port = None;
|
|
status.url = None;
|
|
status.error = None;
|
|
status.pid = None;
|
|
}
|
|
Self::emit_status(&app, &self.status.lock());
|
|
|
|
let status_arc = self.status.clone();
|
|
let child_arc = self.child.clone();
|
|
#[cfg(windows)]
|
|
let job_arc = self.job.clone();
|
|
let ready_flag = self.ready.clone();
|
|
let token_arc = self.bootstrap_token.clone();
|
|
thread::spawn(move || {
|
|
if let Err(err) = Self::spawn_cli(
|
|
app.clone(),
|
|
status_arc.clone(),
|
|
child_arc,
|
|
#[cfg(windows)]
|
|
job_arc,
|
|
ready_flag,
|
|
token_arc,
|
|
dev,
|
|
) {
|
|
log_line(&format!("cli spawn failed: {err}"));
|
|
let mut locked = status_arc.lock();
|
|
locked.state = CliState::Error;
|
|
locked.error = Some(err.to_string());
|
|
let snapshot = locked.clone();
|
|
drop(locked);
|
|
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
|
let _ = app.emit("cli:status", snapshot);
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop(&self) -> anyhow::Result<()> {
|
|
#[cfg(windows)]
|
|
let _job = self.job.lock().take();
|
|
|
|
let mut child_opt = self.child.lock();
|
|
if let Some(mut child) = child_opt.take() {
|
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
let pid = child.id() as i32;
|
|
// Prefer signaling the process group to avoid orphaning children
|
|
// when the CLI was launched via a wrapper shell.
|
|
let group_res = libc::kill(-pid, libc::SIGTERM);
|
|
if group_res != 0 {
|
|
let _ = libc::kill(pid, libc::SIGTERM);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = kill_process_tree_windows(child.id(), false);
|
|
}
|
|
|
|
let start = Instant::now();
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) => {
|
|
#[cfg(windows)]
|
|
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
|
log_line(&format!(
|
|
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
|
CLI_WINDOWS_FORCE_GRACE_MS,
|
|
child.id()
|
|
));
|
|
if !kill_process_tree_windows(child.id(), true) {
|
|
let _ = child.kill();
|
|
}
|
|
break;
|
|
}
|
|
|
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
|
log_line(&format!(
|
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
|
CLI_STOP_GRACE_SECS,
|
|
child.id()
|
|
));
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
let pid = child.id() as i32;
|
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
if group_res != 0 {
|
|
let _ = libc::kill(pid, libc::SIGKILL);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
if !kill_process_tree_windows(child.id(), true) {
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
} else {
|
|
#[cfg(windows)]
|
|
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
|
}
|
|
|
|
let mut status = self.status.lock();
|
|
status.state = CliState::Stopped;
|
|
status.pid = None;
|
|
status.port = None;
|
|
status.url = None;
|
|
status.error = None;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn status(&self) -> CliStatus {
|
|
self.status.lock().clone()
|
|
}
|
|
|
|
fn spawn_cli(
|
|
app: AppHandle,
|
|
status: Arc<Mutex<CliStatus>>,
|
|
child_holder: Arc<Mutex<Option<Child>>>,
|
|
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
|
ready: Arc<AtomicBool>,
|
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
|
dev: bool,
|
|
) -> anyhow::Result<()> {
|
|
log_line("resolving CLI entry");
|
|
let resolution = CliEntry::resolve(&app, dev)?;
|
|
let host = resolve_listening_host();
|
|
log_line(&format!(
|
|
"resolved CLI entry runner={:?} entry={} host={}",
|
|
resolution.runner, resolution.entry, host
|
|
));
|
|
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
|
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
|
log_line(&format!("CLI args: {:?}", args));
|
|
if dev {
|
|
log_line("development mode: will prefer tsx + source if present");
|
|
}
|
|
|
|
let cwd = launch_cwd();
|
|
if let Some(ref c) = cwd {
|
|
log_line(&format!("using cwd={}", c.display()));
|
|
}
|
|
|
|
let use_user_shell = supports_user_shell();
|
|
|
|
if resolution.runner == Runner::Tsx
|
|
&& !use_user_shell
|
|
&& which::which(&resolution.node_binary).is_err()
|
|
{
|
|
return Err(anyhow::anyhow!(
|
|
"Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
|
resolution.node_binary
|
|
));
|
|
}
|
|
|
|
let command_info = if use_user_shell {
|
|
log_line("spawning via user shell");
|
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
|
} else {
|
|
log_line(if resolution.runner == Runner::Standalone {
|
|
"spawning directly with standalone executable"
|
|
} else {
|
|
"spawning directly with node"
|
|
});
|
|
ShellCommandType::Direct(DirectCommand {
|
|
program: if resolution.runner == Runner::Standalone {
|
|
resolution.entry.clone()
|
|
} else {
|
|
resolution.node_binary.clone()
|
|
},
|
|
args: resolution.runner_args(&args),
|
|
})
|
|
};
|
|
|
|
let child = match &command_info {
|
|
ShellCommandType::UserShell(cmd) => {
|
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
|
let mut c = Command::new(&cmd.shell);
|
|
c.args(&cmd.args)
|
|
.env_remove("npm_config_prefix")
|
|
.env_remove("NPM_CONFIG_PREFIX")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if resolution.runner != Runner::Standalone {
|
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
|
}
|
|
configure_spawn(&mut c);
|
|
if let Some(ref cwd) = cwd {
|
|
c.current_dir(cwd);
|
|
}
|
|
#[cfg(unix)]
|
|
configure_posix_process_group(&mut c);
|
|
c.spawn()?
|
|
}
|
|
ShellCommandType::Direct(cmd) => {
|
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
|
let mut c = Command::new(&cmd.program);
|
|
c.args(&cmd.args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
if resolution.runner != Runner::Standalone {
|
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
|
}
|
|
configure_spawn(&mut c);
|
|
if let Some(ref cwd) = cwd {
|
|
c.current_dir(cwd);
|
|
}
|
|
#[cfg(unix)]
|
|
configure_posix_process_group(&mut c);
|
|
c.spawn()?
|
|
}
|
|
};
|
|
|
|
let pid = child.id();
|
|
log_line(&format!("spawned pid={pid}"));
|
|
#[cfg(windows)]
|
|
match WindowsJobObject::create().and_then(|job| {
|
|
job.assign_child(&child)?;
|
|
Ok(job)
|
|
}) {
|
|
Ok(job) => {
|
|
log_line(&format!("attached pid={pid} to Windows job object"));
|
|
*job_holder.lock() = Some(job);
|
|
}
|
|
Err(err) => {
|
|
log_line(&format!(
|
|
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
|
));
|
|
}
|
|
}
|
|
|
|
{
|
|
let mut locked = status.lock();
|
|
locked.pid = Some(pid);
|
|
}
|
|
Self::emit_status(&app, &status.lock());
|
|
|
|
{
|
|
let mut holder = child_holder.lock();
|
|
*holder = Some(child);
|
|
}
|
|
|
|
let child_clone = child_holder.clone();
|
|
let status_clone = status.clone();
|
|
let app_clone = app.clone();
|
|
let ready_clone = ready.clone();
|
|
let token_clone = bootstrap_token.clone();
|
|
let auth_cookie_name_clone = auth_cookie_name.clone();
|
|
|
|
thread::spawn(move || {
|
|
let stdout = child_clone
|
|
.lock()
|
|
.as_mut()
|
|
.and_then(|c| c.stdout.take())
|
|
.map(BufReader::new);
|
|
let stderr = child_clone
|
|
.lock()
|
|
.as_mut()
|
|
.and_then(|c| c.stderr.take())
|
|
.map(BufReader::new);
|
|
|
|
if let Some(reader) = stdout {
|
|
let app = app_clone.clone();
|
|
let status = status_clone.clone();
|
|
let ready = ready_clone.clone();
|
|
let token = token_clone.clone();
|
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
|
thread::spawn(move || {
|
|
Self::process_stream(
|
|
reader,
|
|
"stdout",
|
|
&app,
|
|
&status,
|
|
&ready,
|
|
&token,
|
|
auth_cookie_name.as_str(),
|
|
);
|
|
});
|
|
}
|
|
|
|
if let Some(reader) = stderr {
|
|
let app = app_clone.clone();
|
|
let status = status_clone.clone();
|
|
let ready = ready_clone.clone();
|
|
let token = token_clone.clone();
|
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
|
thread::spawn(move || {
|
|
Self::process_stream(
|
|
reader,
|
|
"stderr",
|
|
&app,
|
|
&status,
|
|
&ready,
|
|
&token,
|
|
auth_cookie_name.as_str(),
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
let app_clone = app.clone();
|
|
let status_clone = status.clone();
|
|
let ready_clone = ready.clone();
|
|
let child_holder_clone = child_holder.clone();
|
|
#[cfg(windows)]
|
|
let job_holder_clone = job_holder.clone();
|
|
thread::spawn(move || {
|
|
let timeout = Duration::from_secs(60);
|
|
thread::sleep(timeout);
|
|
if ready_clone.load(Ordering::SeqCst) {
|
|
return;
|
|
}
|
|
let mut locked = status_clone.lock();
|
|
locked.state = CliState::Error;
|
|
locked.error = Some("CLI did not start in time".to_string());
|
|
log_line("timeout waiting for CLI readiness");
|
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
let pid = child.id() as i32;
|
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
if group_res != 0 {
|
|
let _ = libc::kill(pid, libc::SIGKILL);
|
|
}
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
if !kill_process_tree_windows(child.id(), true) {
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
|
Self::emit_status(&app_clone, &locked);
|
|
});
|
|
|
|
let status_clone = status.clone();
|
|
let app_clone = app.clone();
|
|
thread::spawn(move || {
|
|
// Do not hold the child mutex while waiting for process exit.
|
|
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
|
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
|
let code = loop {
|
|
let maybe_exited = {
|
|
let mut guard = child_holder.lock();
|
|
if guard.is_none() {
|
|
return;
|
|
}
|
|
match guard
|
|
.as_mut()
|
|
.and_then(|child| child.try_wait().ok().flatten())
|
|
{
|
|
Some(status) => {
|
|
// Drop the handle after the process exits so other callers
|
|
// don't attempt to stop/kill a finished process.
|
|
*guard = None;
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = job_holder_clone.lock().take();
|
|
}
|
|
Some(status)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
|
|
if let Some(status) = maybe_exited {
|
|
break Some(status);
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
};
|
|
|
|
let mut locked = status_clone.lock();
|
|
let failed = locked.state != CliState::Ready;
|
|
let err_msg = if failed {
|
|
Some(match code {
|
|
Some(status) => format!("CLI exited early: {status}"),
|
|
None => "CLI exited early".to_string(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if failed {
|
|
locked.state = CliState::Error;
|
|
if locked.error.is_none() {
|
|
locked.error = err_msg.clone();
|
|
}
|
|
log_line(&format!(
|
|
"cli process exited before ready: {:?}",
|
|
locked.error
|
|
));
|
|
let _ = app_clone.emit(
|
|
"cli:error",
|
|
json!({"message": locked.error.clone().unwrap_or_default()}),
|
|
);
|
|
} else {
|
|
locked.state = CliState::Stopped;
|
|
log_line("cli process stopped cleanly");
|
|
}
|
|
|
|
Self::emit_status(&app_clone, &locked);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn process_stream<R: BufRead>(
|
|
mut reader: R,
|
|
stream: &str,
|
|
app: &AppHandle,
|
|
status: &Arc<Mutex<CliStatus>>,
|
|
ready: &Arc<AtomicBool>,
|
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
|
auth_cookie_name: &str,
|
|
) {
|
|
let mut buffer = String::new();
|
|
let local_url_regex =
|
|
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
|
|
|
loop {
|
|
buffer.clear();
|
|
match reader.read_line(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(_) => {
|
|
let line = buffer.trim_end();
|
|
if !line.is_empty() {
|
|
if line.starts_with(token_prefix) {
|
|
let token = line.trim_start_matches(token_prefix).trim();
|
|
if !token.is_empty() {
|
|
let mut guard = bootstrap_token.lock();
|
|
if guard.is_none() {
|
|
*guard = Some(token.to_string());
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
log_line(&format!("[cli][{}] {}", stream, line));
|
|
|
|
if ready.load(Ordering::SeqCst) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
|
|
let mut locked = status.lock();
|
|
if locked.error.is_none() {
|
|
locked.error = Some(format!(
|
|
"Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
|
node_binary.trim()
|
|
));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some(url) = local_url_regex
|
|
.as_ref()
|
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
|
.map(|m| m.as_str().to_string())
|
|
{
|
|
Self::mark_ready(
|
|
app,
|
|
status,
|
|
ready,
|
|
bootstrap_token,
|
|
auth_cookie_name,
|
|
url,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mark_ready(
|
|
app: &AppHandle,
|
|
status: &Arc<Mutex<CliStatus>>,
|
|
ready: &Arc<AtomicBool>,
|
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
|
auth_cookie_name: &str,
|
|
base_url: String,
|
|
) {
|
|
ready.store(true, Ordering::SeqCst);
|
|
let port = Url::parse(&base_url)
|
|
.ok()
|
|
.and_then(|u| u.port_or_known_default())
|
|
.map(|p| p as u16);
|
|
let mut locked = status.lock();
|
|
locked.port = port;
|
|
locked.url = Some(base_url.clone());
|
|
locked.state = CliState::Ready;
|
|
locked.error = None;
|
|
log_line(&format!("cli ready on {base_url}"));
|
|
|
|
let token = bootstrap_token.lock().take();
|
|
|
|
if let Some(token) = token {
|
|
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
|
// skip the exchange and let the user authenticate normally.
|
|
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
|
if scheme.as_deref() != Some("http") {
|
|
navigate_main(app, &base_url);
|
|
} else {
|
|
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
|
Ok(Some(session_id)) => {
|
|
if let Err(err) =
|
|
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
|
{
|
|
log_line(&format!("failed to set session cookie: {err}"));
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
} else {
|
|
navigate_main(app, &base_url);
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
log_line("bootstrap token exchange failed (invalid token)");
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
}
|
|
Err(err) => {
|
|
log_line(&format!("bootstrap token exchange failed: {err}"));
|
|
navigate_main(app, &format!("{base_url}/login"));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
navigate_main(app, &base_url);
|
|
}
|
|
let _ = app.emit("cli:ready", locked.clone());
|
|
Self::emit_status(app, &locked);
|
|
}
|
|
|
|
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
|
let _ = app.emit("cli:status", status.clone());
|
|
}
|
|
}
|
|
|
|
fn supports_user_shell() -> bool {
|
|
cfg!(unix)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ShellCommand {
|
|
shell: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct DirectCommand {
|
|
program: String,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ShellCommandType {
|
|
UserShell(ShellCommand),
|
|
Direct(DirectCommand),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CliEntry {
|
|
entry: String,
|
|
runner: Runner,
|
|
runner_path: Option<String>,
|
|
node_binary: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Runner {
|
|
Standalone,
|
|
Tsx,
|
|
}
|
|
|
|
impl CliEntry {
|
|
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
|
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
|
|
|
if dev {
|
|
if let Some(tsx_path) = resolve_tsx(app) {
|
|
if let Some(entry) = resolve_dev_entry(app) {
|
|
return Ok(Self {
|
|
entry,
|
|
runner: Runner::Tsx,
|
|
runner_path: Some(tsx_path),
|
|
node_binary,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(entry) = resolve_standalone_entry(app) {
|
|
return Ok(Self {
|
|
entry,
|
|
runner: Runner::Standalone,
|
|
runner_path: None,
|
|
node_binary: String::new(),
|
|
});
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
|
|
))
|
|
}
|
|
|
|
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
|
let mut args = vec![
|
|
"serve".to_string(),
|
|
"--host".to_string(),
|
|
host.to_string(),
|
|
"--auth-cookie-name".to_string(),
|
|
auth_cookie_name.to_string(),
|
|
"--generate-token".to_string(),
|
|
"--unrestricted-root".to_string(),
|
|
];
|
|
|
|
if dev {
|
|
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
|
|
// remote proxy sessions can still spin up secure local windows.
|
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.or_else(|| {
|
|
std::env::var("ELECTRON_RENDERER_URL")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
})
|
|
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
|
let log_level = std::env::var("CLI_LOG_LEVEL")
|
|
.ok()
|
|
.map(|value| value.trim().to_lowercase())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "info".to_string());
|
|
|
|
args.push("--https".to_string());
|
|
args.push("true".to_string());
|
|
args.push("--http".to_string());
|
|
args.push("true".to_string());
|
|
args.push("--http-port".to_string());
|
|
args.push("0".to_string());
|
|
args.push("--ui-dev-server".to_string());
|
|
args.push(ui_dev_server);
|
|
args.push("--log-level".to_string());
|
|
args.push(log_level);
|
|
} else {
|
|
// Prod desktop: always keep loopback HTTP enabled.
|
|
args.push("--https".to_string());
|
|
args.push("true".to_string());
|
|
args.push("--http".to_string());
|
|
args.push("true".to_string());
|
|
}
|
|
args
|
|
}
|
|
|
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
|
if self.runner == Runner::Standalone {
|
|
return cli_args.to_vec();
|
|
}
|
|
|
|
let mut args = VecDeque::new();
|
|
if self.runner == Runner::Tsx {
|
|
if let Some(path) = &self.runner_path {
|
|
args.push_back(path.clone());
|
|
}
|
|
}
|
|
args.push_back(self.entry.clone());
|
|
for arg in cli_args {
|
|
args.push_back(arg.clone());
|
|
}
|
|
args.into_iter().collect()
|
|
}
|
|
}
|
|
|
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|
let cwd = std::env::current_dir().ok();
|
|
let workspace = workspace_root();
|
|
let mut candidates = vec![
|
|
cwd.as_ref()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
|
cwd.as_ref()
|
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
|
workspace
|
|
.as_ref()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
|
workspace
|
|
.as_ref()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
|
workspace
|
|
.as_ref()
|
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
|
];
|
|
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(dir) = exe.parent() {
|
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
|
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
|
}
|
|
}
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
|
let cwd = std::env::current_dir().ok();
|
|
let workspace = workspace_root();
|
|
let candidates = vec![
|
|
workspace
|
|
.as_ref()
|
|
.map(|p| p.join("packages/server/src/index.ts")),
|
|
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
|
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
|
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
|
];
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
|
|
let executable_name = if cfg!(windows) {
|
|
"codenomad-server.exe"
|
|
} else {
|
|
"codenomad-server"
|
|
};
|
|
let base = workspace_root();
|
|
let mut candidates = vec![base
|
|
.as_ref()
|
|
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
|
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(dir) = exe.parent() {
|
|
candidates.push(Some(
|
|
dir.join("resources/server/dist").join(executable_name),
|
|
));
|
|
|
|
let resources = dir.join("../Resources");
|
|
candidates.push(Some(resources.join("server/dist").join(executable_name)));
|
|
candidates.push(Some(
|
|
resources
|
|
.join("resources/server/dist")
|
|
.join(executable_name),
|
|
));
|
|
|
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
|
for root in linux_resource_roots {
|
|
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
|
candidates.push(Some(
|
|
root.join("resources/server/dist").join(executable_name),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
first_existing(candidates)
|
|
}
|
|
|
|
fn build_shell_command_string(
|
|
entry: &CliEntry,
|
|
cli_args: &[String],
|
|
) -> anyhow::Result<ShellCommand> {
|
|
let shell = default_shell();
|
|
let mut quoted: Vec<String> = Vec::new();
|
|
let command = if entry.runner == Runner::Standalone {
|
|
quoted.push(shell_escape(&entry.entry));
|
|
for arg in cli_args {
|
|
quoted.push(shell_escape(arg));
|
|
}
|
|
format!("exec {}", quoted.join(" "))
|
|
} else {
|
|
quoted.push(shell_escape(&entry.node_binary));
|
|
for arg in entry.runner_args(cli_args) {
|
|
quoted.push(shell_escape(&arg));
|
|
}
|
|
format!(
|
|
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
|
shell_escape(&entry.node_binary),
|
|
quoted.join(" "),
|
|
MISSING_NODE_PREFIX,
|
|
shell_escape(&entry.node_binary),
|
|
)
|
|
};
|
|
let wrapped_command = wrap_command_for_shell(&command, &shell);
|
|
let args = build_shell_args(&shell, &wrapped_command);
|
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
|
Ok(ShellCommand { shell, args })
|
|
}
|
|
|
|
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
|
|
let shell_name = std::path::Path::new(shell)
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
if shell_name.contains("bash") {
|
|
return format!(
|
|
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
|
|
command
|
|
);
|
|
}
|
|
|
|
if shell_name.contains("zsh") {
|
|
return format!(
|
|
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
|
|
command
|
|
);
|
|
}
|
|
|
|
command.to_string()
|
|
}
|
|
|
|
fn default_shell() -> String {
|
|
if let Ok(shell) = std::env::var("SHELL") {
|
|
if !shell.trim().is_empty() {
|
|
return shell;
|
|
}
|
|
}
|
|
if cfg!(target_os = "macos") {
|
|
"/bin/zsh".to_string()
|
|
} else {
|
|
"/bin/bash".to_string()
|
|
}
|
|
}
|
|
|
|
fn shell_escape(input: &str) -> String {
|
|
if input.is_empty() {
|
|
"''".to_string()
|
|
} else if !input
|
|
.chars()
|
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
|
{
|
|
input.to_string()
|
|
} else {
|
|
let escaped = input.replace('\'', "'\\''");
|
|
format!("'{}'", escaped)
|
|
}
|
|
}
|
|
|
|
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|
let shell_name = std::path::Path::new(shell)
|
|
.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
if shell_name.contains("zsh") {
|
|
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
|
} else {
|
|
vec!["-l".into(), "-c".into(), command.into()]
|
|
}
|
|
}
|
|
|
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|
paths
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|p| p.exists())
|
|
.map(|p| normalize_path(p))
|
|
}
|
|
|
|
fn normalize_path(path: PathBuf) -> String {
|
|
let resolved = if let Ok(clean) = path.canonicalize() {
|
|
clean
|
|
} else {
|
|
path
|
|
};
|
|
|
|
let rendered = resolved.to_string_lossy().to_string();
|
|
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
|
format!("\\\\{}", stripped)
|
|
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
|
stripped.to_string()
|
|
} else {
|
|
rendered
|
|
}
|
|
}
|