Unify the initial connection handshake and capability update paths. The server now sends a server_info status message on connect and whenever capabilities change, enabling the onboarding flow where voice becomes available after models are configured.
1079 lines
38 KiB
Rust
1079 lines
38 KiB
Rust
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use http::Request;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::{AppHandle, Emitter, Manager, State};
|
|
use tokio::sync::mpsc;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
const LOCAL_TRANSPORT_EVENT_NAME: &str = "local-daemon-transport-event";
|
|
const UNIX_CLIENT_URL: &str = "ws://localhost/ws";
|
|
#[cfg(windows)]
|
|
const PIPE_CLIENT_URL: &str = "ws://localhost/ws";
|
|
const CLI_LINK_NAME: &str = "paseo";
|
|
#[cfg(windows)]
|
|
const CLI_LINK_WINDOWS_NAME: &str = "paseo.exe";
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedRuntimeManifest {
|
|
pub runtime_id: String,
|
|
pub runtime_version: String,
|
|
pub platform: String,
|
|
pub arch: String,
|
|
pub created_at: String,
|
|
pub node_relative_path: String,
|
|
pub cli_entrypoint_relative_path: String,
|
|
pub cli_shim_relative_path: String,
|
|
pub server_runner_relative_path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BundledRuntimePointer {
|
|
runtime_id: String,
|
|
runtime_version: String,
|
|
relative_root: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedTcpSettings {
|
|
pub enabled: bool,
|
|
pub host: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedRuntimeStatus {
|
|
pub runtime_id: String,
|
|
pub runtime_version: String,
|
|
pub runtime_root: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CliDaemonStatus {
|
|
server_id: Option<String>,
|
|
status: String,
|
|
listen: String,
|
|
hostname: Option<String>,
|
|
pid: Option<i64>,
|
|
home: String,
|
|
log_path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedDaemonStatus {
|
|
pub runtime_id: String,
|
|
pub runtime_version: String,
|
|
pub server_id: String,
|
|
pub status: String,
|
|
pub listen: String,
|
|
pub hostname: Option<String>,
|
|
pub pid: Option<i64>,
|
|
pub home: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedDaemonLogs {
|
|
pub log_path: String,
|
|
pub contents: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedPairingOffer {
|
|
pub relay_enabled: bool,
|
|
pub url: Option<String>,
|
|
pub qr: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CliSymlinkInstructions {
|
|
pub title: String,
|
|
pub detail: String,
|
|
pub commands: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LocalTransportEvent {
|
|
session_id: String,
|
|
kind: String,
|
|
text: Option<String>,
|
|
binary_base64: Option<String>,
|
|
code: Option<u16>,
|
|
reason: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
struct LocalTransportSession {
|
|
sender: mpsc::UnboundedSender<Message>,
|
|
}
|
|
|
|
pub struct LocalTransportState {
|
|
next_session_id: AtomicU64,
|
|
sessions: Arc<Mutex<HashMap<String, LocalTransportSession>>>,
|
|
}
|
|
|
|
impl Default for LocalTransportState {
|
|
fn default() -> Self {
|
|
Self {
|
|
next_session_id: AtomicU64::new(1),
|
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LocalTransportState {
|
|
fn alloc_session_id(&self) -> String {
|
|
format!(
|
|
"local-session-{}",
|
|
self.next_session_id.fetch_add(1, Ordering::Relaxed)
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn build_local_websocket_request(
|
|
url: &str,
|
|
) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
|
|
url.into_client_request()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn build_local_websocket_request(
|
|
url: &str,
|
|
) -> Result<Request<()>, tokio_tungstenite::tungstenite::Error> {
|
|
url.into_client_request()
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
fn local_client_url() -> &'static str {
|
|
UNIX_CLIENT_URL
|
|
}
|
|
|
|
#[cfg(all(test, windows))]
|
|
fn local_client_url() -> &'static str {
|
|
PIPE_CLIENT_URL
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn connect_local_socket(
|
|
socket_path: PathBuf,
|
|
) -> Result<
|
|
tokio_tungstenite::WebSocketStream<tokio::net::UnixStream>,
|
|
tokio_tungstenite::tungstenite::Error,
|
|
> {
|
|
let stream = tokio::net::UnixStream::connect(socket_path)
|
|
.await
|
|
.map_err(tokio_tungstenite::tungstenite::Error::Io)?;
|
|
let request = build_local_websocket_request(UNIX_CLIENT_URL)?;
|
|
let (ws_stream, _) = tokio_tungstenite::client_async(request, stream).await?;
|
|
Ok(ws_stream)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
async fn connect_local_pipe(
|
|
pipe_path: String,
|
|
) -> Result<
|
|
tokio_tungstenite::WebSocketStream<tokio::net::windows::named_pipe::NamedPipeClient>,
|
|
tokio_tungstenite::tungstenite::Error,
|
|
> {
|
|
let stream = tokio::net::windows::named_pipe::ClientOptions::new()
|
|
.open(&pipe_path)
|
|
.map_err(tokio_tungstenite::tungstenite::Error::Io)?;
|
|
let request = build_local_websocket_request(PIPE_CLIENT_URL)?;
|
|
let (ws_stream, _) = tokio_tungstenite::client_async(request, stream).await?;
|
|
Ok(ws_stream)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn manual_http_request_lacks_websocket_handshake_headers() {
|
|
let request = Request::builder()
|
|
.uri(local_client_url())
|
|
.header("Host", "localhost")
|
|
.body(())
|
|
.expect("valid manual request");
|
|
|
|
assert!(request.headers().get("sec-websocket-key").is_none());
|
|
assert!(request.headers().get("sec-websocket-version").is_none());
|
|
assert!(request.headers().get("upgrade").is_none());
|
|
assert!(request.headers().get("connection").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn generated_local_websocket_request_includes_required_headers() {
|
|
let request = build_local_websocket_request(local_client_url())
|
|
.expect("local websocket request should be generated");
|
|
|
|
assert_eq!(request.uri().to_string(), local_client_url());
|
|
assert_eq!(
|
|
request
|
|
.headers()
|
|
.get("host")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("localhost")
|
|
);
|
|
assert!(request.headers().contains_key("sec-websocket-key"));
|
|
assert_eq!(
|
|
request
|
|
.headers()
|
|
.get("sec-websocket-version")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("13")
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers()
|
|
.get("upgrade")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("websocket")
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.headers()
|
|
.get("connection")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Upgrade")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_symlink_instructions_serialize_with_camel_case_keys() {
|
|
let value = serde_json::to_value(CliSymlinkInstructions {
|
|
title: "Add paseo to your shell".to_string(),
|
|
detail: "Create a symlink to the Paseo desktop executable.".to_string(),
|
|
commands: "sudo ...".to_string(),
|
|
})
|
|
.expect("serializes");
|
|
|
|
assert_eq!(
|
|
value.get("title").and_then(|entry| entry.as_str()),
|
|
Some("Add paseo to your shell")
|
|
);
|
|
assert_eq!(
|
|
value.get("commands").and_then(|entry| entry.as_str()),
|
|
Some("sudo ...")
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
#[ignore = "requires a running local daemon socket"]
|
|
fn connects_to_running_local_daemon_socket() {
|
|
let socket_path = std::env::var("PASEO_LOCAL_SOCKET_SMOKE_PATH")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(PathBuf::from)
|
|
.or_else(|| dirs::home_dir().map(|home| home.join(".paseo").join("paseo.sock")))
|
|
.expect("socket path should resolve");
|
|
|
|
assert!(
|
|
socket_path.exists(),
|
|
"socket path does not exist: {}",
|
|
socket_path.display()
|
|
);
|
|
|
|
tauri::async_runtime::block_on(async move {
|
|
let mut ws_stream = connect_local_socket(socket_path.clone())
|
|
.await
|
|
.unwrap_or_else(|error| {
|
|
panic!(
|
|
"local socket websocket handshake failed for {}: {error}",
|
|
socket_path.display()
|
|
)
|
|
});
|
|
ws_stream.close(None).await.expect("close websocket stream");
|
|
});
|
|
}
|
|
}
|
|
|
|
fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
|
let raw = fs::read_to_string(path)
|
|
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
|
serde_json::from_str::<T>(&raw)
|
|
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))
|
|
}
|
|
|
|
fn bundled_runtime_root_from_resource_dir(resource_dir: &Path) -> Option<PathBuf> {
|
|
for candidate in [
|
|
resource_dir.join("resources").join("managed-runtime"),
|
|
resource_dir.join("managed-runtime"),
|
|
] {
|
|
if candidate.exists() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
if let Ok(raw_resource_dir) = app.path().resource_dir() {
|
|
let resource_dir = dunce::simplified(&raw_resource_dir).to_path_buf();
|
|
if let Some(candidate) = bundled_runtime_root_from_resource_dir(&resource_dir) {
|
|
log::info!("[runtime] found bundled runtime at {}", candidate.display());
|
|
return Ok(candidate);
|
|
}
|
|
} else {
|
|
log::info!("[runtime] resource_dir() unavailable, resolving from executable path");
|
|
}
|
|
|
|
// Fallback: resolve symlinks and search relative to the real executable location.
|
|
// Handles CLI symlink invocation where Tauri can't resolve resource_dir.
|
|
if let Ok(canonical) = std::env::current_exe().and_then(|p| p.canonicalize()) {
|
|
let exe_dir = dunce::simplified(&canonical);
|
|
if let Some(exe_dir) = exe_dir.parent() {
|
|
let mut candidates = vec![exe_dir.join("resources"), exe_dir.to_path_buf()];
|
|
if let Some(contents_dir) = exe_dir.parent() {
|
|
candidates.push(contents_dir.join("Resources"));
|
|
candidates.push(contents_dir.join("resources"));
|
|
}
|
|
for resource_dir in candidates {
|
|
if let Some(found) = bundled_runtime_root_from_resource_dir(&resource_dir) {
|
|
log::info!(
|
|
"[runtime] found bundled runtime (via exe fallback) at {}",
|
|
found.display()
|
|
);
|
|
return Ok(found);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log::error!("[runtime] no managed runtime found");
|
|
Err("Managed runtime resources are not bundled with this desktop build.".to_string())
|
|
}
|
|
|
|
fn load_bundled_runtime_pointer(
|
|
app: &AppHandle,
|
|
) -> Result<(PathBuf, BundledRuntimePointer), String> {
|
|
let root = bundled_runtime_root(app)?;
|
|
let pointer_path = root.join("current-runtime.json");
|
|
let pointer = read_json_file::<BundledRuntimePointer>(&pointer_path)?;
|
|
Ok((root, pointer))
|
|
}
|
|
|
|
fn load_runtime_manifest(runtime_root: &Path) -> Result<ManagedRuntimeManifest, String> {
|
|
read_json_file::<ManagedRuntimeManifest>(&runtime_root.join("runtime-manifest.json"))
|
|
}
|
|
|
|
fn tail_log(path: &Path, max_lines: usize) -> String {
|
|
let raw = match fs::read_to_string(path) {
|
|
Ok(value) => value,
|
|
Err(_) => return String::new(),
|
|
};
|
|
let mut lines = raw.lines().rev().take(max_lines).collect::<Vec<_>>();
|
|
lines.reverse();
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn to_stdio_message(input: Option<&str>) -> String {
|
|
input.unwrap_or_default().trim().to_string()
|
|
}
|
|
|
|
fn cli_command(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
args: &[&str],
|
|
) -> Result<Command, String> {
|
|
let node = runtime_root.join(&manifest.node_relative_path);
|
|
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
|
|
if !node.exists() {
|
|
log::error!("[cli] bundled Node missing at {}", node.display());
|
|
return Err(format!(
|
|
"Bundled Node runtime is missing at {}",
|
|
node.display()
|
|
));
|
|
}
|
|
if !cli.exists() {
|
|
log::error!("[cli] bundled CLI missing at {}", cli.display());
|
|
return Err(format!(
|
|
"Bundled CLI entrypoint is missing at {}",
|
|
cli.display()
|
|
));
|
|
}
|
|
log::info!(
|
|
"[cli] node={} cli={} args={:?}",
|
|
node.display(),
|
|
cli.display(),
|
|
args
|
|
);
|
|
let mut command = Command::new(node);
|
|
command.arg(cli);
|
|
command.args(args);
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
|
}
|
|
Ok(command)
|
|
}
|
|
|
|
fn shell_single_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', r"'\''"))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn powershell_double_quote(value: &str) -> String {
|
|
value.replace('`', "``").replace('"', "`\"")
|
|
}
|
|
|
|
fn outer_cli_link_path() -> Result<PathBuf, String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
return Ok(PathBuf::from("/usr/local/bin").join(CLI_LINK_NAME));
|
|
}
|
|
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
|
{
|
|
return Ok(PathBuf::from("/usr/local/bin").join(CLI_LINK_NAME));
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let local_app_data = dirs::data_local_dir().ok_or_else(|| {
|
|
"Failed to resolve LocalAppData for CLI symlink instructions.".to_string()
|
|
})?;
|
|
Ok(local_app_data
|
|
.join("Microsoft")
|
|
.join("WinGet")
|
|
.join("Links")
|
|
.join(CLI_LINK_WINDOWS_NAME))
|
|
}
|
|
}
|
|
|
|
fn desktop_cli_source_path() -> Result<PathBuf, String> {
|
|
let current_exe = std::env::current_exe()
|
|
.map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?;
|
|
Ok(dunce::simplified(¤t_exe).to_path_buf())
|
|
}
|
|
|
|
fn cli_symlink_instructions_internal() -> Result<CliSymlinkInstructions, String> {
|
|
let outer_link = outer_cli_link_path()?;
|
|
#[cfg(windows)]
|
|
{
|
|
let desktop_executable = desktop_cli_source_path()?;
|
|
let target_dir = outer_link
|
|
.parent()
|
|
.ok_or_else(|| "CLI symlink target is missing a parent directory.".to_string())?;
|
|
return Ok(CliSymlinkInstructions {
|
|
title: "Add paseo to your shell".to_string(),
|
|
detail: "Create a symlink to the Paseo desktop executable.".to_string(),
|
|
commands: format!(
|
|
"$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\nif (Test-Path $target) {{ Remove-Item -Path $target -Force }}\nNew-Item -ItemType SymbolicLink -Path $target -Target \"{}\" | Out-Null\n",
|
|
powershell_double_quote(outer_link.to_string_lossy().as_ref()),
|
|
powershell_double_quote(target_dir.to_string_lossy().as_ref()),
|
|
powershell_double_quote(desktop_executable.to_string_lossy().as_ref())
|
|
),
|
|
});
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
let desktop_executable = desktop_cli_source_path()?;
|
|
Ok(CliSymlinkInstructions {
|
|
title: "Add paseo to your shell".to_string(),
|
|
detail: "Create a symlink to the Paseo desktop executable.".to_string(),
|
|
commands: format!(
|
|
"sudo mkdir -p {target_dir}\nsudo ln -sf {source_path} {target_path}\n",
|
|
target_dir = shell_single_quote(
|
|
outer_link
|
|
.parent()
|
|
.ok_or_else(
|
|
|| "CLI symlink target is missing a parent directory.".to_string()
|
|
)?
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
),
|
|
target_path = shell_single_quote(outer_link.to_string_lossy().as_ref()),
|
|
source_path = shell_single_quote(desktop_executable.to_string_lossy().as_ref())
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn ensure_runtime_ready_internal(app: &AppHandle) -> Result<ManagedRuntimeStatus, String> {
|
|
log::info!("[runtime] ensuring runtime is ready");
|
|
let (bundled_root, pointer) = load_bundled_runtime_pointer(app)?;
|
|
let runtime_root = bundled_root.join(&pointer.relative_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
log::info!(
|
|
"[runtime] manifest: id={} version={}",
|
|
manifest.runtime_id,
|
|
manifest.runtime_version
|
|
);
|
|
|
|
Ok(ManagedRuntimeStatus {
|
|
runtime_id: manifest.runtime_id,
|
|
runtime_version: manifest.runtime_version,
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
})
|
|
}
|
|
|
|
fn run_cli_json_command(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
args: &[&str],
|
|
) -> Result<serde_json::Value, String> {
|
|
log::info!("[cli] running: {:?}", args);
|
|
let output = cli_command(runtime_root, manifest, args)?
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| {
|
|
log::error!("[cli] failed to spawn: {error}");
|
|
format!("Failed to run bundled CLI: {error}")
|
|
})?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::error!(
|
|
"[cli] exit={} stderr={}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr.trim()
|
|
);
|
|
return Err(format!(
|
|
"Bundled CLI failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr.trim()
|
|
));
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
log::info!("[cli] success, parsing JSON output");
|
|
serde_json::from_str(stdout.trim()).map_err(|error| {
|
|
log::error!("[cli] JSON parse error: {error}; stdout={}", stdout.trim());
|
|
format!(
|
|
"Failed to parse bundled CLI JSON output: {error}; stdout={}",
|
|
stdout.trim()
|
|
)
|
|
})
|
|
}
|
|
|
|
fn run_cli_passthrough_command(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
args: &[String],
|
|
) -> Result<i32, String> {
|
|
log::info!("[cli] passthrough: {:?}", args);
|
|
let status = cli_command(runtime_root, manifest, &[])?
|
|
.args(args)
|
|
.status()
|
|
.map_err(|error| format!("Failed to run bundled CLI: {error}"))?;
|
|
Ok(status.code().unwrap_or(1))
|
|
}
|
|
|
|
fn managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let value = run_cli_json_command(&runtime_root, &manifest, &["daemon", "status", "--json"])?;
|
|
let daemon_status: CliDaemonStatus = serde_json::from_value(value)
|
|
.map_err(|error| format!("Failed to parse managed daemon status: {error}"))?;
|
|
|
|
Ok(ManagedDaemonStatus {
|
|
runtime_id: manifest.runtime_id,
|
|
runtime_version: manifest.runtime_version,
|
|
server_id: daemon_status.server_id.unwrap_or_default(),
|
|
status: daemon_status.status,
|
|
listen: daemon_status.listen,
|
|
hostname: daemon_status.hostname,
|
|
pid: daemon_status.pid,
|
|
home: daemon_status.home,
|
|
})
|
|
}
|
|
|
|
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
let t0 = std::time::Instant::now();
|
|
log::info!("[daemon] starting managed daemon");
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
log::info!("[daemon] runtime ready ({}ms)", t0.elapsed().as_millis());
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let output = cli_command(&runtime_root, &manifest, &["start"])?
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| {
|
|
log::error!("[daemon] failed to spawn: {error}");
|
|
format!("Failed to launch managed daemon: {error}")
|
|
})?;
|
|
if !output.status.success() {
|
|
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
|
|
log::error!(
|
|
"[daemon] start failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
);
|
|
return Err(format!(
|
|
"Managed daemon start failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
));
|
|
}
|
|
log::info!("[daemon] start command succeeded ({}ms), waiting for daemon to be ready", t0.elapsed().as_millis());
|
|
for attempt in 0..150 {
|
|
let daemon_status = managed_daemon_status_internal(app)?;
|
|
log::info!(
|
|
"[daemon] poll attempt {}: status={:?} server_id={:?} listen={:?} pid={:?}",
|
|
attempt + 1,
|
|
daemon_status.status,
|
|
daemon_status.server_id,
|
|
daemon_status.listen,
|
|
daemon_status.pid
|
|
);
|
|
if daemon_status.status == "running" && !daemon_status.server_id.trim().is_empty() {
|
|
log::info!(
|
|
"[daemon] ready after {} attempts, {}ms (pid={:?})",
|
|
attempt + 1,
|
|
t0.elapsed().as_millis(),
|
|
daemon_status.pid
|
|
);
|
|
return Ok(daemon_status);
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
}
|
|
log::warn!("[daemon] timed out waiting for daemon to become ready ({}ms)", t0.elapsed().as_millis());
|
|
managed_daemon_status_internal(app)
|
|
}
|
|
|
|
fn stop_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
log::info!("[daemon] stopping managed daemon");
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let output = cli_command(&runtime_root, &manifest, &["daemon", "stop", "--json"])?
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| {
|
|
log::error!("[daemon] failed to spawn stop command: {error}");
|
|
format!("Failed to stop managed daemon: {error}")
|
|
})?;
|
|
if !output.status.success() {
|
|
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
|
|
log::error!(
|
|
"[daemon] stop failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
);
|
|
return Err(format!(
|
|
"Managed daemon stop failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
));
|
|
}
|
|
log::info!("[daemon] stop command succeeded");
|
|
managed_daemon_status_internal(app)
|
|
}
|
|
|
|
fn restart_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
log::info!("[daemon] restarting managed daemon");
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let output = cli_command(&runtime_root, &manifest, &["daemon", "restart", "--json"])?
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| {
|
|
log::error!("[daemon] failed to spawn restart command: {error}");
|
|
format!("Failed to restart managed daemon: {error}")
|
|
})?;
|
|
if !output.status.success() {
|
|
let stderr = to_stdio_message(Some(&String::from_utf8_lossy(&output.stderr)));
|
|
log::error!(
|
|
"[daemon] restart failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
);
|
|
return Err(format!(
|
|
"Managed daemon restart failed (exit {}): {}",
|
|
output.status.code().unwrap_or(-1),
|
|
stderr
|
|
));
|
|
}
|
|
log::info!("[daemon] restart command succeeded");
|
|
managed_daemon_status_internal(app)
|
|
}
|
|
|
|
fn update_managed_tcp_settings_internal(
|
|
app: &AppHandle,
|
|
_settings: ManagedTcpSettings,
|
|
) -> Result<ManagedDaemonStatus, String> {
|
|
let _ = app;
|
|
Err("Managed daemon TCP settings are no longer configurable from desktop.".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn managed_runtime_status(app: AppHandle) -> Result<ManagedRuntimeStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || ensure_runtime_ready_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("Managed runtime status task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn managed_daemon_status(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || managed_daemon_status_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("Managed daemon status task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn start_managed_daemon(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || start_managed_daemon_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("Managed daemon start task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_managed_daemon(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || stop_managed_daemon_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("Managed daemon stop task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn restart_managed_daemon(app: AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || restart_managed_daemon_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("Managed daemon restart task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cli_symlink_instructions(app: AppHandle) -> Result<CliSymlinkInstructions, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let _ = app;
|
|
cli_symlink_instructions_internal()
|
|
})
|
|
.await
|
|
.map_err(|error| format!("CLI symlink instructions task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let runtime_status = ensure_runtime_ready_internal(&app)?;
|
|
let runtime_root = PathBuf::from(&runtime_status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let value =
|
|
run_cli_json_command(&runtime_root, &manifest, &["daemon", "status", "--json"])?;
|
|
let daemon_status: CliDaemonStatus = serde_json::from_value(value)
|
|
.map_err(|error| format!("Failed to parse managed daemon status for logs: {error}"))?;
|
|
let log_path = PathBuf::from(&daemon_status.log_path);
|
|
Ok(ManagedDaemonLogs {
|
|
log_path: log_path.to_string_lossy().into_owned(),
|
|
contents: tail_log(&log_path, 400),
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Managed daemon logs task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn managed_daemon_pairing(app: AppHandle) -> Result<ManagedPairingOffer, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let status = ensure_runtime_ready_internal(&app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let value = run_cli_json_command(&runtime_root, &manifest, &["daemon", "pair", "--json"])?;
|
|
serde_json::from_value::<ManagedPairingOffer>(value)
|
|
.map_err(|error| format!("Failed to parse managed pairing offer: {error}"))
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Managed daemon pairing task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_managed_daemon_tcp_settings(
|
|
app: AppHandle,
|
|
settings: ManagedTcpSettings,
|
|
) -> Result<ManagedDaemonStatus, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
update_managed_tcp_settings_internal(&app, settings)
|
|
})
|
|
.await
|
|
.map_err(|error| format!("Managed daemon TCP settings task failed: {error}"))?
|
|
}
|
|
|
|
pub fn run_managed_cli_from_current_process(args: Vec<String>) -> Result<i32, String> {
|
|
let current_exe = std::env::current_exe()
|
|
.and_then(|p| p.canonicalize())
|
|
.map_err(|error| format!("Failed to resolve desktop executable path: {error}"))?;
|
|
let current_exe = dunce::simplified(¤t_exe).to_path_buf();
|
|
let exe_dir = current_exe
|
|
.parent()
|
|
.ok_or_else(|| "Desktop executable path is missing a parent directory.".to_string())?;
|
|
let mut resource_dirs = vec![exe_dir.join("resources"), exe_dir.to_path_buf()];
|
|
if let Some(contents_dir) = exe_dir.parent() {
|
|
resource_dirs.push(contents_dir.join("Resources"));
|
|
resource_dirs.push(contents_dir.join("resources"));
|
|
}
|
|
let bundled_root = resource_dirs
|
|
.into_iter()
|
|
.find_map(|resource_dir| bundled_runtime_root_from_resource_dir(&resource_dir))
|
|
.ok_or_else(|| {
|
|
"Managed runtime resources are not bundled with this desktop build.".to_string()
|
|
})?;
|
|
let pointer =
|
|
read_json_file::<BundledRuntimePointer>(&bundled_root.join("current-runtime.json"))?;
|
|
let runtime_root = bundled_root.join(pointer.relative_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
run_cli_passthrough_command(&runtime_root, &manifest, &args)
|
|
}
|
|
|
|
async fn spawn_local_transport_session<S>(
|
|
app: AppHandle,
|
|
transport_state: State<'_, LocalTransportState>,
|
|
session_id: String,
|
|
ws_stream: tokio_tungstenite::WebSocketStream<S>,
|
|
) -> Result<String, String>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
|
{
|
|
let (mut write, mut read) = ws_stream.split();
|
|
let (sender, mut receiver) = mpsc::unbounded_channel::<Message>();
|
|
transport_state
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "Local transport session lock poisoned.".to_string())?
|
|
.insert(session_id.clone(), LocalTransportSession { sender });
|
|
|
|
let app_for_read = app.clone();
|
|
let app_for_write = app.clone();
|
|
let sessions_for_read = Arc::clone(&transport_state.sessions);
|
|
let read_session_id = session_id.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
let _ = app_for_read.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: read_session_id.clone(),
|
|
kind: "open".to_string(),
|
|
text: None,
|
|
binary_base64: None,
|
|
code: None,
|
|
reason: None,
|
|
error: None,
|
|
},
|
|
);
|
|
|
|
while let Some(message) = read.next().await {
|
|
match message {
|
|
Ok(Message::Text(text)) => {
|
|
let _ = app_for_read.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: read_session_id.clone(),
|
|
kind: "message".to_string(),
|
|
text: Some(text.to_string()),
|
|
binary_base64: None,
|
|
code: None,
|
|
reason: None,
|
|
error: None,
|
|
},
|
|
);
|
|
}
|
|
Ok(Message::Binary(bytes)) => {
|
|
let _ = app_for_read.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: read_session_id.clone(),
|
|
kind: "message".to_string(),
|
|
text: None,
|
|
binary_base64: Some(BASE64_STANDARD.encode(bytes)),
|
|
code: None,
|
|
reason: None,
|
|
error: None,
|
|
},
|
|
);
|
|
}
|
|
Ok(Message::Close(frame)) => {
|
|
let _ = app_for_read.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: read_session_id.clone(),
|
|
kind: "close".to_string(),
|
|
text: None,
|
|
binary_base64: None,
|
|
code: frame.as_ref().map(|value| value.code.into()),
|
|
reason: frame.as_ref().map(|value| value.reason.to_string()),
|
|
error: None,
|
|
},
|
|
);
|
|
break;
|
|
}
|
|
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
|
|
Ok(Message::Frame(_)) => {}
|
|
Err(error) => {
|
|
let _ = app_for_read.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: read_session_id.clone(),
|
|
kind: "error".to_string(),
|
|
text: None,
|
|
binary_base64: None,
|
|
code: None,
|
|
reason: None,
|
|
error: Some(error.to_string()),
|
|
},
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(mut sessions) = sessions_for_read.lock() {
|
|
sessions.remove(&read_session_id);
|
|
}
|
|
});
|
|
|
|
let sessions_for_write = Arc::clone(&transport_state.sessions);
|
|
let write_session_id = session_id.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
while let Some(message) = receiver.recv().await {
|
|
if write.send(message).await.is_err() {
|
|
let _ = app_for_write.emit(
|
|
LOCAL_TRANSPORT_EVENT_NAME,
|
|
LocalTransportEvent {
|
|
session_id: write_session_id.clone(),
|
|
kind: "error".to_string(),
|
|
text: None,
|
|
binary_base64: None,
|
|
code: None,
|
|
reason: None,
|
|
error: Some("Local transport write failed.".to_string()),
|
|
},
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
if let Ok(mut sessions) = sessions_for_write.lock() {
|
|
sessions.remove(&write_session_id);
|
|
}
|
|
});
|
|
|
|
Ok(session_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn open_local_daemon_transport(
|
|
app: AppHandle,
|
|
transport_state: State<'_, LocalTransportState>,
|
|
transport_type: String,
|
|
transport_path: String,
|
|
) -> Result<String, String> {
|
|
let session_id = transport_state.alloc_session_id();
|
|
log::info!(
|
|
"[transport] opening session {} type={} path={}",
|
|
session_id,
|
|
transport_type,
|
|
transport_path
|
|
);
|
|
let _ = app;
|
|
match transport_type.as_str() {
|
|
"pipe" => {
|
|
#[cfg(windows)]
|
|
{
|
|
let ws_stream = connect_local_pipe(transport_path)
|
|
.await
|
|
.map_err(|error| format!("Failed to connect to local daemon pipe: {error}"))?;
|
|
spawn_local_transport_session(app, transport_state, session_id, ws_stream).await
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
Err(tokio_tungstenite::tungstenite::Error::Io(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"Local pipe transport is only available on Windows.",
|
|
))
|
|
.to_string())
|
|
}
|
|
}
|
|
"socket" => {
|
|
#[cfg(unix)]
|
|
{
|
|
let ws_stream = connect_local_socket(PathBuf::from(transport_path))
|
|
.await
|
|
.map_err(|error| {
|
|
format!("Failed to connect to local daemon socket: {error}")
|
|
})?;
|
|
spawn_local_transport_session(app, transport_state, session_id, ws_stream).await
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
Err(tokio_tungstenite::tungstenite::Error::Io(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"Local socket transport is only available on Unix platforms.",
|
|
))
|
|
.to_string())
|
|
}
|
|
}
|
|
other => Err(format!("Unsupported local transport type: {other}")),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn send_local_daemon_transport_message(
|
|
transport_state: State<'_, LocalTransportState>,
|
|
session_id: String,
|
|
text: Option<String>,
|
|
binary_base64: Option<String>,
|
|
) -> Result<(), String> {
|
|
let sessions = transport_state
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "Local transport session lock poisoned.".to_string())?;
|
|
let session = sessions
|
|
.get(&session_id)
|
|
.ok_or_else(|| format!("Local transport session not found: {session_id}"))?;
|
|
if let Some(text) = text {
|
|
session
|
|
.sender
|
|
.send(Message::Text(text.into()))
|
|
.map_err(|_| "Local transport session is closed.".to_string())?;
|
|
return Ok(());
|
|
}
|
|
if let Some(binary_base64) = binary_base64 {
|
|
let bytes = BASE64_STANDARD
|
|
.decode(binary_base64.as_bytes())
|
|
.map_err(|error| format!("Failed to decode local transport payload: {error}"))?;
|
|
session
|
|
.sender
|
|
.send(Message::Binary(bytes.into()))
|
|
.map_err(|_| "Local transport session is closed.".to_string())?;
|
|
return Ok(());
|
|
}
|
|
Err("Local transport send requires text or binary payload.".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn close_local_daemon_transport(
|
|
transport_state: State<'_, LocalTransportState>,
|
|
session_id: String,
|
|
) -> Result<(), String> {
|
|
let mut sessions = transport_state
|
|
.sessions
|
|
.lock()
|
|
.map_err(|_| "Local transport session lock poisoned.".to_string())?;
|
|
let session = sessions
|
|
.remove(&session_id)
|
|
.ok_or_else(|| format!("Local transport session not found: {session_id}"))?;
|
|
session
|
|
.sender
|
|
.send(Message::Close(None))
|
|
.map_err(|_| "Local transport session is already closed.".to_string())?;
|
|
Ok(())
|
|
}
|