- Add .claude schedule tasks to .gitignore - Add screen-orientation polyfill for Expo web - Refactor agent provider launch config into shared utility - Update desktop runtime manager with improved binary management - Update desktop release workflow
1805 lines
62 KiB
Rust
1805 lines
62 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::env;
|
|
#[cfg(windows)]
|
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
|
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 MANAGED_STATE_FILE: &str = "managed-state.json";
|
|
const DEFAULT_MANAGED_HOME_BASENAME: &str = ".paseo";
|
|
const DEFAULT_MANAGED_HOME_DIRNAME: &str = "managed-home";
|
|
#[cfg(not(windows))]
|
|
const SHORT_SOCKET_FILENAME: &str = "paseo.sock";
|
|
const UNIX_CLIENT_URL: &str = "ws://localhost/ws";
|
|
#[cfg(windows)]
|
|
const PIPE_CLIENT_URL: &str = "ws://localhost/ws";
|
|
#[cfg(windows)]
|
|
const PIPE_PREFIX: &str = r"\\.\pipe\";
|
|
const DEFAULT_MANAGED_TCP_HOST: &str = "127.0.0.1";
|
|
const DEFAULT_MANAGED_TCP_PORT: u16 = 7771;
|
|
const CLI_SHIM_NAME: &str = "paseo";
|
|
#[cfg(windows)]
|
|
const CLI_SHIM_WINDOWS_NAME: &str = "paseo.cmd";
|
|
#[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")]
|
|
struct ManagedStateFile {
|
|
runtime_id: String,
|
|
runtime_root: String,
|
|
managed_home: String,
|
|
transport_type: String,
|
|
transport_path: String,
|
|
tcp_enabled: bool,
|
|
tcp_listen: Option<String>,
|
|
cli_shim_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct PidFile {
|
|
pid: Option<i64>,
|
|
hostname: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ManagedPaths {
|
|
managed_home: PathBuf,
|
|
transport_path: PathBuf,
|
|
logs_path: PathBuf,
|
|
state_file_path: PathBuf,
|
|
diagnostics_root: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ManagedTransportTarget {
|
|
transport_type: String,
|
|
transport_path: 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,
|
|
pub managed_home: String,
|
|
pub transport_type: String,
|
|
pub transport_path: String,
|
|
pub diagnostics_root: String,
|
|
pub state_file_path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ManagedDaemonStatus {
|
|
pub runtime_id: String,
|
|
pub runtime_version: String,
|
|
pub runtime_root: String,
|
|
pub managed_home: String,
|
|
pub transport_type: String,
|
|
pub transport_path: String,
|
|
pub daemon_pid: Option<i64>,
|
|
pub daemon_running: bool,
|
|
pub daemon_status: String,
|
|
pub log_path: String,
|
|
pub server_id: Option<String>,
|
|
pub hostname: Option<String>,
|
|
pub relay_enabled: bool,
|
|
pub tcp_enabled: bool,
|
|
pub tcp_listen: Option<String>,
|
|
pub cli_shim_path: Option<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 CliShimResult {
|
|
pub status: String,
|
|
pub installed: bool,
|
|
pub path: Option<String>,
|
|
pub message: String,
|
|
pub manual_instructions: Option<CliManualInstructions>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CliManualInstructions {
|
|
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_shim_result_serializes_manual_instructions_with_camel_case_keys() {
|
|
let value = serde_json::to_value(CliShimResult {
|
|
status: "manualInstallRequired".to_string(),
|
|
installed: false,
|
|
path: Some("/usr/local/bin/paseo".to_string()),
|
|
message: "Manual install required.".to_string(),
|
|
manual_instructions: Some(CliManualInstructions {
|
|
title: "Install from Terminal".to_string(),
|
|
detail: "Run the commands below.".to_string(),
|
|
commands: "sudo ...".to_string(),
|
|
}),
|
|
})
|
|
.expect("serializes");
|
|
|
|
assert_eq!(
|
|
value.get("status").and_then(|entry| entry.as_str()),
|
|
Some("manualInstallRequired")
|
|
);
|
|
assert_eq!(
|
|
value
|
|
.get("manualInstructions")
|
|
.and_then(|entry| entry.get("commands"))
|
|
.and_then(|entry| entry.as_str()),
|
|
Some("sudo ...")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_shim_contents_runs_bundled_runtime_in_place() {
|
|
let runtime_root =
|
|
PathBuf::from("/Applications/Paseo.app/Contents/Resources/managed-runtime/runtime-1");
|
|
let paths = ManagedPaths {
|
|
managed_home: PathBuf::from("/Users/me/.paseo"),
|
|
transport_path: PathBuf::from("/Users/me/.paseo/paseo.sock"),
|
|
logs_path: PathBuf::from("/Users/me/.paseo/daemon.log"),
|
|
state_file_path: PathBuf::from(
|
|
"/Users/me/Library/Application Support/Paseo/managed-state.json",
|
|
),
|
|
diagnostics_root: PathBuf::from("/Users/me/Library/Application Support/Paseo"),
|
|
};
|
|
let manifest = ManagedRuntimeManifest {
|
|
runtime_id: "runtime-1".to_string(),
|
|
runtime_version: "0.1.0".to_string(),
|
|
platform: "darwin".to_string(),
|
|
arch: "arm64".to_string(),
|
|
created_at: "2025-01-01T00:00:00.000Z".to_string(),
|
|
node_relative_path: "node/node".to_string(),
|
|
cli_entrypoint_relative_path: "node_modules/@getpaseo/cli/dist/index.js".to_string(),
|
|
cli_shim_relative_path: "node_modules/@getpaseo/cli/bin/paseo".to_string(),
|
|
server_runner_relative_path:
|
|
"node_modules/@getpaseo/server/dist/scripts/daemon-runner.js".to_string(),
|
|
};
|
|
|
|
let contents = cli_shim_contents(&runtime_root, &manifest, &paths);
|
|
|
|
assert!(contents.contains("export PASEO_HOME='/Users/me/.paseo'"));
|
|
assert!(contents.contains("Contents/Resources/managed-runtime/runtime-1/node/node"));
|
|
assert!(contents.contains("node_modules/@getpaseo/cli/dist/index.js"));
|
|
assert!(!contents.contains("--paseo-cli-shim"));
|
|
}
|
|
|
|
#[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(DEFAULT_MANAGED_HOME_BASENAME)
|
|
.join(SHORT_SOCKET_FILENAME)
|
|
})
|
|
})
|
|
.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 write_json_file<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
|
let raw = serde_json::to_string_pretty(value)
|
|
.map_err(|error| format!("Failed to serialize {}: {error}", path.display()))?;
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
|
}
|
|
fs::write(path, format!("{raw}\n"))
|
|
.map_err(|error| format!("Failed to write {}: {error}", path.display()))
|
|
}
|
|
|
|
fn app_data_root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
app.path()
|
|
.app_data_dir()
|
|
.map_err(|error| format!("Failed to resolve app data directory: {error}"))
|
|
}
|
|
|
|
fn resolve_test_root() -> Option<PathBuf> {
|
|
std::env::var("PASEO_DESKTOP_TEST_ROOT")
|
|
.ok()
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
fn resolve_override_path(name: &str) -> Option<PathBuf> {
|
|
std::env::var(name).ok().map(PathBuf::from)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn current_username() -> String {
|
|
std::env::var("USER")
|
|
.ok()
|
|
.or_else(|| std::env::var("USERNAME").ok())
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| "user".to_string())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn hash_seed(value: &str) -> String {
|
|
let mut hasher = DefaultHasher::new();
|
|
value.hash(&mut hasher);
|
|
format!("{:016x}", hasher.finish())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn build_windows_pipe_path(seed: &str) -> String {
|
|
let user = current_username()
|
|
.chars()
|
|
.map(|character| {
|
|
if character.is_ascii_alphanumeric() {
|
|
character.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect::<String>();
|
|
format!("{PIPE_PREFIX}paseo-managed-{user}-{}", hash_seed(seed))
|
|
}
|
|
|
|
fn default_transport_type() -> &'static str {
|
|
#[cfg(windows)]
|
|
{
|
|
"pipe"
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
"socket"
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn default_managed_home() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join(DEFAULT_MANAGED_HOME_BASENAME)
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn default_managed_home(root: &Path) -> PathBuf {
|
|
root.join(DEFAULT_MANAGED_HOME_DIRNAME)
|
|
}
|
|
|
|
fn default_transport_path(managed_home: &Path, diagnostics_root: &Path) -> PathBuf {
|
|
#[cfg(windows)]
|
|
{
|
|
PathBuf::from(build_windows_pipe_path(
|
|
diagnostics_root.to_string_lossy().as_ref(),
|
|
))
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let _ = diagnostics_root;
|
|
managed_home.join(SHORT_SOCKET_FILENAME)
|
|
}
|
|
#[cfg(all(not(windows), not(target_os = "macos")))]
|
|
{
|
|
let _ = diagnostics_root;
|
|
managed_home.join(SHORT_SOCKET_FILENAME)
|
|
}
|
|
}
|
|
|
|
fn resolve_paths(app: &AppHandle) -> Result<ManagedPaths, String> {
|
|
if let Some(test_root) = resolve_test_root() {
|
|
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME")
|
|
.unwrap_or_else(|| test_root.join(DEFAULT_MANAGED_HOME_DIRNAME));
|
|
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
|
|
.unwrap_or_else(|| default_transport_path(&managed_home, &test_root));
|
|
return Ok(ManagedPaths {
|
|
managed_home: managed_home.clone(),
|
|
transport_path,
|
|
logs_path: managed_home.join("daemon.log"),
|
|
state_file_path: test_root.join(MANAGED_STATE_FILE),
|
|
diagnostics_root: test_root,
|
|
});
|
|
}
|
|
|
|
let root = app_data_root(app)?;
|
|
let managed_home = resolve_override_path("PASEO_DESKTOP_MANAGED_HOME").unwrap_or_else(|| {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
default_managed_home()
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
default_managed_home(&root)
|
|
}
|
|
});
|
|
let transport_path = resolve_override_path("PASEO_DESKTOP_MANAGED_SOCKET_PATH")
|
|
.unwrap_or_else(|| default_transport_path(&managed_home, &root));
|
|
Ok(ManagedPaths {
|
|
managed_home: managed_home.clone(),
|
|
transport_path,
|
|
logs_path: managed_home.join("daemon.log"),
|
|
state_file_path: root.join(MANAGED_STATE_FILE),
|
|
diagnostics_root: root,
|
|
})
|
|
}
|
|
|
|
fn dev_resource_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources")
|
|
}
|
|
|
|
fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
if let Ok(resource_dir) = app.path().resource_dir() {
|
|
let candidate = resource_dir.join("managed-runtime");
|
|
if candidate.exists() {
|
|
log::info!("[runtime] found bundled runtime at {}", candidate.display());
|
|
return Ok(candidate);
|
|
}
|
|
log::info!(
|
|
"[runtime] no bundled runtime at {}, checking dev path",
|
|
candidate.display()
|
|
);
|
|
}
|
|
let dev = dev_resource_root().join("managed-runtime");
|
|
if dev.exists() {
|
|
log::info!("[runtime] using dev runtime at {}", dev.display());
|
|
return Ok(dev);
|
|
}
|
|
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 read_server_id(managed_home: &Path) -> Option<String> {
|
|
let raw = fs::read_to_string(managed_home.join("server-id")).ok()?;
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
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 is_pid_running(pid: i32) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
let output = Command::new("kill")
|
|
.arg("-0")
|
|
.arg(pid.to_string())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
return output.map(|status| status.success()).unwrap_or(false);
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = pid;
|
|
false
|
|
}
|
|
}
|
|
|
|
fn to_stdio_message(input: Option<&str>) -> String {
|
|
input.unwrap_or_default().trim().to_string()
|
|
}
|
|
|
|
fn parse_tcp_listen(listen: &str) -> Result<(String, u16), String> {
|
|
let trimmed = listen.trim();
|
|
let (host, port_raw) = trimmed
|
|
.rsplit_once(':')
|
|
.ok_or_else(|| "Managed TCP listen target must be host:port.".to_string())?;
|
|
let host = host.trim();
|
|
if host.is_empty() {
|
|
return Err("Managed TCP host cannot be empty.".to_string());
|
|
}
|
|
let port = port_raw
|
|
.trim()
|
|
.parse::<u16>()
|
|
.map_err(|_| "Managed TCP port must be a valid integer.".to_string())?;
|
|
if port == 0 {
|
|
return Err("Managed TCP port must be greater than 0.".to_string());
|
|
}
|
|
if port == 6767 {
|
|
return Err("Managed TCP mode cannot use port 6767.".to_string());
|
|
}
|
|
Ok((host.to_string(), port))
|
|
}
|
|
|
|
fn resolve_tcp_settings_from_state(state: Option<&ManagedStateFile>) -> ManagedTcpSettings {
|
|
let listen = state
|
|
.and_then(|value| value.tcp_listen.clone())
|
|
.unwrap_or_else(|| format!("{DEFAULT_MANAGED_TCP_HOST}:{DEFAULT_MANAGED_TCP_PORT}"));
|
|
let (host, port) = parse_tcp_listen(&listen).unwrap_or((
|
|
DEFAULT_MANAGED_TCP_HOST.to_string(),
|
|
DEFAULT_MANAGED_TCP_PORT,
|
|
));
|
|
ManagedTcpSettings {
|
|
enabled: state.map(|value| value.tcp_enabled).unwrap_or(false),
|
|
host,
|
|
port,
|
|
}
|
|
}
|
|
|
|
fn managed_transport_target(
|
|
paths: &ManagedPaths,
|
|
state: Option<&ManagedStateFile>,
|
|
) -> Result<ManagedTransportTarget, String> {
|
|
let tcp_settings = resolve_tcp_settings_from_state(state);
|
|
if tcp_settings.enabled {
|
|
return Ok(ManagedTransportTarget {
|
|
transport_type: "tcp".to_string(),
|
|
transport_path: format!("{}:{}", tcp_settings.host, tcp_settings.port),
|
|
});
|
|
}
|
|
Ok(ManagedTransportTarget {
|
|
transport_type: default_transport_type().to_string(),
|
|
transport_path: paths.transport_path.to_string_lossy().into_owned(),
|
|
})
|
|
}
|
|
|
|
fn cli_host_for_target(target: &ManagedTransportTarget) -> String {
|
|
match target.transport_type.as_str() {
|
|
"tcp" => target.transport_path.clone(),
|
|
"pipe" => format!("pipe://{}", target.transport_path),
|
|
_ => format!("unix://{}", target.transport_path),
|
|
}
|
|
}
|
|
|
|
fn cli_env(paths: &ManagedPaths) -> Vec<(String, String)> {
|
|
let state = read_state_file(&paths.state_file_path);
|
|
let target =
|
|
managed_transport_target(paths, state.as_ref()).unwrap_or(ManagedTransportTarget {
|
|
transport_type: default_transport_type().to_string(),
|
|
transport_path: paths.transport_path.to_string_lossy().into_owned(),
|
|
});
|
|
vec![
|
|
(
|
|
"PASEO_HOME".to_string(),
|
|
paths.managed_home.to_string_lossy().into_owned(),
|
|
),
|
|
("PASEO_HOST".to_string(), cli_host_for_target(&target)),
|
|
]
|
|
}
|
|
|
|
fn cli_command(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
args: &[&str],
|
|
paths: &ManagedPaths,
|
|
) -> 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);
|
|
for (key, value) in cli_env(paths) {
|
|
command.env(key, value);
|
|
}
|
|
Ok(command)
|
|
}
|
|
|
|
fn shell_single_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', r"'\''"))
|
|
}
|
|
|
|
fn escape_applescript_string(value: &str) -> String {
|
|
value.replace('\\', r"\\").replace('"', "\\\"")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn powershell_double_quote(value: &str) -> String {
|
|
value.replace('`', "``").replace('"', "`\"")
|
|
}
|
|
|
|
fn outer_cli_shim_path() -> Result<PathBuf, String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME));
|
|
}
|
|
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
|
{
|
|
return Ok(PathBuf::from("/usr/local/bin").join(CLI_SHIM_NAME));
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let local_app_data = dirs::data_local_dir().ok_or_else(|| {
|
|
"Failed to resolve LocalAppData for CLI shim instructions.".to_string()
|
|
})?;
|
|
Ok(local_app_data
|
|
.join("Microsoft")
|
|
.join("WinGet")
|
|
.join("Links")
|
|
.join(CLI_SHIM_WINDOWS_NAME))
|
|
}
|
|
}
|
|
|
|
fn cli_shim_contents(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
paths: &ManagedPaths,
|
|
) -> String {
|
|
let node = runtime_root.join(&manifest.node_relative_path);
|
|
let cli = runtime_root.join(&manifest.cli_entrypoint_relative_path);
|
|
#[cfg(windows)]
|
|
{
|
|
return format!(
|
|
"@echo off\r\nset \"PASEO_HOME={}\"\r\n\"{}\" \"{}\" %*\r\n",
|
|
paths.managed_home.display(),
|
|
node.display(),
|
|
cli.display()
|
|
);
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
format!(
|
|
"#!/bin/sh\nexport PASEO_HOME={}\nexec \"{}\" \"{}\" \"$@\"\n",
|
|
shell_single_quote(paths.managed_home.to_string_lossy().as_ref()),
|
|
node.display(),
|
|
cli.display()
|
|
)
|
|
}
|
|
}
|
|
|
|
fn write_cli_launcher(path: &Path, contents: &str) -> Result<(), String> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
|
}
|
|
fs::write(path, contents)
|
|
.map_err(|error| format!("Failed to write {}: {error}", path.display()))?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
|
.map_err(|error| format!("Failed to chmod {}: {error}", path.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cli_manual_instructions(
|
|
runtime_root: &Path,
|
|
paths: &ManagedPaths,
|
|
manifest: &ManagedRuntimeManifest,
|
|
) -> Result<CliManualInstructions, String> {
|
|
let outer_shim = outer_cli_shim_path()?;
|
|
let contents = cli_shim_contents(runtime_root, manifest, paths);
|
|
#[cfg(windows)]
|
|
{
|
|
let target_dir = outer_shim
|
|
.parent()
|
|
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?;
|
|
return Ok(CliManualInstructions {
|
|
title: "Install the Paseo CLI from PowerShell".to_string(),
|
|
detail: "If the automatic install does not complete, run these commands in PowerShell to write the PATH shim manually.".to_string(),
|
|
commands: format!(
|
|
"$target = \"{}\"\nNew-Item -ItemType Directory -Force -Path \"{}\" | Out-Null\n$contents = @\"\n{}\"@\nSet-Content -Path $target -Value $contents -Encoding ASCII\n",
|
|
powershell_double_quote(outer_shim.to_string_lossy().as_ref()),
|
|
powershell_double_quote(target_dir.to_string_lossy().as_ref()),
|
|
contents
|
|
),
|
|
});
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
Ok(CliManualInstructions {
|
|
title: "Install the Paseo CLI from Terminal".to_string(),
|
|
detail: "If the automatic install does not complete, run these commands in Terminal to write the global PATH shim manually.".to_string(),
|
|
commands: format!(
|
|
"sudo mkdir -p {target_dir}\nprintf '%s\\n' {lines} | sudo tee {target_path} >/dev/null\nsudo chmod 755 {target_path}\n",
|
|
target_dir = shell_single_quote(
|
|
outer_shim
|
|
.parent()
|
|
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
),
|
|
target_path = shell_single_quote(outer_shim.to_string_lossy().as_ref()),
|
|
lines = contents
|
|
.lines()
|
|
.map(|line| shell_single_quote(line))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn detect_installed_cli_shim_path(
|
|
runtime_root: &Path,
|
|
paths: &ManagedPaths,
|
|
manifest: &ManagedRuntimeManifest,
|
|
) -> Option<String> {
|
|
let outer_shim = outer_cli_shim_path().ok()?;
|
|
let expected = cli_shim_contents(runtime_root, manifest, paths);
|
|
let actual = fs::read_to_string(&outer_shim).ok()?;
|
|
(actual == expected).then(|| outer_shim.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn read_pid_from_home(managed_home: &Path) -> Option<i64> {
|
|
read_json_file::<PidFile>(&managed_home.join("paseo.pid"))
|
|
.ok()
|
|
.and_then(|parsed| parsed.pid)
|
|
}
|
|
|
|
fn read_hostname_from_home(managed_home: &Path) -> Option<String> {
|
|
read_json_file::<PidFile>(&managed_home.join("paseo.pid"))
|
|
.ok()
|
|
.and_then(|parsed| parsed.hostname)
|
|
}
|
|
|
|
fn read_state_file(path: &Path) -> Option<ManagedStateFile> {
|
|
read_json_file::<ManagedStateFile>(path).ok()
|
|
}
|
|
|
|
fn write_state_file(path: &Path, value: &ManagedStateFile) -> Result<(), String> {
|
|
write_json_file(path, value)
|
|
}
|
|
|
|
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 paths = resolve_paths(app)?;
|
|
log::info!(
|
|
"[runtime] runtime_root={} managed_home={} transport={}",
|
|
runtime_root.display(),
|
|
paths.managed_home.display(),
|
|
paths.transport_path.display()
|
|
);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
log::info!(
|
|
"[runtime] manifest: id={} version={}",
|
|
manifest.runtime_id,
|
|
manifest.runtime_version
|
|
);
|
|
if let Some(parent) = paths.transport_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
|
}
|
|
fs::create_dir_all(&paths.managed_home)
|
|
.map_err(|error| format!("Failed to create {}: {error}", paths.managed_home.display()))?;
|
|
let existing_state = read_state_file(&paths.state_file_path);
|
|
let target = managed_transport_target(&paths, existing_state.as_ref())?;
|
|
log::info!(
|
|
"[runtime] transport target: type={} path={}",
|
|
target.transport_type,
|
|
target.transport_path
|
|
);
|
|
let state = ManagedStateFile {
|
|
runtime_id: manifest.runtime_id.clone(),
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: target.transport_type.clone(),
|
|
transport_path: target.transport_path.clone(),
|
|
tcp_enabled: existing_state
|
|
.as_ref()
|
|
.map(|entry| entry.tcp_enabled)
|
|
.unwrap_or(false),
|
|
tcp_listen: existing_state
|
|
.as_ref()
|
|
.and_then(|entry| entry.tcp_listen.clone()),
|
|
cli_shim_path: existing_state.and_then(|entry| entry.cli_shim_path),
|
|
};
|
|
write_state_file(&paths.state_file_path, &state)?;
|
|
|
|
Ok(ManagedRuntimeStatus {
|
|
runtime_id: manifest.runtime_id,
|
|
runtime_version: manifest.runtime_version,
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: state.transport_type,
|
|
transport_path: state.transport_path,
|
|
diagnostics_root: paths.diagnostics_root.to_string_lossy().into_owned(),
|
|
state_file_path: paths.state_file_path.to_string_lossy().into_owned(),
|
|
})
|
|
}
|
|
|
|
fn run_cli_json_command(
|
|
runtime_root: &Path,
|
|
manifest: &ManagedRuntimeManifest,
|
|
args: &[&str],
|
|
paths: &ManagedPaths,
|
|
) -> Result<serde_json::Value, String> {
|
|
log::info!("[cli] running: {:?}", args);
|
|
let output = cli_command(runtime_root, manifest, args, paths)?
|
|
.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 managed_daemon_status_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let paths = resolve_paths(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let cli_shim_path = detect_installed_cli_shim_path(&runtime_root, &paths, &manifest);
|
|
let state = read_state_file(&paths.state_file_path);
|
|
let target = managed_transport_target(&paths, state.as_ref())?;
|
|
let tcp_settings = resolve_tcp_settings_from_state(state.as_ref());
|
|
let cli_status = run_cli_json_command(
|
|
&runtime_root,
|
|
&manifest,
|
|
&[
|
|
"daemon",
|
|
"status",
|
|
"--home",
|
|
&paths.managed_home.to_string_lossy(),
|
|
"--json",
|
|
],
|
|
&paths,
|
|
)
|
|
.ok();
|
|
|
|
let daemon_status = cli_status
|
|
.as_ref()
|
|
.and_then(|value| value.get("status"))
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or(if read_pid_from_home(&paths.managed_home).is_some() {
|
|
"running"
|
|
} else {
|
|
"stopped"
|
|
})
|
|
.to_string();
|
|
let daemon_pid = cli_status
|
|
.as_ref()
|
|
.and_then(|value| value.get("pid"))
|
|
.and_then(|value| {
|
|
value
|
|
.as_i64()
|
|
.or_else(|| value.as_str().and_then(|raw| raw.parse::<i64>().ok()))
|
|
})
|
|
.or_else(|| read_pid_from_home(&paths.managed_home));
|
|
let daemon_running = daemon_pid
|
|
.map(|pid| is_pid_running(pid as i32))
|
|
.unwrap_or(false)
|
|
|| daemon_status == "running";
|
|
|
|
Ok(ManagedDaemonStatus {
|
|
runtime_id: manifest.runtime_id,
|
|
runtime_version: manifest.runtime_version,
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: target.transport_type,
|
|
transport_path: target.transport_path,
|
|
daemon_pid,
|
|
daemon_running,
|
|
daemon_status,
|
|
log_path: paths.logs_path.to_string_lossy().into_owned(),
|
|
server_id: read_server_id(&paths.managed_home),
|
|
hostname: read_hostname_from_home(&paths.managed_home),
|
|
relay_enabled: true,
|
|
tcp_enabled: tcp_settings.enabled,
|
|
tcp_listen: tcp_settings
|
|
.enabled
|
|
.then(|| format!("{}:{}", tcp_settings.host, tcp_settings.port)),
|
|
cli_shim_path,
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn install_cli_shim_via_macos_prompt(shim_path: &Path, contents: &str) -> Result<(), String> {
|
|
// Manual smoke path: click Install CLI in the desktop app on macOS and confirm the
|
|
// /usr/local/bin/paseo prompt writes the trivial outer shim shown in cli_manual_instructions().
|
|
let temp_path = std::env::temp_dir().join(format!("paseo-cli-shim-{}", std::process::id()));
|
|
write_cli_launcher(&temp_path, contents)?;
|
|
let command = format!(
|
|
"mkdir -p {target_dir} && install -m 755 {temp_path} {target_path}",
|
|
target_dir = shell_single_quote(
|
|
shim_path
|
|
.parent()
|
|
.ok_or_else(|| "CLI shim target is missing a parent directory.".to_string())?
|
|
.to_string_lossy()
|
|
.as_ref()
|
|
),
|
|
temp_path = shell_single_quote(temp_path.to_string_lossy().as_ref()),
|
|
target_path = shell_single_quote(shim_path.to_string_lossy().as_ref())
|
|
);
|
|
let output = Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(format!(
|
|
"do shell script \"{}\" with administrator privileges",
|
|
escape_applescript_string(&command)
|
|
))
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| format!("Failed to request macOS CLI install privileges: {error}"))?;
|
|
let _ = fs::remove_file(&temp_path);
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
if stderr.contains("User canceled") || stderr.contains("(-128)") {
|
|
return Err("ELEVATION_DENIED".to_string());
|
|
}
|
|
Err(if stderr.is_empty() {
|
|
"Failed to install the global CLI shim.".to_string()
|
|
} else {
|
|
stderr
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn remove_cli_shim_via_macos_prompt(shim_path: &Path) -> Result<(), String> {
|
|
let command = format!(
|
|
"rm -f {}",
|
|
shell_single_quote(shim_path.to_string_lossy().as_ref())
|
|
);
|
|
let output = Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(format!(
|
|
"do shell script \"{}\" with administrator privileges",
|
|
escape_applescript_string(&command)
|
|
))
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.map_err(|error| format!("Failed to request macOS CLI uninstall privileges: {error}"))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
|
Err(if stderr.is_empty() {
|
|
"Failed to remove the global CLI shim.".to_string()
|
|
} else {
|
|
stderr
|
|
})
|
|
}
|
|
|
|
fn install_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
|
|
log::info!("[cli-shim] installing CLI shim");
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let paths = resolve_paths(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let shim_path = outer_cli_shim_path()?;
|
|
let shim_contents = cli_shim_contents(&runtime_root, &manifest, &paths);
|
|
let manual_instructions = cli_manual_instructions(&runtime_root, &paths, &manifest)?;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let install_result = install_cli_shim_via_macos_prompt(&shim_path, &shim_contents);
|
|
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
|
let install_result = write_cli_launcher(&shim_path, &shim_contents);
|
|
#[cfg(windows)]
|
|
let install_result: Result<(), String> = Err("AUTOMATIC_INSTALL_UNAVAILABLE".to_string());
|
|
|
|
log::info!("[cli-shim] target path: {}", shim_path.display());
|
|
match install_result {
|
|
Ok(()) => {
|
|
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
|
runtime_id: status.runtime_id.clone(),
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: status.transport_type.clone(),
|
|
transport_path: status.transport_path.clone(),
|
|
tcp_enabled: false,
|
|
tcp_listen: None,
|
|
cli_shim_path: None,
|
|
});
|
|
state.cli_shim_path = Some(shim_path.to_string_lossy().into_owned());
|
|
write_state_file(&paths.state_file_path, &state)?;
|
|
Ok(CliShimResult {
|
|
status: "installed".to_string(),
|
|
installed: true,
|
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
|
message: format!("Paseo CLI installed at {}.", shim_path.display()),
|
|
manual_instructions: None,
|
|
})
|
|
}
|
|
Err(error) if error == "ELEVATION_DENIED" => Ok(CliShimResult {
|
|
status: "elevationDenied".to_string(),
|
|
installed: false,
|
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
|
message: "CLI install needs administrator approval. If you dismissed the prompt, install it from Terminal with the commands below.".to_string(),
|
|
manual_instructions: Some(manual_instructions),
|
|
}),
|
|
Err(error) if error == "AUTOMATIC_INSTALL_UNAVAILABLE" => Ok(CliShimResult {
|
|
status: "automaticInstallUnavailable".to_string(),
|
|
installed: false,
|
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
|
message: "Automatic CLI install is not available on this platform. Finish the install manually with the commands below.".to_string(),
|
|
manual_instructions: Some(manual_instructions),
|
|
}),
|
|
Err(error) => Ok(CliShimResult {
|
|
status: "manualInstallRequired".to_string(),
|
|
installed: false,
|
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
|
message: format!(
|
|
"Automatic CLI install did not complete: {error}. Finish the install manually with the commands below."
|
|
),
|
|
manual_instructions: Some(manual_instructions),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn uninstall_cli_shim_internal(app: &AppHandle) -> Result<CliShimResult, String> {
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let paths = resolve_paths(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let shim_path = detect_installed_cli_shim_path(&runtime_root, &paths, &manifest)
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
read_state_file(&paths.state_file_path)
|
|
.and_then(|entry| entry.cli_shim_path.map(PathBuf::from))
|
|
})
|
|
.unwrap_or(outer_cli_shim_path()?);
|
|
if shim_path.exists() {
|
|
#[cfg(target_os = "macos")]
|
|
remove_cli_shim_via_macos_prompt(&shim_path)?;
|
|
#[cfg(all(not(target_os = "macos"), not(windows)))]
|
|
fs::remove_file(&shim_path)
|
|
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
|
|
#[cfg(windows)]
|
|
fs::remove_file(&shim_path)
|
|
.map_err(|error| format!("Failed to remove {}: {error}", shim_path.display()))?;
|
|
}
|
|
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
|
runtime_id: status.runtime_id.clone(),
|
|
runtime_root: runtime_root.to_string_lossy().into_owned(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: status.transport_type.clone(),
|
|
transport_path: status.transport_path.clone(),
|
|
tcp_enabled: false,
|
|
tcp_listen: None,
|
|
cli_shim_path: None,
|
|
});
|
|
state.cli_shim_path = None;
|
|
write_state_file(&paths.state_file_path, &state)?;
|
|
|
|
Ok(CliShimResult {
|
|
status: "removed".to_string(),
|
|
installed: false,
|
|
path: Some(shim_path.to_string_lossy().into_owned()),
|
|
message: "Paseo CLI shim removed.".to_string(),
|
|
manual_instructions: None,
|
|
})
|
|
}
|
|
|
|
fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus, String> {
|
|
log::info!("[daemon] starting managed daemon");
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let paths = resolve_paths(app)?;
|
|
let existing_status = managed_daemon_status_internal(app)?;
|
|
if existing_status.daemon_running {
|
|
log::info!(
|
|
"[daemon] already running (pid={:?})",
|
|
existing_status.daemon_pid
|
|
);
|
|
return Ok(existing_status);
|
|
}
|
|
if let Some(parent) = paths.transport_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
|
}
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let state = read_state_file(&paths.state_file_path);
|
|
let target = managed_transport_target(&paths, state.as_ref())?;
|
|
log::info!(
|
|
"[daemon] spawning: home={} listen={} (type={})",
|
|
paths.managed_home.display(),
|
|
target.transport_path,
|
|
target.transport_type
|
|
);
|
|
let output = cli_command(
|
|
&runtime_root,
|
|
&manifest,
|
|
&[
|
|
"start",
|
|
"--home",
|
|
&paths.managed_home.to_string_lossy(),
|
|
"--listen",
|
|
&target.transport_path,
|
|
],
|
|
&paths,
|
|
)?
|
|
.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, waiting for daemon to be ready");
|
|
for attempt in 0..30 {
|
|
let daemon_status = managed_daemon_status_internal(app)?;
|
|
if daemon_status.daemon_running {
|
|
log::info!(
|
|
"[daemon] ready after {} attempts (pid={:?})",
|
|
attempt + 1,
|
|
daemon_status.daemon_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");
|
|
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 paths = resolve_paths(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",
|
|
"--home",
|
|
&paths.managed_home.to_string_lossy(),
|
|
"--json",
|
|
],
|
|
&paths,
|
|
)?
|
|
.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 paths = resolve_paths(app)?;
|
|
let runtime_root = PathBuf::from(&status.runtime_root);
|
|
let manifest = load_runtime_manifest(&runtime_root)?;
|
|
let state = read_state_file(&paths.state_file_path);
|
|
let target = managed_transport_target(&paths, state.as_ref())?;
|
|
log::info!(
|
|
"[daemon] restart: listen={} (type={})",
|
|
target.transport_path,
|
|
target.transport_type
|
|
);
|
|
let output = cli_command(
|
|
&runtime_root,
|
|
&manifest,
|
|
&[
|
|
"daemon",
|
|
"restart",
|
|
"--home",
|
|
&paths.managed_home.to_string_lossy(),
|
|
"--listen",
|
|
&target.transport_path,
|
|
"--json",
|
|
],
|
|
&paths,
|
|
)?
|
|
.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> {
|
|
log::info!(
|
|
"[tcp] updating settings: enabled={} host={} port={}",
|
|
settings.enabled,
|
|
settings.host,
|
|
settings.port
|
|
);
|
|
if settings.enabled {
|
|
parse_tcp_listen(&format!("{}:{}", settings.host.trim(), settings.port))?;
|
|
}
|
|
let status = ensure_runtime_ready_internal(app)?;
|
|
let paths = resolve_paths(app)?;
|
|
let mut state = read_state_file(&paths.state_file_path).unwrap_or(ManagedStateFile {
|
|
runtime_id: status.runtime_id.clone(),
|
|
runtime_root: status.runtime_root.clone(),
|
|
managed_home: paths.managed_home.to_string_lossy().into_owned(),
|
|
transport_type: status.transport_type.clone(),
|
|
transport_path: status.transport_path.clone(),
|
|
tcp_enabled: false,
|
|
tcp_listen: None,
|
|
cli_shim_path: None,
|
|
});
|
|
state.tcp_enabled = settings.enabled;
|
|
state.tcp_listen = Some(format!("{}:{}", settings.host.trim(), settings.port));
|
|
let target = managed_transport_target(&paths, Some(&state))?;
|
|
state.transport_type = target.transport_type;
|
|
state.transport_path = target.transport_path;
|
|
write_state_file(&paths.state_file_path, &state)?;
|
|
|
|
if managed_daemon_status_internal(app)?.daemon_running {
|
|
return restart_managed_daemon_internal(app);
|
|
}
|
|
managed_daemon_status_internal(app)
|
|
}
|
|
|
|
#[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 managed_daemon_logs(app: AppHandle) -> Result<ManagedDaemonLogs, String> {
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
ensure_runtime_ready_internal(&app)?;
|
|
let paths = resolve_paths(&app)?;
|
|
Ok(ManagedDaemonLogs {
|
|
log_path: paths.logs_path.to_string_lossy().into_owned(),
|
|
contents: tail_log(&paths.logs_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 paths = resolve_paths(&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",
|
|
"--home",
|
|
&paths.managed_home.to_string_lossy(),
|
|
"--json",
|
|
],
|
|
&paths,
|
|
)?;
|
|
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 install_cli_shim(app: AppHandle) -> Result<CliShimResult, String> {
|
|
tauri::async_runtime::spawn_blocking(move || install_cli_shim_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("CLI shim install task failed: {error}"))?
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn uninstall_cli_shim(app: AppHandle) -> Result<CliShimResult, String> {
|
|
tauri::async_runtime::spawn_blocking(move || uninstall_cli_shim_internal(&app))
|
|
.await
|
|
.map_err(|error| format!("CLI shim uninstall 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}"))?
|
|
}
|
|
|
|
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(())
|
|
}
|