use clap::Parser;
use cli::args::Args;
use color_eyre::Result;
use event::{Event, EventHandler};
use vortix::app::App;
use vortix::{cli, config, constants, event, ui};
#[allow(clippy::too_many_lines)] fn main() -> Result<()> {
color_eyre::install()?;
init_tracing();
vortix::vortix_process::set_global_runner(vortix::vortix_process::CommandRunner::real());
vortix::platform::set_global_platform(vortix::platform::Platform::detect_current());
let settings = match vortix::vortix_config::Settings::load() {
Ok(s) => s,
Err(e) => {
eprintln!("warning: failed to load settings ({e}); using defaults");
vortix::vortix_config::Settings::default()
}
};
let runtime_handle = vortix::vortix_process::global_runner()
.as_real()
.map(|r| r.runtime().handle().clone());
if let Some(handle) = runtime_handle.clone() {
let _guard = handle.enter();
match vortix::vortix_core::journal::Journal::open(
vortix::vortix_core::journal::JournalConfig {
disk: settings.journal.disk,
retention_days: settings.journal.retention_days,
retention_count: settings.journal.retention_count,
..Default::default()
},
) {
Ok(journal) => {
vortix::vortix_core::journal::set_global_journal(journal);
}
Err(e) => {
eprintln!("warning: failed to open journal ({e}); diagnostics will be limited");
}
}
}
let _ = runtime_handle;
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
eprintln!();
eprintln!("Vortix crashed unexpectedly.");
eprintln!("If your network is broken, run: vortix release-killswitch");
eprintln!();
default_hook(info);
}));
let args = Args::parse();
let config_dir_source = if args.config_dir.is_some() {
if std::env::var("VORTIX_CONFIG_DIR").is_ok() {
let env_val = std::env::var("VORTIX_CONFIG_DIR").unwrap_or_default();
let cli_val = args
.config_dir
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if cli_val == env_val {
"from VORTIX_CONFIG_DIR"
} else {
"from --config-dir"
}
} else {
"from --config-dir"
}
} else {
"default"
};
let explicit_override = args.config_dir.is_some();
let mut config_dir = config::resolve_config_dir(args.config_dir.as_ref())
.map_err(|e| color_eyre::eyre::eyre!("Failed to resolve config directory: {e}"))?;
if !explicit_override {
if let Some(old_dir) = config::check_migration(&config_dir) {
config_dir = prompt_migration(&old_dir, &config_dir);
}
}
config::set_config_dir(config_dir.clone());
let profiles_dir = config_dir.join(constants::PROFILES_DIR_NAME);
if std::env::var_os("VORTIX_SKIP_MIGRATION").is_some() {
eprintln!("VORTIX_SKIP_MIGRATION set — skipping startup sidecar backfill.");
} else {
match vortix::vortix_config::migrate_legacy_profiles(&profiles_dir) {
Ok(stats) => {
if stats.created > 0 {
eprintln!(
"Migrated {} profile(s) to the new sidecar scheme.",
stats.created
);
}
if stats.failed > 0 {
eprintln!(
"Warning: {} profile(s) failed to migrate; existing files untouched. Run `vortix migrate` to retry.",
stats.failed
);
}
}
Err(e) => {
eprintln!(
"Warning: profile sidecar migration skipped — {e}. Startup continues; run `vortix migrate` once the issue is resolved."
);
}
}
}
let orphans = vortix::vortix_process::scan_orphans();
if !orphans.is_empty() {
eprintln!(
"Warning: detected {} possible orphan VPN process(es) from a previous session:",
orphans.len()
);
for o in &orphans {
eprintln!(" - pid {} ({})", o.pid, o.command);
}
eprintln!(
" These may be leftovers from a previous vortix crash. Run `sudo kill <pid>` to clean up, or `sudo vortix down --force` to tear down via vortix."
);
}
let app_config = match config::load_config(&config_dir) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Error: {e}");
eprintln!();
eprintln!("Fix the file or remove it to use defaults:");
eprintln!(" nano {}/config.toml", config_dir.display());
eprintln!(" rm {}/config.toml", config_dir.display());
std::process::exit(1);
}
};
let output_mode = if args.json {
cli::output::OutputMode::Json
} else if args.quiet {
cli::output::OutputMode::Quiet
} else {
cli::output::OutputMode::Human
};
if let Some(command) = &args.command {
let exit_code = cli::commands::handle_command(
command,
&config_dir,
config_dir_source,
&app_config,
output_mode,
);
std::process::exit(exit_code);
}
let terminal = init_terminal()?;
let result = run_tui(terminal, app_config, config_dir);
restore_terminal();
result
}
fn prompt_migration(old_dir: &std::path::Path, new_dir: &std::path::Path) -> std::path::PathBuf {
use std::io::Write;
eprintln!();
eprintln!(" Old data found at: {}", old_dir.display());
eprintln!(" New config dir: {}", new_dir.display());
eprintln!();
eprintln!(" Vortix now stores config under your home directory instead of");
eprintln!(" /root, so profiles are accessible without sudo.");
eprintln!();
eprintln!(" [Y] Move your existing profiles and settings to the new location.");
eprintln!(" Files are copied first, then deleted from the old path.");
eprintln!();
eprintln!(
" [n] Start fresh. Your old data stays at {} but",
old_dir.display()
);
eprintln!(" won't be used. You can import profiles again or copy manually.");
eprintln!();
eprint!(" Move data? [Y/n] ");
let _ = std::io::stderr().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
eprintln!(" Could not read input. Starting fresh.\n");
return new_dir.to_path_buf();
}
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" || input == "yes" {
eprintln!();
match config::migrate_data(old_dir, new_dir) {
Ok(()) => {
let profiles_exist = new_dir.join("profiles").is_dir()
&& std::fs::read_dir(new_dir.join("profiles"))
.map(|mut d| d.next().is_some())
.unwrap_or(false);
if profiles_exist {
eprintln!(" Done! Data moved to {}\n", new_dir.display());
} else {
eprintln!(
" Warning: Move completed but no profiles found at {}",
new_dir.join("profiles").display()
);
eprintln!(
" Check if your profiles are still at {}\n",
old_dir.display()
);
}
new_dir.to_path_buf()
}
Err(e) => {
eprintln!(" Move failed: {e}");
eprintln!(" Your original data is untouched at {}", old_dir.display());
eprintln!(" Starting fresh at {}\n", new_dir.display());
new_dir.to_path_buf()
}
}
} else {
eprintln!();
eprintln!(" Starting fresh at {}", new_dir.display());
eprintln!(" Old data is still at {}.", old_dir.display());
eprintln!(" This prompt will appear until you migrate or the old data is removed.");
eprintln!(" To silence it: --config-dir {}\n", old_dir.display());
new_dir.to_path_buf()
}
}
fn run_tui(
mut terminal: ratatui::DefaultTerminal,
config: config::AppConfig,
config_dir: std::path::PathBuf,
) -> Result<()> {
let tick_rate = config.tick_rate;
let profiles_dir_for_resolver = config_dir.join(constants::PROFILES_DIR_NAME);
let mut app = App::new(config, config_dir);
if let Some(runtime) = vortix::vortix_process::global_runner().as_real() {
let _guard = runtime.runtime().handle().enter();
if let Some(journal) = vortix::vortix_core::journal::global_journal().cloned() {
use vortix::state::Protocol;
use vortix::tunnel::{tunnel_for_with_secrets, TunnelKind};
use vortix::vortix_config::profile_store::{FsProfileStore, ProfileStore};
use vortix::vortix_core::engine::{Engine, EngineHandle};
use vortix::vortix_core::profile::{ProfileId, ProtocolKind};
use vortix::vortix_protocol_wireguard::WgTunnel;
let resolver_dir = profiles_dir_for_resolver.clone();
let resolver = move |id: &ProfileId| {
let store = FsProfileStore::new(resolver_dir.clone());
store.get(id).ok()
};
let factory_config_dir = vortix::utils::get_app_config_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
let factory = move |profile: &vortix::vortix_core::profile::Profile| {
let proto = match profile.protocol {
ProtocolKind::OpenVpn => Protocol::OpenVPN,
_ => Protocol::WireGuard,
};
tunnel_for_with_secrets(proto, &factory_config_dir, "3", 30)
};
let initial_tunnel = TunnelKind::WireGuard(WgTunnel::new());
let engine = Engine::new(initial_tunnel, resolver).with_tunnel_factory(factory);
let handle = EngineHandle::local(engine, journal);
app = app.with_engine_handle(handle);
if let Some(j) = vortix::vortix_core::journal::global_journal() {
let mut rx = j.subscribe();
let nudge = app.engine.telemetry_nudge.clone();
tokio::spawn(async move {
use vortix::vortix_core::engine::EngineEvent;
while let Ok(envelope) = rx.recv().await {
if matches!(envelope.event, EngineEvent::TunnelUp { .. }) {
if let Some(n) = &nudge {
let _ = n.send(());
}
}
}
});
}
}
}
let events = EventHandler::new(tick_rate);
let size = terminal.size()?;
app.on_resize(size.width, size.height);
app.process_external();
terminal.draw(|frame| ui::render(frame, &mut app))?;
while !app.should_quit {
if app.has_active_animation() {
while let Some(event) = events.try_next()? {
match event {
Event::Key(key_event) => app.handle_key(key_event),
Event::Mouse(mouse_event) => app.handle_mouse(mouse_event),
Event::Tick => app.on_tick(),
Event::Resize(w, h) => app.on_resize(w, h),
}
}
app.advance_animation();
} else {
match events.next()? {
Event::Key(key_event) => app.handle_key(key_event),
Event::Mouse(mouse_event) => app.handle_mouse(mouse_event),
Event::Tick => app.on_tick(),
Event::Resize(width, height) => app.on_resize(width, height),
}
}
app.process_external();
terminal.draw(|frame| ui::render(frame, &mut app))?;
if app.has_active_animation() {
std::thread::sleep(std::time::Duration::from_millis(
constants::FLIP_ANIMATION_FRAME_MS,
));
}
}
Ok(())
}
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("off"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(true)
.compact()
.try_init();
}
fn init_terminal() -> Result<ratatui::DefaultTerminal> {
let mut terminal = ratatui::init();
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
terminal.clear()?;
Ok(terminal)
}
fn restore_terminal() {
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
ratatui::restore();
}