Files
NotAlterra/src/main.rs
T

1195 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! NotAlterra — Subnautica 2 save-file manager.
//!
//! Cross-platform terminal application. Locates save folders, recovers
//! .sav files from .bak backups, creates/restores full backups, and
//! manages Config (.ini) files.
//!
//! MIT License. Not affiliated with Unknown Worlds Entertainment or KRAFTON.
mod config;
mod discovery;
mod guard;
mod gvas;
mod ops;
mod tui;
use anyhow::Result;
use chrono::TimeZone;
use config::AppConfig;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, ListState, Paragraph},
Frame, Terminal,
};
use std::io;
use std::path::{Path, PathBuf};
const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION"));
fn main() -> Result<()> {
for arg in std::env::args().skip(1) {
if arg == "--version" || arg == "-v" {
println!("notalterra {}", VERSION);
return Ok(());
}
}
// ── setup terminal ─────────────────────────────────────────────────
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal);
// ── teardown ───────────────────────────────────────────────────────
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
// ── app state ──────────────────────────────────────────────────────────────
struct App {
config: AppConfig,
config_path: PathBuf,
log_path: PathBuf,
save_folder: Option<PathBuf>,
tui_state: tui::AppState,
}
impl App {
fn new() -> Result<Self> {
let config_path = crate::config::config_path();
let config = crate::config::load_config(&config_path)?;
let log_path = guard::log_path();
let save_folder = config.last_path.as_deref().map(PathBuf::from);
let mut tui_state = tui::AppState::default();
tui_state.version = VERSION.to_string();
tui_state.save_path = save_folder.as_ref().map(|p| p.display().to_string());
// Refresh stats for the dashboard
refresh_stats(&mut tui_state, save_folder.as_deref());
Ok(Self {
config,
config_path,
log_path,
save_folder,
tui_state,
})
}
fn backup_root(&self) -> PathBuf {
exe_dir().join("NotAlterra_Backups")
}
fn set_status(&mut self, msg: &str, style: tui::StatusStyle) {
self.tui_state.status_message = Some(msg.to_string());
self.tui_state.status_style = style;
}
fn clear_status(&mut self) {
self.tui_state.status_message = None;
}
fn set_spinner(&mut self, active: bool) {
self.tui_state.spinner_active = active;
if active {
self.tui_state.spinner_start = Some(std::time::Instant::now());
}
}
}
fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) {
tui_state.save_path = save_folder.map(|p| p.display().to_string());
let backup_root = exe_dir().join("NotAlterra_Backups");
let (live, bak, ini) = ops::folder_stats(save_folder, &backup_root);
tui_state.live_save_count = live;
tui_state.backup_count = bak;
tui_state.has_ini_backup = ini;
}
// ── main loop ──────────────────────────────────────────────────────────────
fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let mut app = App::new()?;
// Guard: exit if game is running
if guard::game_running() {
println!("Subnautica 2 is running. Close the game and relaunch NotAlterra.");
return Ok(());
}
// Disclaimer flow
if !app.config.disclaimer_accepted {
match run_disclaimer(terminal, &mut app)? {
Some(true) => {} // accepted
_ => return Ok(()), // declined or cancelled on first launch → exit
}
}
// Auto-scan on startup if no cached path
let save_found = if app.save_folder.is_some() {
true
} else {
app.set_spinner(true);
terminal.draw(|f| {
tui::draw_text_screen(f, &app.tui_state,
&[Line::from(Span::styled("Locating save folder…", Style::default().add_modifier(Modifier::BOLD)))],
"",
);
})?;
let folders = discovery::discover_save_folders();
app.set_spinner(false);
if let Some(first) = folders.first() {
app.save_folder = Some(first.path.clone());
app.config.last_path = Some(first.path.display().to_string());
app.config.last_scan = Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
crate::config::save_config(&app.config_path, &app.config)?;
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
true
} else {
false
}
};
// Main menu loop
let mut menu_state = ListState::default().with_selected(Some(0));
loop {
terminal.draw(|f| {
let cols = f.area().width;
let rows = f.area().height;
if cols < 60 || rows < 15 {
draw_too_small(f);
return;
}
tui::draw_main_menu(f, &mut menu_state, &app.tui_state, save_found);
})?;
let max_idx = if save_found { 6 } else { 7 };
if event::poll(std::time::Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Release { continue; }
match key.code {
KeyCode::Up => {
let i = menu_state.selected().unwrap_or(0);
menu_state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = menu_state.selected().unwrap_or(0);
menu_state.select(Some((i + 1).min(max_idx)));
}
KeyCode::Enter => {
let idx = menu_state.selected().unwrap_or(0);
// Remap: when locate is hidden, shift by 1
let remap = if save_found { idx + 1 } else { idx };
match remap {
0 => action_locate_saves(terminal, &mut app)?,
1 => action_recover_bak(terminal, &mut app)?,
2 => action_create_backup(terminal, &mut app)?,
3 => action_restore_backup(terminal, &mut app)?,
4 => action_inspect_saves(terminal, &mut app)?,
5 => run_ini_submenu(terminal, &mut app)?,
6 => {
match run_disclaimer(terminal, &mut app)? {
Some(false) => return Ok(()), // declined → exit
_ => {} // accepted or cancelled → stay
}
}
7 => return Ok(()), // Exit
_ => {}
}
app.clear_status();
}
KeyCode::Esc => {
return Ok(());
}
_ => {}
}
}
}
}
}
// ── disclaimer ─────────────────────────────────────────────────────────────
fn run_disclaimer<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<Option<bool>> {
let mut selected_yes = true;
loop {
terminal.draw(|f| {
if f.area().width < 60 || f.area().height < 20 {
draw_too_small(f);
return;
}
tui::draw_disclaimer_popup(f, &app.tui_state, selected_yes);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => selected_yes = !selected_yes,
KeyCode::Char('y') | KeyCode::Char('Y') => {
guard::log_action("LICENSE", "accepted", "OK", &app.log_path)?;
app.config.disclaimer_accepted = true;
crate::config::save_config(&app.config_path, &app.config)?;
return Ok(Some(true));
}
KeyCode::Char('n') | KeyCode::Char('N') => {
guard::log_action("LICENSE", "declined", "OK", &app.log_path)?;
app.config.disclaimer_accepted = false;
crate::config::save_config(&app.config_path, &app.config)?;
return Ok(Some(false));
}
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Enter => {
guard::log_action("LICENSE", if selected_yes { "accepted" } else { "declined" }, "OK", &app.log_path)?;
let accepted = selected_yes;
app.config.disclaimer_accepted = accepted;
crate::config::save_config(&app.config_path, &app.config)?;
return Ok(Some(accepted));
}
_ => {}
}
}
}
// ── menu actions ───────────────────────────────────────────────────────────
fn action_locate_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
// Run scan on a background thread so we can show a live elapsed timer.
let (tx, rx) = std::sync::mpsc::channel();
let scan_start = std::time::Instant::now();
std::thread::spawn(move || {
let result = discovery::discover_save_folders();
tx.send(result).ok();
});
app.set_spinner(true);
let folders = loop {
let elapsed = scan_start.elapsed().as_secs();
terminal.draw(|f| {
tui::draw_text_screen(
f,
&app.tui_state,
&[
Line::from(Span::styled(
"Scanning for Subnautica save files…",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"The first scan checks every user profile and common",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
"install location — it may take a moment.",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
format!(" ⏱ {elapsed}s elapsed"),
Style::default().fg(Color::Cyan),
)),
],
"",
);
})?;
if let Ok(f) = rx.try_recv() {
break f;
}
std::thread::sleep(std::time::Duration::from_millis(100));
};
app.set_spinner(false);
let elapsed = scan_start.elapsed();
let elapsed_str = if elapsed.as_secs() >= 1 {
format!("{:.1}s", elapsed.as_secs_f64())
} else {
format!("{}ms", elapsed.as_millis())
};
if folders.is_empty() {
app.set_status("No Subnautica 2 save folders detected.", tui::StatusStyle::Error);
wait_for_key(terminal, app)?;
return Ok(());
}
// Cache the first result
if let Some(first) = folders.first() {
app.save_folder = Some(first.path.clone());
app.config.last_path = Some(first.path.display().to_string());
app.config.last_scan = Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
crate::config::save_config(&app.config_path, &app.config)?;
}
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
let msg = format!("Found {} save folder(s) in {elapsed_str}.", folders.len());
app.set_status(&msg, tui::StatusStyle::Success);
// Show found folders
let lines: Vec<Line> = std::iter::once(Line::from(Span::styled(
format!("Found {} save folder(s):", folders.len()),
Style::default().fg(Color::Green),
)))
.chain(folders.iter().map(|f| {
Line::from(Span::styled(
format!(" {}{}", f.label, f.path.display()),
Style::default().fg(Color::Gray),
))
}))
.collect();
let prompt = "Press any key to return to menu…";
loop {
terminal.draw(|f| tui::draw_text_screen(f, &app.tui_state, &lines, prompt))?;
if let Event::Key(_) = event::read()? {
break;
}
}
Ok(())
}
fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
// Warn if game is running
if guard::game_running() {
app.set_status(
"Subnautica 2 is running — save files may be locked!",
tui::StatusStyle::Warning,
);
}
// Show spinner while parsing metadata
app.set_status("Reading save metadata…", tui::StatusStyle::Info);
app.set_spinner(true);
terminal.draw(|f| {
tui::draw_text_screen(
f,
&app.tui_state,
&[Line::from(Span::styled(
"Reading save file metadata…",
Style::default().add_modifier(Modifier::BOLD),
))],
"Parsing GVAS headers for slot names and display names…",
);
})?;
let bak_summaries = ops::list_bak_files_with_meta(&save_folder);
app.set_spinner(false);
app.clear_status();
if bak_summaries.is_empty() {
app.set_status("No .bak files found.", tui::StatusStyle::Error);
wait_for_key(terminal, app)?;
return Ok(());
}
// Build multi-column display with slot grouping.
// First entry in each slot gets a numbered label matching the savegame slot.
let mut labelled: std::collections::HashSet<String> = std::collections::HashSet::new();
let items: Vec<String> = bak_summaries
.iter()
.map(|s| {
let num = slot_number(&s.slot);
let first = labelled.insert(s.slot.clone());
let label_col = if first { format!("Slot {num}") } else { String::new() };
let name = s.display_name.as_deref().unwrap_or("(unnamed)");
let name_col = if name.len() > 24 {
format!("{}…", &name[..23])
} else {
name.to_string()
};
let date = s.mtime.as_deref().unwrap_or("?");
let save_type = if s.is_online { "Online" } else { "Local" };
format!(
" {:<8} {:<26} {:<6} {:>6} {}",
label_col,
name_col,
save_type,
format_size(s.size),
date,
)
})
.collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
// Filenames for the info bar, and descriptions
let filenames: Vec<String> = bak_summaries
.iter()
.map(|s| s.filename.clone())
.collect();
let descs: Vec<String> = bak_summaries
.iter()
.map(|_| "Restore this backup to its canonical .sav file".to_string())
.collect();
let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect();
let mut state = ListState::default().with_selected(Some(0));
loop {
let selected_info = state
.selected()
.and_then(|i| filenames.get(i))
.map(|s| s.as_str());
terminal.draw(|f| {
tui::draw_picker_with_info(
f,
&app.tui_state,
&item_refs,
&desc_refs,
&mut state,
selected_info,
);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(bak_summaries.len().saturating_sub(1))));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
let chosen = &bak_summaries[idx];
let target = derive_target_sav(&chosen.filename);
let target_path = save_folder.join(&target);
// Target .sav metadata
let target_meta = if target_path.exists() {
std::fs::metadata(&target_path).ok().map(|m| {
let sz = m.len();
let mt = m.modified().ok().and_then(|t| {
let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
chrono::Local.timestamp_opt(s as i64, 0).single()
});
let meta = crate::gvas::extract_metadata(&target_path).ok();
let online = meta.as_ref().map(|m| m.is_online).unwrap_or(false);
let disp = meta.and_then(|m| m.display_name);
(sz, mt, online, disp)
})
} else { None };
// Mode change warning
let mode_entry = target_meta.as_ref().and_then(|(_, _, live, _)| {
if *live != chosen.is_online {
let from = if *live { "Online" } else { "Local" };
let to = if chosen.is_online { "Online" } else { "Local" };
Some(("⚠ Mode change", format!("{from}{to}")))
} else { None }
});
// Name change warning
let name_entry = target_meta.as_ref().and_then(|(_, _, _, live_name)| {
let bak_name = chosen.display_name.as_deref().unwrap_or("(unnamed)");
match live_name {
Some(live) if live != bak_name => {
Some(("⚠ Name change", format!("{live}{bak_name}")))
}
_ => None,
}
});
// Build details
let src_line = format!("{} {} {}", chosen.filename, format_size(chosen.size), chosen.mtime.as_deref().unwrap_or("?"));
let mut details = vec![
("Slot", chosen.slot.as_str()),
("Name", chosen.display_name.as_deref().unwrap_or("(unnamed)")),
("Backup", src_line.as_str()),
];
let tgt_line: String;
if let Some((sz, mt, _, _)) = &target_meta {
tgt_line = format!("{} {} {}", target, format_size(*sz), mt.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).as_deref().unwrap_or("?"));
details.push(("Replace", tgt_line.as_str()));
} else {
details.push(("Create", target.as_str()));
}
if let Some((k, v)) = &mode_entry {
details.push((&**k, &**v));
}
if let Some((k, v)) = &name_entry {
details.push((&**k, &**v));
}
if !has_existing_backup(app) {
details.push(("⚠ No backup", "create a full backup first"));
}
let accepted = confirm_modal(terminal, app, "Confirm Recovery", &details)?;
if accepted {
match ops::recover_bak_to_sav(&save_folder, &chosen.filename) {
Ok(result) => {
let msg = format!("Restored {}{}", result.source, result.target);
app.set_status(&msg, tui::StatusStyle::Success);
guard::log_action(
"RECOVER",
&format!("{}{}", result.source, result.target),
"OK",
&app.log_path,
)?;
}
Err(e) => {
app.set_status(&format!("Failed: {e}"), tui::StatusStyle::Error);
guard::log_action(
"RECOVER",
&chosen.filename,
&format!("FAILED: {e}"),
&app.log_path,
)?;
}
}
}
break;
}
KeyCode::Esc => break,
_ => {}
}
}
refresh_stats(&mut app.tui_state, Some(&save_folder));
Ok(())
}
fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
if guard::game_running() {
app.set_status("Subnautica 2 is running — files may be locked.", tui::StatusStyle::Warning);
}
app.set_status("Creating backup…", tui::StatusStyle::Info);
app.set_spinner(true);
terminal.draw(|f| {
tui::draw_text_screen(
f,
&app.tui_state,
&[Line::from(Span::styled(
"Creating full backup…",
Style::default().add_modifier(Modifier::BOLD),
))],
"Copying save folder contents…",
);
})?;
let backup_root = app.backup_root();
match ops::create_full_backup(&save_folder, &backup_root) {
Ok(result) => {
app.set_spinner(false);
let verified = if result.verified { "verified" } else { "unverified" };
let msg = format!(
"{} files, {} — backup {}",
result.files_copied,
format_size(result.total_size),
verified,
);
guard::log_action("MANUAL_BAK", &msg, "OK", &app.log_path)?;
ok_dialog(terminal, app, "Backup Complete", &msg)?;
}
Err(e) => {
app.set_spinner(false);
let msg = format!("Backup failed: {e}");
guard::log_action("MANUAL_BAK", &save_folder.display().to_string(), &format!("FAILED: {e}"), &app.log_path)?;
ok_dialog(terminal, app, "Backup Failed", &msg)?;
}
}
Ok(())
}
fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
let backup_root = app.backup_root();
let backups = ops::list_full_backups(&backup_root);
if backups.is_empty() {
ok_dialog(terminal, app, "No Backups", "No full backups found in NotAlterra_Backups.\nUse 'Create full backup' from the main menu first.")?;
return Ok(());
}
let items: Vec<String> = backups
.iter()
.map(|p| {
let name = p.file_name().unwrap().to_string_lossy();
format!(" {name}")
})
.collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let descs: Vec<&str> = vec!["Restore this backup into the save folder"; backups.len()];
let mut state = ListState::default().with_selected(Some(0));
loop {
terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(backups.len().saturating_sub(1))));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
let chosen = &backups[idx];
let name = chosen.file_name().unwrap().to_string_lossy().to_string();
let mut restore_details = vec![("Backup", name.as_str())];
if !has_existing_backup(app) {
restore_details.push(("⚠ No backup", "create a full backup first"));
}
let accepted = confirm_modal(terminal, app, "Confirm Restore", &restore_details)?;
if accepted {
guard::log_action("AUTO_BAK", &format!("pre-restore → {}", save_folder.display()), "OK", &app.log_path)?;
match ops::restore_full_backup(chosen, &save_folder, &backup_root) {
Ok(()) => {
app.set_status("Restore complete. Previous files preserved.", tui::StatusStyle::Success);
guard::log_action("RESTORE", &format!("{}{}", name, save_folder.display()), "OK", &app.log_path)?;
}
Err(e) => {
app.set_status(&format!("Restore failed: {e}"), tui::StatusStyle::Error);
guard::log_action("RESTORE", &name, &format!("FAILED: {e}"), &app.log_path)?;
}
}
}
break;
}
KeyCode::Esc => break,
_ => {}
}
}
refresh_stats(&mut app.tui_state, Some(&save_folder));
Ok(())
}
// ── .ini submenu ───────────────────────────────────────────────────────────
fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let config_path = get_config_path(terminal, app)?;
let backup_root = app.backup_root();
let items: Vec<&str> = vec![
"Backup .ini files",
"Restore .ini files from backup",
"Delete .ini files (requires backup)",
];
let descs: Vec<&str> = vec![
"Copy all .ini files from Config/Windows to NotAlterra_Backups",
"Restore .ini files from a previous backup",
"Remove .ini files — game regenerates defaults (backup required first)",
];
let mut state = ListState::default().with_selected(Some(0));
loop {
terminal.draw(|f| {
tui::draw_sub_menu(f, &app.tui_state, "Config (.ini) Management", &items, &descs, &mut state);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(2)));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
match idx {
0 => ini_backup_action(terminal, app, &config_path, &backup_root)?,
1 => ini_restore_action(terminal, app, &config_path, &backup_root)?,
2 => ini_delete_action(terminal, app, &config_path, &backup_root)?,
_ => {}
}
}
KeyCode::Esc => break,
_ => {}
}
}
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
Ok(())
}
fn ini_backup_action<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
config_path: &Path,
backup_root: &Path,
) -> Result<()> {
if guard::game_running() {
app.set_status("Subnautica 2 is running — files may be locked.", tui::StatusStyle::Warning);
}
match ops::backup_ini_files(config_path, backup_root) {
Ok(result) => {
let verified = if result.verified { "verified" } else { "unverified" };
app.set_status(
&format!("Config backup created: {} files ({})", result.files_copied, verified),
tui::StatusStyle::Success,
);
guard::log_action("CONFIG_BAK", &result.dest_dir.display().to_string(), "OK", &app.log_path)?;
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
}
Err(e) => {
app.set_status(&format!("Config backup failed: {e}"), tui::StatusStyle::Error);
}
}
wait_for_key(terminal, app)?;
Ok(())
}
fn ini_restore_action<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
config_path: &Path,
backup_root: &Path,
) -> Result<()> {
let backups = ops::list_ini_backups(backup_root);
if backups.is_empty() {
ok_dialog(terminal, app, "No .ini Backups", "No .ini backups found.\nUse 'Backup .ini files' first.")?;
return Ok(());
}
let items: Vec<String> = backups
.iter()
.map(|p| {
let name = p.file_name().unwrap().to_string_lossy();
format!(" {name}")
})
.collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let descs: Vec<&str> = vec!["Restore this .ini backup"; backups.len()];
let mut state = ListState::default().with_selected(Some(0));
loop {
terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(backups.len().saturating_sub(1))));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
let chosen = &backups[idx];
guard::log_action("AUTO_BAK", &format!("ini pre-restore → {}", config_path.display()), "OK", &app.log_path)?;
match ops::restore_ini_files(chosen, config_path, backup_root) {
Ok(()) => {
guard::log_action("CONFIG_RESTORE", &chosen.display().to_string(), "OK", &app.log_path)?;
ok_dialog(terminal, app, ".ini Restore Complete", ".ini files restored.")?;
}
Err(e) => {
ok_dialog(terminal, app, ".ini Restore Failed", &format!("{e}"))?;
}
}
break;
}
KeyCode::Esc => break,
_ => {}
}
}
Ok(())
}
fn ini_delete_action<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
config_path: &Path,
backup_root: &Path,
) -> Result<()> {
match ops::delete_ini_files(config_path, backup_root) {
Ok(n) => {
let msg = format!("Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch.");
guard::log_action("CONFIG_DEL", &config_path.display().to_string(), "OK", &app.log_path)?;
ok_dialog(terminal, app, ".ini Delete Complete", &msg)?;
}
Err(e) => {
ok_dialog(terminal, app, ".ini Delete Refused", &format!("{e}"))?;
}
}
Ok(())
}
// ── inspect saves ──────────────────────────────────────────────────────────
fn action_inspect_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
let mut files: Vec<_> = std::fs::read_dir(&save_folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
let n = e.file_name();
let s = n.to_string_lossy();
s.starts_with("savegame_") && (s.ends_with(".sav") || s.ends_with(".bak"))
})
.collect();
files.sort_by_key(|e| e.file_name());
let mut labelled = std::collections::HashSet::new();
let items: Vec<String> = files.iter().map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let slot = crate::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into());
let num = slot_number(&slot);
let first = labelled.insert(slot.clone());
let label = if first { format!("Slot {num}") } else { String::new() };
let sz = e.metadata().map(|m| m.len()).unwrap_or(0);
format!(" {:<8} {:<30} {:>7}", label, name, format_size(sz))
}).collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let filenames: Vec<String> = files.iter().map(|e| e.file_name().to_string_lossy().to_string()).collect();
let descs = vec!["Press Enter to view full GVAS metadata"; files.len()];
let desc_refs: Vec<&str> = descs.iter().map(|s| *s).collect();
let mut state = ListState::default().with_selected(Some(0));
loop {
let selected_info = state.selected().and_then(|i| filenames.get(i)).map(|s| s.as_str());
terminal.draw(|f| {
tui::draw_picker_with_info(f, &app.tui_state, &item_refs, &desc_refs, &mut state, selected_info);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Up => { let i = state.selected().unwrap_or(0); state.select(Some(i.saturating_sub(1))); }
KeyCode::Down => { let i = state.selected().unwrap_or(0); state.select(Some((i+1).min(files.len().saturating_sub(1)))); }
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
let path = files[idx].path();
match crate::gvas::extract_full_metadata(&path) {
Ok(meta) => {
let dim = Style::default().fg(Color::DarkGray);
let val = Style::default().fg(Color::White);
let hl = Style::default().fg(Color::Cyan);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(filenames[idx].clone(), hl.add_modifier(Modifier::BOLD))),
Line::from(""),
];
let fields: Vec<(&str, &str, String)> = vec![
("SlotName", ":", meta.slot_name.as_deref().unwrap_or("?").into()),
("DisplayName", ":", meta.display_name.as_deref().unwrap_or("(unnamed)").into()),
("Online", ":", (if meta.is_online { "yes" } else { "no" }).into()),
("Was Multi", ":", (if meta.was_multiplayer { "yes" } else { "no" }).into()),
("GameMode", ":", meta.game_mode.as_deref().unwrap_or("?").into()),
("Level", ":", meta.level_name.as_deref().unwrap_or("?").into()),
("Build", ":", meta.build_number.map_or("?".into(), |n| n.to_string())),
("Branch", ":", meta.build_branch.as_deref().unwrap_or("?").into()),
("Saves", ":", meta.saves_count.map_or("?".into(), |n| n.to_string())),
("Ver", ":", meta.latest_version.map_or("?".into(), |n| n.to_string())),
("DataVer", ":", meta.data_version.map_or("?".into(), |n| n.to_string())),
];
let max_label: usize = fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8);
for (key, sep, value) in fields {
let padded = format!("{:<max_label$}{sep} ", key);
lines.push(Line::from(vec![
Span::styled(padded, dim),
Span::styled(value, val),
]));
}
ok_dialog_styled(terminal, app, "GVAS Metadata", &lines)?;
}
Err(e) => {
ok_dialog(terminal, app, "Parse Error", &format!("{e}"))?;
}
}
}
KeyCode::Esc => break,
_ => {}
}
}
Ok(())
}
// ── helpers ────────────────────────────────────────────────────────────────
/// Ensure we have a save folder — prompt discovery if not.
fn ensure_save_folder<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) -> Result<PathBuf> {
// Try cached path first
if let Some(ref sf) = app.save_folder {
if sf.exists() {
return Ok(sf.clone());
}
}
// Discover
let folders = discovery::discover_save_folders();
if let Some(first) = folders.first() {
app.save_folder = Some(first.path.clone());
app.config.last_path = Some(first.path.display().to_string());
crate::config::save_config(&app.config_path, &app.config)?;
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
return Ok(first.path.clone());
}
anyhow::bail!("No save folders found. Run 'Locate Subnautica save files' from the main menu.")
}
/// Get or discover the Config\Windows path.
fn get_config_path<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) -> Result<PathBuf> {
// Try cached config path
if let Some(ref cp) = app.config.config_path {
let p = PathBuf::from(cp);
if p.exists() {
return Ok(p);
}
}
// Derive from save folder
if let Some(ref sf) = app.save_folder {
if let Some(cp) = discovery::derive_config_path(sf) {
return Ok(cp);
}
}
// Fall back to discovery
let folders = discovery::discover_save_folders();
for f in &folders {
if let Some(cp) = discovery::derive_config_path(&f.path) {
app.config.config_path = Some(cp.display().to_string());
crate::config::save_config(&app.config_path, &app.config)?;
return Ok(cp);
}
}
anyhow::bail!("Cannot determine Config/Windows path. Run 'Locate Subnautica save files' first.")
}
/// Derive the target .sav name from a .bak filename.
/// Read the next keyboard event, silently skipping KeyRelease events.
/// Key-repeat and initial press are both accepted.
fn read_key_event() -> Result<crossterm::event::KeyEvent> {
loop {
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Release {
return Ok(key);
}
}
}
// Other events (terminal changes etc) trigger a loop → redraw
}
}
/// Determine if a save folder is cloud-backed (Xbox/Game Pass wgs path).
fn is_cloud_path(path: &Path) -> bool {
let s = path.to_string_lossy().to_lowercase();
s.contains("\\packages\\") || s.contains("/packages/") || s.contains("\\wgs") || s.contains("/wgs")
}
/// Extract the numeric suffix from a slot name like "savegame_3".
fn slot_number(slot: &str) -> String {
slot.strip_prefix("savegame_")
.map(|s| s.to_string())
.unwrap_or_else(|| slot.to_string())
}
fn ok_dialog_styled<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, lines: &[Line]) -> Result<()> {
loop {
terminal.draw(|f| tui::draw_ok_dialog_styled(f, &app.tui_state, title, lines))?;
let key = read_key_event()?;
if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
return Ok(());
}
}
}
fn ok_dialog<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, msg: &str) -> Result<()> {
loop {
terminal.draw(|f| tui::draw_ok_dialog(f, &app.tui_state, title, msg))?;
let key = read_key_event()?;
if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
return Ok(());
}
}
}
fn has_existing_backup(app: &App) -> bool {
let root = app.backup_root();
root.exists() && std::fs::read_dir(&root).map_or(false, |mut d| d.any(|e| e.map_or(false, |e| e.path().is_dir())))
}
fn require_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<bool> {
if has_existing_backup(app) {
return Ok(true);
}
app.set_status("No backup found — create a full backup before destructive actions.", tui::StatusStyle::Error);
wait_for_key(terminal, app)?;
Ok(false)
}
fn derive_target_sav(bak_name: &str) -> String {
crate::gvas::derive_slot_from_filename(bak_name)
.map(|s| format!("{s}.sav"))
.unwrap_or_else(|| bak_name.replace(".bak", ".sav"))
}
/// Show a confirmation popup with [ Yes ] [ No ] buttons.
fn confirm_modal<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
title: &str,
details: &[(&str, &str)],
) -> Result<bool> {
let mut selected_yes = true;
loop {
terminal.draw(|f| {
if f.area().width < 60 || f.area().height < 15 {
draw_too_small(f);
return;
}
tui::draw_confirm_popup(f, &app.tui_state, title, details, selected_yes);
})?;
let key = read_key_event()?;
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
selected_yes = !selected_yes;
}
KeyCode::Char('y') | KeyCode::Char('Y') => return Ok(true),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => return Ok(false),
KeyCode::Enter => return Ok(selected_yes),
_ => {}
}
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
/// Wait for any key press, showing the current state.
fn wait_for_key<B: Backend>(terminal: &mut Terminal<B>, app: &App) -> Result<()> {
loop {
terminal.draw(|f| {
let prompt_span = Span::styled(
"Press any key to return to menu…",
Style::default().fg(Color::DarkGray),
);
let p = Paragraph::new(prompt_span)
.alignment(Alignment::Center);
f.render_widget(p, centered_bottom(f.area()));
// Also show the status message if set
if let Some(ref msg) = app.tui_state.status_message {
let color = match app.tui_state.status_style {
tui::StatusStyle::Success => Color::Green,
tui::StatusStyle::Warning => Color::Yellow,
tui::StatusStyle::Error => Color::Red,
tui::StatusStyle::Info => Color::Cyan,
tui::StatusStyle::Neutral => Color::Gray,
};
let line = Line::from(Span::styled(format!(" [{msg}]"), Style::default().fg(color)));
f.render_widget(
Paragraph::new(line),
Rect {
x: f.area().x,
y: f.area().height.saturating_sub(2),
width: f.area().width,
height: 1,
},
);
}
})?;
if let Event::Key(_) = event::read()? {
break;
}
}
Ok(())
}
fn centered_bottom(area: Rect) -> Rect {
Rect {
x: area.x,
y: area.height.saturating_sub(1),
width: area.width,
height: 1,
}
}
/// Draw "terminal too small" message.
fn draw_too_small(f: &mut Frame) {
let msg = "Terminal too small (min 60×15)";
let p = Paragraph::new(Span::styled(
msg,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
let area = centered_rect(50, 20, f.area());
f.render_widget(p, area);
}
/// Read file metadata.
fn fs_meta(path: &Path) -> Result<std::fs::Metadata, ()> {
std::fs::metadata(path).map_err(|_| ())
}
/// Format a byte size human-readably.
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.0} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}