mod ansi;
mod app;
mod input;
mod platform;
mod pty;
mod renderer;
mod replay;
mod terminal;
use log::*;
use nix::sys::signal::{SigSet, SigmaskHow, Signal, pthread_sigmask};
use std::collections::HashMap;
#[cfg(feature = "agent-harness")]
use std::fs::File;
#[cfg(feature = "agent-harness")]
use std::io::{Read, Seek, SeekFrom};
#[cfg(feature = "agent-harness")]
use std::path::PathBuf;
use std::thread;
#[cfg(feature = "agent-harness")]
use std::time::Duration;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
use winit::window::WindowId;
#[derive(Debug)]
enum UserEvent {
PtyOutput {
session_id: u64,
data: Vec<u8>,
},
PtyClosed {
session_id: u64,
},
NewWindow,
#[cfg(feature = "agent-harness")]
AgentCommand(String),
ReloadConfig,
}
struct PanasynApp {
apps: HashMap<WindowId, app::App>,
proxy: EventLoopProxy<UserEvent>,
launch_options: app::LaunchOptions,
}
impl PanasynApp {
fn new(proxy: EventLoopProxy<UserEvent>, launch_options: app::LaunchOptions) -> Self {
Self {
apps: HashMap::new(),
proxy,
launch_options,
}
}
fn open_window(&mut self, event_loop: &ActiveEventLoop) {
let proxy = self.proxy.clone();
match app::App::new(event_loop, proxy, &self.launch_options) {
Ok(app) => {
let window_id = app.window_id();
self.apps.insert(window_id, app);
if let Some(app) = self.apps.get_mut(&window_id) {
app.start_pending_readers();
}
}
Err(e) => {
error!("startup failed: {e}");
if self.apps.is_empty() {
event_loop.exit();
}
}
}
}
fn close_window(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId) {
self.apps.remove(&window_id);
if self.apps.is_empty() {
event_loop.exit();
}
}
fn window_for_session(&self, session_id: u64) -> Option<WindowId> {
self.apps
.iter()
.find_map(|(window_id, app)| app.owns_session(session_id).then_some(*window_id))
}
}
impl ApplicationHandler<UserEvent> for PanasynApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.apps.is_empty() {
self.open_window(event_loop);
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
let request = self
.apps
.get_mut(&window_id)
.and_then(|app| app.handle_window_event(window_id, event));
if matches!(request, Some(app::AppRequest::CloseWindow)) {
self.close_window(event_loop, window_id);
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
if matches!(event, UserEvent::NewWindow) {
self.open_window(event_loop);
return;
}
if matches!(event, UserEvent::ReloadConfig) {
let config = self.launch_options.config();
for app in self.apps.values_mut() {
app.apply_config(config.clone());
}
event_loop.set_control_flow(ControlFlow::Poll);
return;
}
#[cfg(feature = "agent-harness")]
if let UserEvent::AgentCommand(command) = event {
let mut close_windows = Vec::new();
for (window_id, app) in &mut self.apps {
if matches!(
app.handle_user_event(UserEvent::AgentCommand(command.clone())),
Some(app::AppRequest::CloseWindow)
) {
close_windows.push(*window_id);
}
}
for window_id in close_windows {
self.close_window(event_loop, window_id);
}
event_loop.set_control_flow(ControlFlow::Poll);
return;
}
let session_id = match &event {
UserEvent::PtyOutput { session_id, .. } | UserEvent::PtyClosed { session_id } => {
*session_id
}
UserEvent::NewWindow | UserEvent::ReloadConfig => unreachable!(),
#[cfg(feature = "agent-harness")]
UserEvent::AgentCommand(_) => {
unreachable!()
}
};
let Some(window_id) = self.window_for_session(session_id) else {
return;
};
let request = self
.apps
.get_mut(&window_id)
.and_then(|app| app.handle_user_event(event));
event_loop.set_control_flow(ControlFlow::Poll);
if matches!(request, Some(app::AppRequest::CloseWindow)) {
self.close_window(event_loop, window_id);
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
let mut control_flow = ControlFlow::Wait;
for app in self.apps.values_mut() {
control_flow = merge_control_flow(control_flow, app.handle_about_to_wait());
}
_event_loop.set_control_flow(control_flow);
}
}
fn merge_control_flow(current: ControlFlow, next: ControlFlow) -> ControlFlow {
match (current, next) {
(ControlFlow::Poll, _) | (_, ControlFlow::Poll) => ControlFlow::Poll,
(ControlFlow::WaitUntil(a), ControlFlow::WaitUntil(b)) => ControlFlow::WaitUntil(a.min(b)),
(ControlFlow::WaitUntil(deadline), ControlFlow::Wait)
| (ControlFlow::Wait, ControlFlow::WaitUntil(deadline)) => ControlFlow::WaitUntil(deadline),
(ControlFlow::Wait, ControlFlow::Wait) => ControlFlow::Wait,
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let sub = args.get(1).map(String::as_str);
if let Some("replay" | "fuzz" | "convert" | "profile" | "bench" | "list" | "help") = sub {
return replay::run();
}
let config_reload_signals = block_config_reload_signal();
#[cfg(feature = "agent-harness")]
let mut launch_options = parse_launch_options(&args)?;
#[cfg(not(feature = "agent-harness"))]
let launch_options = parse_launch_options(&args)?;
#[cfg(feature = "agent-harness")]
if launch_options.test_profile.is_none() && running_as_agent_bundle() {
launch_options.test_profile = Some(app::TestProfile::Agent);
}
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.init();
info!("panasyn start");
let event_loop = winit::event_loop::EventLoop::<UserEvent>::with_user_event().build()?;
let proxy = event_loop.create_proxy();
if let Some(signals) = config_reload_signals {
start_config_reload_watcher(proxy.clone(), signals);
}
#[cfg(feature = "agent-harness")]
if launch_options.test_profile.is_some() {
start_agent_control_watcher(proxy.clone());
}
let mut panasyn_app = PanasynApp::new(proxy, launch_options);
event_loop.run_app(&mut panasyn_app)?;
Ok(())
}
fn parse_launch_options(args: &[String]) -> Result<app::LaunchOptions, Box<dyn std::error::Error>> {
#[cfg(feature = "agent-harness")]
let mut options = app::LaunchOptions::default();
#[cfg(not(feature = "agent-harness"))]
let options = app::LaunchOptions::default();
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
#[cfg(feature = "agent-harness")]
"--test-profile" => {
let Some(profile) = iter.next() else {
return Err("--test-profile requires a value".into());
};
options.test_profile = Some(parse_test_profile(profile)?);
}
#[cfg(feature = "agent-harness")]
"--agent-test-profile" => {
options.test_profile = Some(app::TestProfile::Agent);
}
"--help" | "-h" => {
print_gui_usage();
std::process::exit(0);
}
unknown => return Err(format!("unknown argument: {unknown}").into()),
}
}
Ok(options)
}
fn block_config_reload_signal() -> Option<SigSet> {
let mut signals = SigSet::empty();
signals.add(Signal::SIGHUP);
if let Err(error) = pthread_sigmask(SigmaskHow::SIG_BLOCK, Some(&signals), None) {
warn!("failed to block SIGHUP for config reload watcher: {error}");
return None;
}
Some(signals)
}
#[cfg(feature = "agent-harness")]
fn parse_test_profile(value: &str) -> Result<app::TestProfile, Box<dyn std::error::Error>> {
match value {
"agent" => Ok(app::TestProfile::Agent),
other => Err(format!("unknown test profile: {other}").into()),
}
}
fn start_config_reload_watcher(proxy: EventLoopProxy<UserEvent>, signals: SigSet) {
thread::spawn(move || {
loop {
match signals.wait() {
Ok(Signal::SIGHUP) => {
if proxy.send_event(UserEvent::ReloadConfig).is_err() {
return;
}
}
Ok(signal) => warn!("unexpected signal in config reload watcher: {signal:?}"),
Err(error) => {
warn!("config reload watcher failed: {error}");
return;
}
}
}
});
}
#[cfg(feature = "agent-harness")]
fn running_as_agent_bundle() -> bool {
if std::env::var("PANASYN_AGENT_BUNDLE").as_deref() == Ok("1") {
return true;
}
std::env::current_exe()
.ok()
.and_then(|path| {
path.file_stem()
.map(|name| name.to_string_lossy().into_owned())
})
.is_some_and(|name| name == "PanasynAgent")
}
#[cfg(feature = "agent-harness")]
fn start_agent_control_watcher(proxy: EventLoopProxy<UserEvent>) {
let Some(path) = agent_control_file_path() else {
return;
};
info!("agent control file: {}", path.display());
thread::spawn(move || {
let mut offset = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let mut partial = String::new();
loop {
thread::sleep(Duration::from_millis(100));
let Ok(metadata) = std::fs::metadata(&path) else {
offset = 0;
partial.clear();
continue;
};
if metadata.len() < offset {
offset = 0;
partial.clear();
}
if metadata.len() == offset {
continue;
}
let Ok(mut file) = File::open(&path) else {
continue;
};
if file.seek(SeekFrom::Start(offset)).is_err() {
offset = 0;
partial.clear();
continue;
}
let mut bytes = Vec::new();
if file.read_to_end(&mut bytes).is_err() {
continue;
}
offset = metadata.len();
partial.push_str(&String::from_utf8_lossy(&bytes));
let has_complete_line = partial.ends_with('\n') || partial.ends_with('\r');
let mut lines = partial.lines().map(str::to_string).collect::<Vec<_>>();
partial = if has_complete_line {
String::new()
} else {
lines.pop().unwrap_or_default()
};
for line in lines {
let command = line.trim();
if command.is_empty() || command.starts_with('#') {
continue;
}
if proxy
.send_event(UserEvent::AgentCommand(command.to_string()))
.is_err()
{
return;
}
}
}
});
}
#[cfg(feature = "agent-harness")]
fn agent_control_file_path() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("PANASYN_AGENT_CONTROL_FILE") {
return Some(PathBuf::from(path));
}
if let Some(resource) = agent_bundle_resource("agent-control-path")
&& let Ok(path) = std::fs::read_to_string(resource)
{
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
std::env::current_dir()
.ok()
.map(|cwd| cwd.join("target/panasyn-agent-control.txt"))
}
#[cfg(feature = "agent-harness")]
fn agent_bundle_resource(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos = exe.parent()?;
if macos.file_name()? != "MacOS" {
return None;
}
let contents = macos.parent()?;
if contents.file_name()? != "Contents" {
return None;
}
Some(contents.join("Resources").join(name))
}
fn print_gui_usage() {
println!("USAGE:");
#[cfg(feature = "agent-harness")]
println!(" panasyn [--test-profile agent]");
#[cfg(not(feature = "agent-harness"))]
println!(" panasyn");
#[cfg(feature = "agent-harness")]
println!(" panasyn --agent-test-profile");
println!();
println!("Tool subcommands:");
println!(" panasyn replay|fuzz|convert|profile|bench|list|help ...");
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[test]
fn parse_launch_options_defaults_to_user_config() {
let options = parse_launch_options(&args(&["panasyn"])).unwrap();
#[cfg(feature = "agent-harness")]
assert_eq!(options.test_profile, None);
#[cfg(not(feature = "agent-harness"))]
let _ = options;
}
#[cfg(not(feature = "agent-harness"))]
#[test]
fn production_build_rejects_agent_profile_argument() {
assert!(parse_launch_options(&args(&["panasyn", "--test-profile", "agent"])).is_err());
assert!(parse_launch_options(&args(&["panasyn", "--agent-test-profile"])).is_err());
}
#[cfg(feature = "agent-harness")]
#[test]
fn parse_launch_options_accepts_agent_profile() {
let options = parse_launch_options(&args(&["panasyn", "--test-profile", "agent"])).unwrap();
assert_eq!(options.test_profile, Some(app::TestProfile::Agent));
}
#[cfg(feature = "agent-harness")]
#[test]
fn parse_launch_options_rejects_unknown_profile() {
assert!(parse_launch_options(&args(&["panasyn", "--test-profile", "demo"])).is_err());
}
#[cfg(feature = "agent-harness")]
#[test]
fn parse_test_profile_accepts_agent() {
assert_eq!(
parse_test_profile("agent").unwrap(),
app::TestProfile::Agent
);
}
#[test]
fn merge_control_flow_keeps_poll_from_any_window() {
assert_eq!(
merge_control_flow(ControlFlow::Wait, ControlFlow::Poll),
ControlFlow::Poll
);
assert_eq!(
merge_control_flow(ControlFlow::Poll, ControlFlow::Wait),
ControlFlow::Poll
);
}
#[test]
fn merge_control_flow_uses_earliest_wait_until() {
let later = std::time::Instant::now() + std::time::Duration::from_secs(2);
let earlier = later - std::time::Duration::from_secs(1);
assert_eq!(
merge_control_flow(
ControlFlow::WaitUntil(later),
ControlFlow::WaitUntil(earlier)
),
ControlFlow::WaitUntil(earlier)
);
assert_eq!(
merge_control_flow(ControlFlow::Wait, ControlFlow::WaitUntil(earlier)),
ControlFlow::WaitUntil(earlier)
);
}
}