Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
portable-pty = "0.8"
base64 = "0.21"
uuid = { version = "1.0", features = ["v4"] }
# Async runtime for PTY reading
tokio = { version = "1", features = ["full"] }

Expand Down
171 changes: 118 additions & 53 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,83 +1,148 @@
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem, MasterPty};
use std::collections::HashMap;
use std::env;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;
use tauri::Manager;
use uuid::Uuid;
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
use serde::Serialize;

struct PtySession {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
}

struct AppState {
pty_writer: Arc<Mutex<Box<dyn Write + Send>>>,
sessions: Arc<Mutex<HashMap<String, PtySession>>>,
}

#[derive(Clone, Serialize)]
struct PtyOutputPayload {
session_id: String,
data: Vec<u8>,
}

#[tauri::command]
fn create_pty_session(app_handle: tauri::AppHandle, state: tauri::State<AppState>) -> Result<String, String> {
let session_id = Uuid::new_v4().to_string();

let pty_system = NativePtySystem::default();
let mut cmd = CommandBuilder::new("zsh");
cmd.env("TERM", "xterm-256color");
cmd.args(["-c", "export PROMPT_EOL_MARK=''; exec zsh"]);

if let Ok(cwd) = env::current_dir() {
cmd.cwd(cwd);
}

let pair = pty_system.openpty(PtySize {
rows: 30,
cols: 100,
pixel_width: 0,
pixel_height: 0,
}).map_err(|e| format!("Failed to create PTY: {}", e))?;

let mut reader = pair.master.try_clone_reader()
.map_err(|e| format!("Failed to clone reader: {}", e))?;
let writer = pair.master.take_writer()
.map_err(|e| format!("Failed to take writer: {}", e))?;

// Spawn shell
let child = pair.slave.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {}", e))?;
// Keep child alive
Box::leak(Box::new(child));

let session = PtySession {
writer: Arc::new(Mutex::new(writer)),
master: Arc::new(Mutex::new(pair.master)),
};

// Store session
{
let mut sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?;
sessions.insert(session_id.clone(), session);
}

// Read thread for this session
let sid = session_id.clone();
thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(n) if n > 0 => {
let payload = PtyOutputPayload {
session_id: sid.clone(),
data: buf[..n].to_vec(),
};
let _ = app_handle.emit_all("pty-output", payload);
}
Ok(_) => break, // EOF
Err(_) => break, // Error
}
}
});

Ok(session_id)
}

#[tauri::command]
fn write_to_pty(session_id: String, data: String, state: tauri::State<AppState>) -> Result<(), String> {
let sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?;
if let Some(session) = sessions.get(&session_id) {
if let Ok(mut writer) = session.writer.lock() {
let _ = write!(writer, "{}", data);
}
}
Ok(())
}

#[tauri::command]
fn write_to_pty(data: String, state: tauri::State<AppState>) {
if let Ok(mut writer) = state.pty_writer.lock() {
// We ignore errors for now (e.g. if pty closed)
let _ = write!(writer, "{}", data);
fn resize_pty(session_id: String, rows: u16, cols: u16, state: tauri::State<AppState>) -> Result<(), String> {
let sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?;
if let Some(session) = sessions.get(&session_id) {
if let Ok(master) = session.master.lock() {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
Ok(())
}

#[tauri::command]
fn close_pty_session(session_id: String, state: tauri::State<AppState>) -> Result<(), String> {
let mut sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?;
sessions.remove(&session_id);
Ok(())
}

fn main() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_window("main").unwrap();

#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");

let pty_system = NativePtySystem::default();
let mut cmd = CommandBuilder::new("zsh");
cmd.env("TERM", "xterm-256color");
// Disable ZSH auto-logout and unsetopt PROMPT_SP to fix '%' issue
cmd.args(["-c", "export PROMPT_EOL_MARK=''; exec zsh"]);

if let Ok(cwd) = env::current_dir() {
cmd.cwd(cwd);
}

// Define initial size
let pair = pty_system.openpty(PtySize {
rows: 30,
cols: 100,
pixel_width: 0,
pixel_height: 0,
}).expect("Failed to create PTY");

let mut reader = pair.master.try_clone_reader().expect("Failed to clone reader");
let writer = pair.master.take_writer().expect("Failed to take writer");

// Spawn shell
let child = pair.slave.spawn_command(cmd).expect("Failed to spawn shell");
// Keep child alive
Box::leak(Box::new(child));

let app_handle = app.app_handle();

// Read thread
thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(n) if n > 0 => {
let data = buf[..n].to_vec();
// Send raw bytes to avoid splitting multi-byte UTF-8 characters
let _ = app_handle.emit_all("pty-output", data);
}
Ok(_) => break, // EOF
Err(_) => break, // Error
}
}
});

app.manage(AppState {
pty_writer: Arc::new(Mutex::new(writer)),
sessions: Arc::new(Mutex::new(HashMap::new())),
});

Ok(())
})
.invoke_handler(tauri::generate_handler![write_to_pty])
.invoke_handler(tauri::generate_handler![
create_pty_session,
write_to_pty,
resize_pty,
close_pty_session
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Loading