Add Tauri app sources
This commit is contained in:
4749
packages/tauri-app/Cargo.lock
generated
Normal file
4749
packages/tauri-app/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/tauri-app/Cargo.toml
Normal file
3
packages/tauri-app/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["src-tauri"]
|
||||||
|
resolver = "2"
|
||||||
19
packages/tauri-app/src-tauri/Cargo.toml
Normal file
19
packages/tauri-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "codenomad-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0-beta.20", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.0.0-beta.20", features = [ "devtools"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
once_cell = "1"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
thiserror = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
which = "4"
|
||||||
|
libc = "0.2"
|
||||||
3
packages/tauri-app/src-tauri/build.rs
Normal file
3
packages/tauri-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
2244
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2244
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2244
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
2244
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
Binary file not shown.
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
636
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
636
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
use parking_lot::Mutex;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
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};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
fn log_line(message: &str) {
|
||||||
|
println!("[tauri-cli] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 navigate_main(app: &AppHandle, url: &str) {
|
||||||
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
|
log_line(&format!("navigating main to {url}"));
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>>>,
|
||||||
|
ready: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliProcessManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
let ready_flag = self.ready.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, 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<()> {
|
||||||
|
let mut child_opt = self.child.lock();
|
||||||
|
if let Some(mut child) = child_opt.take() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => break,
|
||||||
|
Ok(None) => {
|
||||||
|
if start.elapsed() > Duration::from_secs(4) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>>,
|
||||||
|
ready: Arc<AtomicBool>,
|
||||||
|
dev: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
log_line("resolving CLI entry");
|
||||||
|
let resolution = CliEntry::resolve(&app, dev)?;
|
||||||
|
log_line(&format!(
|
||||||
|
"resolved CLI entry runner={:?} entry={}",
|
||||||
|
resolution.runner, resolution.entry
|
||||||
|
));
|
||||||
|
let args = resolution.build_args(dev);
|
||||||
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
|
if dev {
|
||||||
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd = workspace_root();
|
||||||
|
if let Some(ref c) = cwd {
|
||||||
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_info = if supports_user_shell() {
|
||||||
|
log_line("spawning via user shell");
|
||||||
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
|
} else {
|
||||||
|
log_line("spawning directly with node");
|
||||||
|
ShellCommandType::Direct(DirectCommand {
|
||||||
|
program: resolution.node_binary.clone(),
|
||||||
|
args: resolution.runner_args(&args),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !supports_user_shell() {
|
||||||
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
|
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if let Some(ref cwd) = cwd {
|
||||||
|
c.current_dir(cwd);
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if let Some(ref cwd) = cwd {
|
||||||
|
c.current_dir(cwd);
|
||||||
|
}
|
||||||
|
c.spawn()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = child.id();
|
||||||
|
log_line(&format!("spawned pid={pid}"));
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||||
|
}
|
||||||
|
if let Some(reader) = stderr {
|
||||||
|
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let status_clone = status.clone();
|
||||||
|
let ready_clone = ready.clone();
|
||||||
|
let child_holder_clone = child_holder.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let timeout = Duration::from_secs(15);
|
||||||
|
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() {
|
||||||
|
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 || {
|
||||||
|
let code = {
|
||||||
|
let mut guard = child_holder.lock();
|
||||||
|
if let Some(child) = guard.as_mut() {
|
||||||
|
child.wait().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>,
|
||||||
|
) {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||||
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
buffer.clear();
|
||||||
|
match reader.read_line(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {
|
||||||
|
let line = buffer.trim_end();
|
||||||
|
if !line.is_empty() {
|
||||||
|
let _ = app.emit("cli:log", json!({"stream": stream, "message": line}));
|
||||||
|
|
||||||
|
if ready.load(Ordering::SeqCst) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(port) = port_regex
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
|
{
|
||||||
|
Self::mark_ready(app, status, ready, port);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.to_lowercase().contains("http server listening") {
|
||||||
|
if let Some(port) = http_regex
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
|
{
|
||||||
|
Self::mark_ready(app, status, ready, port);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
|
Self::mark_ready(app, status, ready, port as u16);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||||
|
ready.store(true, Ordering::SeqCst);
|
||||||
|
let mut locked = status.lock();
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
locked.port = Some(port);
|
||||||
|
locked.url = Some(url.clone());
|
||||||
|
locked.state = CliState::Ready;
|
||||||
|
locked.error = None;
|
||||||
|
log_line(&format!("cli ready on {url}"));
|
||||||
|
navigate_main(app, &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 {
|
||||||
|
Node,
|
||||||
|
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_dist_entry(app) {
|
||||||
|
return Ok(Self {
|
||||||
|
entry,
|
||||||
|
runner: Runner::Node,
|
||||||
|
runner_path: None,
|
||||||
|
node_binary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_args(&self, dev: bool) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--host".to_string(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
];
|
||||||
|
if dev {
|
||||||
|
args.push("--ui-dev-server".to_string());
|
||||||
|
args.push("http://localhost:3000".to_string());
|
||||||
|
args.push("--log-level".to_string());
|
||||||
|
args.push("debug".to_string());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||||
|
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 candidates = vec![
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
||||||
|
];
|
||||||
|
|
||||||
|
first_existing(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let candidates = vec![
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("../server/src/index.ts")),
|
||||||
|
];
|
||||||
|
|
||||||
|
first_existing(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let base = workspace_root();
|
||||||
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||||
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||||
|
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||||
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||||
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
let resources = dir.join("../Resources");
|
||||||
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
|
for arg in entry.runner_args(cli_args) {
|
||||||
|
quoted.push(shell_escape(&arg));
|
||||||
|
}
|
||||||
|
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
||||||
|
let args = build_shell_args(&shell, &command);
|
||||||
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
|
Ok(ShellCommand { shell, args })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if let Ok(clean) = path.canonicalize() {
|
||||||
|
clean.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
78
packages/tauri-app/src-tauri/src/main.rs
Normal file
78
packages/tauri-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod cli_manager;
|
||||||
|
|
||||||
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
|
use serde_json::json;
|
||||||
|
use tauri::menu::Menu;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub manager: CliProcessManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||||
|
state.manager.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(AppState {
|
||||||
|
manager: CliProcessManager::new(),
|
||||||
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
build_menu(&app.handle())?;
|
||||||
|
let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
let manager = app.state::<AppState>().manager.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"cli:error",
|
||||||
|
json!({"message": err.to_string()}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![cli_get_status])
|
||||||
|
.on_menu_event(|_app_handle, _event| {
|
||||||
|
// No menu items defined currently
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building tauri application")
|
||||||
|
.run(|app_handle, event| {
|
||||||
|
match event {
|
||||||
|
tauri::RunEvent::ExitRequested { .. } => {
|
||||||
|
let app = app_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
let _ = state.manager.stop();
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => {
|
||||||
|
if app_handle.webview_windows().len() <= 1 {
|
||||||
|
let app = app_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
let _ = state.manager.stop();
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
|
||||||
|
let menu = Menu::new(app)?;
|
||||||
|
app.set_menu(menu)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
BIN
packages/tauri-app/src/icon.png
Normal file
BIN
packages/tauri-app/src/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
197
packages/tauri-app/src/index.html
Normal file
197
packages/tauri-app/src/index.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #cfd4dc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 180px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 2.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #f4f6fb;
|
||||||
|
}
|
||||||
|
.loading-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #151a23;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #cfd4dc;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-top-color: #6ce3ff;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
.phrase-controls {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8f96a9;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.phrase-controls button {
|
||||||
|
color: #8fb5ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff9ea9;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper" role="status" aria-live="polite">
|
||||||
|
<img src="./icon.png" alt="CodeNomad" class="logo" />
|
||||||
|
<div>
|
||||||
|
<h1 class="title">CodeNomad</h1>
|
||||||
|
</div>
|
||||||
|
<div class="loading-card">
|
||||||
|
<div class="loading-row">
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
<span id="loading-phrase">Warming up the AI neurons…</span>
|
||||||
|
</div>
|
||||||
|
<div class="phrase-controls">
|
||||||
|
<button id="phrase-toggle" type="button">Show another</button>
|
||||||
|
</div>
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const phrases = [
|
||||||
|
"Warming up the AI neurons…",
|
||||||
|
"Convincing the AI to stop daydreaming…",
|
||||||
|
"Polishing the AI’s code goggles…",
|
||||||
|
"Asking the AI to stop reorganizing your files…",
|
||||||
|
"Feeding the AI additional coffee…",
|
||||||
|
"Teaching the AI not to delete node_modules (again)…",
|
||||||
|
"Telling the AI to act natural before you arrive…",
|
||||||
|
"Asking the AI to please stop rewriting history…",
|
||||||
|
"Letting the AI stretch before its coding sprint…",
|
||||||
|
"Persuading the AI to give you keyboard control…",
|
||||||
|
]
|
||||||
|
|
||||||
|
const phraseEl = document.getElementById("loading-phrase")
|
||||||
|
const button = document.getElementById("phrase-toggle")
|
||||||
|
const errorEl = document.getElementById("error")
|
||||||
|
|
||||||
|
function pickPhrase() {
|
||||||
|
const next = phrases[Math.floor(Math.random() * phrases.length)]
|
||||||
|
phraseEl.textContent = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(message) {
|
||||||
|
errorEl.textContent = message || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(url) {
|
||||||
|
if (!url) return
|
||||||
|
window.location.replace(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
pickPhrase()
|
||||||
|
button?.addEventListener("click", pickPhrase)
|
||||||
|
|
||||||
|
if (!window.__TAURI__ || !window.__TAURI__.event || !window.__TAURI__.invoke) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listen } = window.__TAURI__.event
|
||||||
|
const invoke = window.__TAURI__.invoke
|
||||||
|
|
||||||
|
listen("cli:ready", (event) => {
|
||||||
|
const payload = event?.payload || {}
|
||||||
|
if (payload.url) {
|
||||||
|
navigateTo(payload.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
listen("cli:error", (event) => {
|
||||||
|
const payload = event?.payload || {}
|
||||||
|
if (payload.message) {
|
||||||
|
setError(payload.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
listen("cli:status", (event) => {
|
||||||
|
const payload = event?.payload || {}
|
||||||
|
if (payload.state !== "ready") {
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await invoke("cli_get_status")
|
||||||
|
if (status?.state === "ready" && status.url) {
|
||||||
|
navigateTo(status.url)
|
||||||
|
}
|
||||||
|
if (status?.state === "error" && status.error) {
|
||||||
|
setError(status.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user