diff --git a/rabby-runtime/src/lib.rs b/rabby-runtime/src/lib.rs index 06cb68d..6241225 100644 --- a/rabby-runtime/src/lib.rs +++ b/rabby-runtime/src/lib.rs @@ -1,3 +1,4 @@ pub mod config_store; +pub mod local_terminal; pub mod pty; pub mod wallet_vault; diff --git a/rabby-runtime/src/local_terminal/mod.rs b/rabby-runtime/src/local_terminal/mod.rs new file mode 100644 index 0000000..23d71b4 --- /dev/null +++ b/rabby-runtime/src/local_terminal/mod.rs @@ -0,0 +1,118 @@ +use serde::Serialize; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalCommand { + pub command: String, + pub cwd: Option, +} + +impl LocalCommand { + pub fn new(command: impl Into) -> Self { + Self { + command: command.into(), + cwd: None, + } + } + + pub fn with_cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocalCommandOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalTerminalError { + InvalidCommand(String), + Io(String), +} + +#[derive(Debug, Default, Clone)] +pub struct LocalTerminal; + +impl LocalTerminal { + pub fn run(&self, command: LocalCommand) -> Result { + if command.command.trim().is_empty() { + return Err(LocalTerminalError::InvalidCommand( + "command is required".to_string(), + )); + } + let mut child = Command::new(default_shell()); + child.arg("-lc").arg(&command.command); + if let Some(cwd) = command.cwd { + child.current_dir(cwd); + } + let output = child + .output() + .map_err(|err| LocalTerminalError::Io(err.to_string()))?; + Ok(LocalCommandOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +fn default_shell() -> &'static str { + if cfg!(windows) { + "cmd" + } else { + "/bin/sh" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_terminal_runs_shell_command_and_captures_stdout() { + let output = LocalTerminal::default() + .run(LocalCommand::new("printf 'rabby-local-terminal'")) + .unwrap(); + + assert_eq!(output.exit_code, 0); + assert_eq!(output.stdout, "rabby-local-terminal"); + assert!(output.stderr.is_empty()); + } + + #[test] + fn local_terminal_reports_non_zero_exit() { + let output = LocalTerminal::default() + .run(LocalCommand::new("printf error >&2; exit 7")) + .unwrap(); + + assert_eq!(output.exit_code, 7); + assert_eq!(output.stderr, "error"); + } + + #[test] + fn local_terminal_rejects_blank_commands() { + let err = LocalTerminal::default() + .run(LocalCommand::new(" ")) + .unwrap_err(); + assert_eq!( + err, + LocalTerminalError::InvalidCommand("command is required".to_string()) + ); + } + + #[test] + fn local_terminal_runs_from_working_directory() { + let dir = std::env::temp_dir(); + let output = LocalTerminal::default() + .run(LocalCommand::new("pwd").with_cwd(&dir)) + .unwrap(); + + assert_eq!(output.exit_code, 0); + assert_eq!(output.stdout.trim(), dir.display().to_string()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d69bba..0a6f483 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,7 @@ use rabby_core::{ demo_dashboard, wallet_feature_summary, WalletDashboard, WalletFeatureStatus, Workspace, }; +use rabby_runtime::local_terminal::{LocalCommand, LocalCommandOutput, LocalTerminal}; use rabby_runtime::wallet_vault::{EncryptedWalletVault, WalletVaultStatus}; use serde::Serialize; use std::path::PathBuf; @@ -58,6 +59,17 @@ fn load_wallet_vault(path: String, passphrase: String) -> Result) -> Result { + let mut local_command = LocalCommand::new(command); + if let Some(cwd) = cwd { + local_command = local_command.with_cwd(cwd); + } + LocalTerminal::default() + .run(local_command) + .map_err(|err| format!("{err:?}")) +} + fn resolve_vault_path(path: Option) -> PathBuf { path.map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("rabby-demo-wallet-vault.json")) @@ -70,7 +82,8 @@ fn main() { wallet_mvp_summary, wallet_dashboard, save_demo_wallet_vault, - load_wallet_vault + load_wallet_vault, + run_local_command ]) .run(tauri::generate_context!()) .expect("failed to run Rabby Tauri application"); @@ -94,4 +107,11 @@ mod tests { let path = resolve_vault_path(Some("/tmp/custom-rabby-vault.json".to_string())); assert_eq!(path, PathBuf::from("/tmp/custom-rabby-vault.json")); } + + #[test] + fn local_command_tauri_bridge_captures_output() { + let output = run_local_command("printf tauri-local".to_string(), None).unwrap(); + assert_eq!(output.exit_code, 0); + assert_eq!(output.stdout, "tauri-local"); + } } diff --git a/ui/app.js b/ui/app.js index e2879c3..a3445bc 100644 --- a/ui/app.js +++ b/ui/app.js @@ -136,6 +136,30 @@ function wireVaultActions() { if (button) button.addEventListener('click', saveDemoVault); } +async function runLocalTerminalCommand() { + const command = document.getElementById('terminal-command').value; + const output = document.getElementById('terminal-output'); + output.textContent = 'Running…'; + try { + const tauri = window.__TAURI__?.core; + if (!tauri) throw new Error('Tauri bridge unavailable in browser preview'); + const result = await tauri.invoke('run_local_command', { command, cwd: null }); + output.textContent = [ + `$ ${command}`, + `exit ${result.exit_code}`, + result.stdout ? `stdout:\n${result.stdout}` : '', + result.stderr ? `stderr:\n${result.stderr}` : '' + ].filter(Boolean).join('\n'); + } catch (error) { + output.textContent = String(error); + } +} + +function wireTerminalActions() { + const button = document.getElementById('run-terminal'); + if (button) button.addEventListener('click', runLocalTerminalCommand); +} + async function boot() { const [dashboard, features] = await Promise.all([ invokeOrFallback('wallet_dashboard', fallbackDashboard), @@ -144,6 +168,7 @@ async function boot() { renderDashboard(dashboard); renderFeatures(features); wireVaultActions(); + wireTerminalActions(); } boot(); diff --git a/ui/index.html b/ui/index.html index 73fe45c..f56d675 100644 --- a/ui/index.html +++ b/ui/index.html @@ -132,6 +132,20 @@ +
+
+
+

Local terminal

+

Run a local shell command

+
+ +
+
+ +
Local command runtime is wired through Tauri.
+
+
+
diff --git a/ui/styles.css b/ui/styles.css index 4fafd91..130da53 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -306,3 +306,9 @@ h3 { margin-bottom: 0; font-size: 18px; } .hero-grid, .two-column { grid-template-columns: 1fr; } } + + +.terminal-panel { margin-bottom: 16px; } +.terminal-form { margin-top: 18px; display: grid; gap: 12px; } +.terminal-form input { width: 100%; border: 1px solid #dfe6f6; border-radius: 14px; padding: 12px 14px; color: #253052; background: #f8faff; font: 14px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; } +.terminal-form pre { min-height: 120px; margin: 0; padding: 16px; border-radius: 18px; color: #b9fbcf; background: #12182b; white-space: pre-wrap; overflow: auto; font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; }