v0.4.1: persistent config, split-layout picker, backup location, security docs

This commit is contained in:
2026-06-09 11:39:51 +02:00
parent e2ed45c444
commit 73147b6be1
16 changed files with 804 additions and 111 deletions
+52
View File
@@ -4,6 +4,58 @@ All notable changes to NotAlterra are documented in this file.
---
## [v0.4.1] — 2026-06-09
### Added
- **Split-layout backup picker** — pip (`►`) highlight replaces background bar,
right pane shows live GVAS metadata for the selected backup (loads on
highlight, cached per file)
- **Persistent config** (`app.ini`) — save-folder and backup-root paths now
survive sessions, stored under `data_local_dir/NotAlterra/config/`
- **Set backup location** menu entry — choose where save and UE5 backup
archives are stored; defaults to `~/NotAlterra`
- **Blank separator lines** in menus — visual grouping, auto-skipped on
navigation
- **`docs/CVE_TEMPLATE.md`** — structured vulnerability disclosure template
- **`docs/BUG_REPORT_TEMPLATE.md`** — user-facing bug report reference with
privacy warnings
- **`.github/PULL_REQUEST_TEMPLATE.md`** — PR checklist matching CI gates
- **Safe harbor clause** in `SECURITY.md` — legal protection for good-faith
security researchers
### Changed
- **File picker layout** — horizontal 60/40 split: file list on left,
metadata preview on right. Slot, Description, Date columns only
- **Main menu pip highlight** — `►` replaces full-row cyan background
(legacy highlight code retained for other pickers)
- **Config directory** — `app.ini` and sentinel moved from `exe_dir/` to
`data_local_dir/NotAlterra/config/` (platform-standard)
- **Logs directory** — `transaction.log` moved from `exe_dir/logs/` to
`data_local_dir/NotAlterra/logs/`
- **UE5 ini backups** — stored in `backups/ue5/` under the backup root
instead of `backups/config/`
- **Menu labels** — "Set save folder" renamed to "Set Subnautica 2 location",
descriptions updated
- **Local builds** — compiled with `cargo build` for both Linux and Windows
targets
### Removed
- **"Inspect save files" menu entry** — metadata is now visible inline in
the backup picker right pane. `action_inspect_saves()` code retained.
- **`i` key handler** in backup picker — redundant with live metadata pane
- **Outdated privacy claims** — docs no longer state "no data stored" or
"session-only"; now accurately describe `app.ini` persistence
### Security
- **GitHub Private Vulnerability Reporting** as primary disclosure channel,
email as fallback
- **Safe harbor** — explicit no-litigation commitment for good-faith
reporters following the disclosure policy
- **CVSS v4 scoring** documented in Dependabot advisory for `lru`
transitive dependency (low severity, not actionable)
- All paths in `app.ini` documented as potentially containing the system
username — plain text, never transmitted
## [v0.4.0] — 2026-06-03
### Added
Generated
+1 -1
View File
@@ -540,7 +540,7 @@ dependencies = [
[[package]]
name = "notalterra"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "notalterra"
version = "0.4.0"
version = "0.4.1"
edition = "2021"
authors = ["NotAlterra"]
license = "MIT"
+35 -16
View File
@@ -88,10 +88,9 @@ path manually (paste is supported). The menu is keyboard-driven:
2. **Recover save file** — pick a backup, preview metadata, overwrite the live save
3. **Create full backup** — copies all `savegame_*` files to `NotAlterra_Backups`
4. **Restore full backup** — overwrite the save folder from a previous backup
5. **Inspect save files** — view all GVAS properties of any `.sav` / `.bak`
6. **Manage UE5 Config (.ini) files** — backup, restore, or delete `.ini` files
7. **View disclaimer**
8. **Exit**
5. **Manage UE5 Config (.ini) files** — backup, restore, or delete `.ini` files
6. **View disclaimer**
7. **Exit**
## Where Files Live
@@ -114,22 +113,42 @@ path manually (paste is supported). The menu is keyboard-driven:
...
```
Use **Set save folder** from the menu to enter your save path (paste is
supported). The path exists only in memory for the current session —
re-enter it the next time you run the tool.
Backups are stored in `NotAlterra_Backups\` alongside the binary.
Use **Set Subnautica 2 location** from the menu to enter your save path
(paste supported). The path is persisted to `app.ini` and restored on
next launch.
## Session Persistence
No configuration file is written to disk. The save folder path exists only
in memory for the current session — set it each time via **Set save folder**
from the main menu (paste is supported).
Configuration is stored in the standard platform config directory:
The disclaimer acceptance is tracked via a 0-byte sentinel file
(`NotAlterra_LICENSE_ACCEPTED`) alongside the binary. Delete this file to
re-prompt the disclaimer on next launch.
| Platform | Config path |
|---|---|
| Windows | `%LOCALAPPDATA%\NotAlterra\config\app.ini` |
| Linux | `~/.local/share/NotAlterra/config/app.ini` |
The `app.ini` file stores your save-folder path, backup location, and
disclaimer acceptance. It is auto-created when you first set any of
these options. Delete `NOTALTERRA_LICENSE_ACCEPTED` in the same directory
to re-prompt the disclaimer on next launch.
> **Privacy note**: `app.ini` stores your save-folder and backup paths —
> the minimum needed to avoid re-entering them each session. These paths
> may reveal your system username (e.g. `C:\Users\jane\...`). The
> information never leaves your machine — NotAlterra has no network
> access and no telemetry. The file is plain text; you can inspect or
> delete it at any time.
Backup archives are stored in your user data directory by default:
| Platform | Backup root |
|---|---|
| Windows | `C:\Users\<you>\NotAlterra\backups\saves\` |
| Linux | `~/NotAlterra/backups/saves/` |
UE5 Config `.ini` backups go into `backups/ue5/` under the same root.
You can change the backup root at any time via **Set backup location**
in the main menu. The path is persisted in `app.ini`.
## Platform Support
+7 -4
View File
@@ -6,10 +6,13 @@ extract, and run.
### What's new in v0.4.1
Log migration — existing `transaction.log` is moved into `logs/` on first launch
Backup directory structure scaffolded at startup (`backups/saves/`, `backups/config/`, `logs/`)
`ensure_dir()` helper for consistent directory creation
All migration paths integrated into startup (config.ini, backups, log)
Persistent `app.ini` config — save folder and backup location survive sessions
Split-layout backup picker with inline GVAS metadata preview
`►` pip highlight replaces background bar on all menus
Set backup location menu entry (defaults to `~/NotAlterra/`)
• Security disclosure pipeline: SECURITY.md safe harbor, CVE template, bug report template, PR template
• Config moved to platform-standard directory (`AppData/Local` on Windows)
• UE5 ini backups stored in `backups/ue5/` under the backup root
### What's new in v0.4.0
+1 -1
View File
@@ -24,7 +24,7 @@ What happened? What did you expect to happen instead?
### Environment
- **OS**: (e.g. Windows 11, Ubuntu 24.04, Steam Deck)
- **NotAlterra version**: (shown in the title bar, e.g. v0.4.0)
- **NotAlterra version**: (shown in the title bar, e.g. v0.4.1)
- **Subnautica 2 install**: (Steam, Xbox, Epic, custom)
### Logs
+13 -12
View File
@@ -32,23 +32,24 @@ the project's GPG key.
## Privacy
NotAlterra does not collect, transmit, or store any personal user data.
The application runs entirely offline:
NotAlterra does not collect or transmit any personal user data — it has
no network access, no telemetry, and no analytics. The application runs
entirely offline:
- No telemetry, no analytics, no crash reporters.
- No network requests — the binary never opens a socket.
- All configuration is stored locally in `config.ini` alongside the
executable.
- Configuration is stored locally in `app.ini` under the platform config
directory (e.g. `%LOCALAPPDATA%\NotAlterra\config\app.ini`).
The only potentially identifying information stored is the game's
save-folder path in `config.ini`, which includes the current Windows
username. This path never leaves the local machine — it is read once on
startup and used exclusively to locate saves and configuration files.
The `app.ini` file stores your save-folder path and backup root — the
minimum needed to avoid re-entering them each session. These paths may
include the current system username (e.g. `C:\Users\jane\...`). This
information never leaves the local machine. The file is plain text and
can be inspected or deleted at any time.
Because no data is collected or transmitted, there is nothing to share,
sell, or expose. This section serves as a safe-harbor statement:
NotAlterra is designed to respect user privacy by collecting nothing at
all.
Because no data is transmitted, there is nothing to share, sell, or
expose. This section serves as a safe-harbor statement: NotAlterra is
designed to respect user privacy by never sending data anywhere.
## Signing
> **Status: pending certification.** No binaries have been signed by
+1 -1
View File
@@ -55,7 +55,7 @@ triggers the issue.
### Affected versions
- NotAlterra version(s): (e.g. v0.4.0)
- NotAlterra version(s): (e.g. v0.4.1)
- Platform: (Windows / Linux / both)
### Impact assessment
+104
View File
@@ -110,3 +110,107 @@ does not require decompressing the entire archive.
Existing `NotAlterra_Backups/` directory-tree backups are detected and
transparently imported on first run after upgrade. No manual migration
required.
---
## GVAS Heuristic Parser vs Structural Walker (v0.4.0)
### Problem
The GVAS save-file parser in `src/gvas.rs` uses byte-scanning to find
property names as raw string patterns. It does not walk the full UE5 GVAS
schema tree. This means:
- Properties the scanner isn't written to look for are silently skipped
- Byte sequences that happen to match a property name can produce false
positives (mitigated by validating the preceding length field)
- Overall GVAS structure (header magic, version, property ordering) is not
validated
The GVAS format is defined by the public Unreal Engine 5 source code — the
SaveGame system and its binary serialization are part of the engine's
open API.
### Decision
Keep the heuristic byte-scan parser. Marked as Won't fix.
### Rationale
**No published schema** — Unknown Worlds does not publish the GVAS property
layout for Subnautica 2. A structural walker would require reverse-
engineering the full property type system without ground truth.
**No user-facing benefit** — the tool reads six properties (SlotName,
DisplayName, bIsMultiplayerSave, playtime, etc.) for display purposes. The
byte-scan finds all of them reliably on known save files. A structural
walker would produce the same output.
**Graceful degradation** — when the scanner cannot find a property, the
picker falls back to the filename. The tool never crashes or presents
incorrect data.
**Fuzz coverage** — three fuzz targets (`parse_gvas`, `full_metadata`,
`backup_roundtrip`) verify the parser does not panic on adversarial input.
If a future game update changes the binary layout, fuzzing will catch it.
---
## Dead-Code Cleanup (v0.4.0)
### Problem
`src/main.rs` had `#![allow(dead_code)]` at the crate level, silencing
compiler warnings for four unused functions. This prevented the compiler
from flagging real dead code.
### Decision
Remove `_game_running_windows()` and `_backup_root()`. Retain
`_game_running_linux()` and `available_space()` for planned future use.
### Rationale
**Removed:**
- `_game_running_windows()` — process detection via `tasklist` was
intentionally disabled across the project to avoid Windows Defender
false positives (Trojan:Win32/Wacatac.C!ml). The startup reminder modal
is the replacement. No plan to re-enable.
- `_backup_root()` — returned the legacy `NotAlterra_Backups/` path,
replaced by `backups/saves/` and `backups/config/` in v0.4.0.
**Retained (each with its own `#[allow(dead_code)]`):**
- `_game_running_linux()` — kept for possible opt-in process detection
on Linux where AV false positives are not a concern.
- `available_space()` — kept for a planned disk-space warning before
backup operations.
---
## Persistent app.ini vs Session-Only Paths (v0.4.1)
### Problem
Save-folder and backup-root paths were session-only — re-entered each
time the tool launched. This was a deliberate privacy choice (v0.3.2),
but in practice users expected paths to persist between sessions.
### Decision
Persist both paths to `app.ini` under the platform config directory
(`data_local_dir/NotAlterra/config/`). Backup data stays in
`~/NotAlterra/backups/` (user-facing, not in AppData).
### Rationale
**Usability** — re-entering paths every session was friction with no
real privacy benefit. The paths already existed on the user's filesystem;
persisting them simply saves keystrokes.
**Transparency, not silence** — instead of claiming "no data stored,"
the documentation now accurately describes what is stored (save-folder
and backup-root paths, which may contain the system username), why
(minimal convenience data), and that it never leaves the machine.
**Platform standards** — config goes to the OS-designated config
directory (`AppData/Local` on Windows, `~/.local/share` on Linux).
User-facing backup data stays in the home directory where users expect
to find it. This separation is standard convention.
**Plain text, user-controlled**`app.ini` is a simple key=value file.
Users can inspect or delete it at any time. No binary format, no
registry, no opaque storage.
+2 -2
View File
@@ -99,9 +99,9 @@ Planned changes for upcoming releases, ordered by priority.
| Target | Item |
|--------|------|
| v0.4.0 | ✅ All v0.4.0 items completed — released 2026-06-03 |
| v0.4.1 | ✅ Persistent app config, backup location picker, split-layout metadata, security docs, pip menus — released 2026-06-09 |
| v0.5.0 | CLI flags: `--backup`, `--extract <archive>`, `--inspect <savefile>` (`.sav`/`.bak`), `--list` |
| v0.5.0 | Add migration notification dialog on startup (user sees old backups converted, old files untouched) |
| v0.5.0 | Move existing `transaction.log` into `logs/` directory on first launch |
| v0.5.0 | Migration notification dialog on startup (user sees old backups converted, old files untouched) |
Items may shift between releases depending on feedback and urgency.
+89 -4
View File
@@ -1,8 +1,93 @@
# Known Issues
## Stale config.ini from prior versions
## `game_running()` returns hardcoded `false`
Users upgrading from v0.3.0 or earlier will have a `config.ini` file next to
the binary that no longer serves any function. It can be safely deleted.
The process-detection guard in `guard.rs` intentionally returns `false` on
both platforms to avoid Windows Defender false positives
(Trojan:Win32/Wacatac.C!ml). The startup reminder modal ("Please close
Subnautica 2 before using NotAlterra") is the real safety mechanism, but
the function name implies active detection.
**Planned**: Auto-remove stale `config.ini` on first launch after upgrade.
The dormant `_game_running_linux()` function (kept for future use) contains
a working code path that is unreachable behind the hardcoded `false` return.
## GVAS parser is heuristic, not a structural walker
`src/gvas.rs` extracts GVAS properties by scanning for property names as
raw byte sequences and validating preceding length fields — it does not
walk the full GVAS schema tree. It works on known Subnautica 2 save files
but:
> The GVAS format is defined by the public Unreal Engine 5 source code —
> the SaveGame system and its binary layout are part of the engine's
> open API.
- Will silently skip properties it wasn't written to look for
- May produce false positives if a matching byte sequence appears in
unrelated data
- Does not validate overall GVAS structure (header magic, version, etc.)
**Decision**: Won't fix. A structural walker would require reverse-
engineering the full UE5 GVAS property tree without a published schema,
with no significant benefit for the tool's feature set. The six properties
the UI displays (SlotName, DisplayName, bIsMultiplayerSave, playtime, etc.)
are found reliably by the current approach. The three fuzz targets
(`parse_gvas`, `full_metadata`, `backup_roundtrip`) ensure the parser does
not crash on adversarial input. When a property cannot be found, the picker
falls back to the filename — the tool degrades gracefully rather than
erroring out.
## No TUI / menu-flow test coverage
`src/main.rs` contains 1,227 lines of event-loop code (menu dispatch,
picker interactions, confirmation dialogs, .ini submenu) with zero
automated tests. All other modules (ops, gvas, guard, config) have
integration tests, but UI regressions are only caught through manual
testing.
This is common for terminal applications but means menu-flow changes
carry higher risk.
## Discovery module carries vestigial code
`src/discovery.rs` is 442 lines, but the primary save-folder workflow is
manual path entry via **Set save folder**. The module still contains:
- `scan_other_users()` — scans `/home/*` (Linux) or `C:\Users\*` (Windows)
for other user profiles
- `walk_for_subnautica()` — broad filesystem walk for custom installs
- `discover_save_folders()` — full discovery entry point
These were downgraded from primary to fallback in v0.3.2 for privacy
reasons, and `quick_discover()` (checking only the current user's default
paths) is now the only automated startup check. The heavy scanning code
could be removed if manual path entry remains the sole workflow.
## No CLI flags for scripting
The tool is TUI-only. There is no `--backup`, `--extract <archive>`,
`--inspect <savefile>`, or `--list` flag. This means it cannot be used in
cron jobs, scheduled tasks, or automated backup scripts.
**Planned**: v0.5.0 roadmap includes CLI flags.
## Bus factor mitigation documented but unimplemented
`docs/GOVERNANCE.md` describes a planned emergency signing key stored with
a non-technical trusted person. The envelope and key do not yet exist. The
designated technical contact is not confirmed. If the maintainer becomes
unreachable, the only viable path is a fork.
## Resolved
### Dormant functions removed (v0.4.0)
`_game_running_windows()` and `_backup_root()` were removed as dead code.
Neither was called from any code path. `_game_running_linux()` and
`available_space()` are retained for planned future use — each has its own
`#[allow(dead_code)]` annotation.
**Rationale**: `_game_running_windows()` was never wired up (process
detection was intentionally disabled to avoid AV false positives).
`_backup_root()` pointed at the legacy `NotAlterra_Backups/` directory
which was replaced by `backups/saves/` and `backups/config/` in v0.4.0.
+3 -2
View File
@@ -4,10 +4,11 @@ Privacy-first design principles and security practices.
## Privacy Principles
- **No sensitive paths written to disk** — user data is session-only when possible
- **Paths persisted to `app.ini`** — save-folder and backup-root paths survive
sessions; may contain the system username. Plain text, user can delete at will.
- **No scanning of user profiles or system directories** beyond the current user
- **All logged paths sanitized** to strip user-identifiable prefixes
- **User consent tracked via sentinel file**, not a config file with user data
- **Disclaimer consent tracked via sentinel file** alongside `app.ini`
## Input Handling
+118 -12
View File
@@ -1,15 +1,42 @@
//! Minimal path utilities — no persistent config.
//! Path utilities and persistent app configuration.
//!
//! Save folder and Config/Windows paths are now session-only, entered
//! via the `Set save folder` menu. The disclaimer acceptance is tracked
//! via a 0-byte sentinel file (`NotAlterra_LICENSE_ACCEPTED`) next to the
//! binary instead of a config file.
//! The save-folder path and backup root are persisted to `app.ini` under the
//! platform config directory so they survive between sessions. The disclaimer
//! acceptance is tracked via a sentinel file in the same directory.
//!
//! The backup root defaults to `~/NotAlterra` and can be changed via the
//! `Set backup location` menu item.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Path to the disclaimer sentinel file alongside the binary.
/// Custom backup root directory, set via the `Set backup location` menu.
/// When `None`, falls back to `exe_dir().join("backups")`.
static BACKUP_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Set a custom backup root directory. Passes ownership.
pub fn set_backup_root(path: PathBuf) {
if let Ok(mut root) = BACKUP_ROOT.lock() {
*root = Some(path);
}
}
/// Return the current backup root, or the default user-profile path.
pub fn get_backup_root() -> PathBuf {
BACKUP_ROOT
.lock()
.ok()
.and_then(|r| r.clone())
.unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(exe_dir)
})
}
/// Path to the disclaimer sentinel file in the config directory.
pub fn sentinel_path() -> PathBuf {
exe_dir().join("NotAlterra_LICENSE_ACCEPTED")
config_base_dir().join("NOTALTERRA_LICENSE_ACCEPTED")
}
/// Return `true` if the disclaimer sentinel exists.
@@ -39,22 +66,101 @@ pub fn cleanup_stale_config() -> bool {
}
}
/// Path to the `backups/saves/` directory (tar.gz archives).
/// Path to the `saves/` directory under the backup root (tar.gz archives).
/// Auto-creates the directory on first call.
pub fn backups_saves_dir() -> PathBuf {
let p = exe_dir().join("backups").join("saves");
let p = get_backup_root().join("backups").join("saves");
std::fs::create_dir_all(&p).ok();
p
}
/// Path to the `backups/config/` directory (.ini tar.gz archives).
/// Auto-creates the directory on first call.
/// Path to the `ue5/` subdirectory under the backup root for UE5 Config `.ini`
/// backup archives. Auto-creates the directory on first call.
pub fn backups_config_dir() -> PathBuf {
let p = exe_dir().join("backups").join("config");
let p = get_backup_root().join("backups").join("ue5");
std::fs::create_dir_all(&p).ok();
p
}
/// Return `~/NotAlterra` as the user's data directory for save/ue5 backups.
/// Falls back to `exe_dir()` if home is not available.
fn home_notalterra_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(exe_dir)
}
// ── persistent app config ────────────────────────────────────────────────────
/// Fixed base directory for `app.ini` and the sentinel file, under the
/// standard platform config location. Separate from backup data so that
/// `~/NotAlterra` remains the user's visible backup data directory.
fn config_base_dir() -> PathBuf {
dirs::data_local_dir()
.map(|d| d.join("NotAlterra").join("config"))
.unwrap_or_else(exe_dir)
}
/// Path to the persistent `app.ini` configuration file.
pub fn app_ini_path() -> PathBuf {
let p = config_base_dir().join("app.ini");
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
p
}
/// Session-lifetime configuration loaded from and persisted to `app.ini`.
#[derive(Debug, Default)]
pub struct AppConfig {
pub save_folder: Option<String>,
pub backup_root: Option<String>,
}
/// Load `app.ini` from the fixed config directory.
/// Returns default (empty) values if the file does not exist or cannot be read.
pub fn load_app_config() -> AppConfig {
let path = app_ini_path();
if !path.exists() {
return AppConfig::default();
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return AppConfig::default(),
};
let mut cfg = AppConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let val = value.trim();
match key {
"save_folder" => cfg.save_folder = Some(val.to_string()),
"backup_root" => cfg.backup_root = Some(val.to_string()),
_ => {}
}
}
}
cfg
}
/// Write the current session paths to `app.ini` in the fixed config directory.
pub fn save_app_config(save_folder: Option<&str>, backup_root: Option<&str>) {
let path = app_ini_path();
let mut content = String::from(
"# NotAlterra configuration\n\
# This file is auto-generated. Edit while the tool is not running.\n\n",
);
if let Some(s) = save_folder {
content.push_str(&format!("save_folder = {s}\n"));
}
if let Some(r) = backup_root {
content.push_str(&format!("backup_root = {r}\n"));
}
let _ = std::fs::write(&path, content);
}
/// Ensure a directory exists, creating all parents as needed.
pub fn ensure_dir(path: PathBuf) {
let _ = std::fs::create_dir_all(&path);
+6 -15
View File
@@ -24,18 +24,6 @@ pub fn game_running() -> bool {
// Close Subnautica 2 manually before using NotAlterra.
false
}
#[allow(dead_code)]
/// Check if Subnautica 2 is running via tasklist (Windows). Dormant.
fn _game_running_windows() -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq Subnautica2.exe", "/NH"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains("Subnautica2.exe"),
Err(_) => false,
}
}
/// Check if Subnautica 2 is currently running (Linux).
///
/// Always returns `false` — process detection via `pgrep` is disabled to
@@ -100,10 +88,13 @@ pub fn migrate_old_log() -> bool {
true
}
/// Path to `transaction.log` inside the `logs/` directory. All timestamped
/// actions are appended here for audit trail purposes.
/// Path to `transaction.log` inside the `logs/` directory under the
/// platform config root. All timestamped actions are appended here for
/// audit trail purposes.
pub fn log_path() -> PathBuf {
let p = exe_dir().join("logs").join("transaction.log");
let p = dirs::data_local_dir()
.map(|d| d.join("NotAlterra").join("logs").join("transaction.log"))
.unwrap_or_else(|| exe_dir().join("logs").join("transaction.log"));
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).ok();
}
+207 -32
View File
@@ -95,11 +95,6 @@ impl App {
})
}
/// Returns the backup root directory alongside the binary.
fn _backup_root(&self) -> PathBuf {
exe_dir().join("NotAlterra_Backups")
}
/// Set the status bar message with optional style.
fn set_status(&mut self, msg: &str, style: tui::StatusStyle) {
self.tui_state.status_message = Some(msg.to_string());
@@ -196,6 +191,22 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
}
}
// Load persistent app config — may override quick_discover
let app_cfg = crate::config::load_app_config();
if let Some(sf) = app_cfg.save_folder {
let p = PathBuf::from(&sf);
if p.exists() {
app.save_folder = Some(p);
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
}
}
if let Some(br) = app_cfg.backup_root {
let p = PathBuf::from(&br);
if p.exists() {
crate::config::set_backup_root(p);
}
}
// Disclaimer flow
if !crate::config::disclaimer_accepted() {
match run_disclaimer(terminal, &mut app)? {
@@ -218,34 +229,43 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
tui::draw_main_menu(f, &mut menu_state, &app.tui_state);
})?;
let max_idx = 7usize;
const SKIP: &[usize] = &[2, 6, 8];
let max_idx = 10usize;
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)));
let mut i = menu_state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !SKIP.contains(&i) || i == 0 { break; }
}
menu_state.select(Some(i));
}
KeyCode::Down => {
let i = menu_state.selected().unwrap_or(0);
menu_state.select(Some((i + 1).min(max_idx)));
let mut i = menu_state.selected().unwrap_or(0);
loop {
i = (i + 1).min(max_idx);
if !SKIP.contains(&i) || i == max_idx { break; }
}
menu_state.select(Some(i));
}
KeyCode::Enter => {
let idx = menu_state.selected().unwrap_or(0);
match idx {
0 => action_set_save_folder(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 => {
3 => action_set_backup_location(terminal, &mut app)?,
4 => action_create_backup(terminal, &mut app)?,
5 => action_restore_backup(terminal, &mut app)?,
7 => run_ini_submenu(terminal, &mut app)?,
9 => {
if let Some(false) = run_disclaimer(terminal, &mut app)? {
return Ok(());
}
}
7 => return Ok(()), // Exit
10 => return Ok(()), // Exit
_ => {}
}
app.clear_status();
@@ -335,6 +355,10 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
if let Some(path) = candidate {
app.save_folder = Some(path.clone());
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
crate::config::save_app_config(
Some(&sanitized),
Some(&crate::config::get_backup_root().to_string_lossy()),
);
let msg = format!("Save folder set to {}", path.display());
app.set_status(&msg, tui::StatusStyle::Success);
input_state.confirmed = true;
@@ -392,6 +416,83 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
}
}
/// Open the input dialog for the user to set a custom backup location.
/// Pre-fills with the default `home_dir/NotAlterra` path for easy editing.
fn action_set_backup_location<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let default = dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(|| crate::config::exe_dir());
let mut input_state = tui::InputDialogState::new(
"Enter the path for storing backup archives:",
);
input_state.input = default.to_string_lossy().to_string();
input_state.cursor = input_state.input.len();
let mut ok_selected = true;
loop {
terminal.draw(|f| {
tui::draw_input_dialog(f, &app.tui_state, &input_state, ok_selected);
})?;
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
match crossterm::event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Release { continue; }
match key.code {
KeyCode::Enter => {
if ok_selected && !input_state.input.is_empty() {
let sanitized: String = input_state.input.chars()
.filter(|c| !c.is_control())
.collect();
let path = std::path::PathBuf::from(&sanitized);
crate::config::set_backup_root(path.clone());
let save_str = app.save_folder.as_ref().map(|p| p.to_string_lossy().to_string());
crate::config::save_app_config(
save_str.as_deref(),
Some(&sanitized),
);
let msg = format!("Backup location set to {}", path.display());
app.set_status(&msg, tui::StatusStyle::Success);
input_state.confirmed = true;
return Ok(());
}
input_state.cancelled = true;
return Ok(());
}
KeyCode::Char(c) if ok_selected => {
input_state.insert(c);
}
KeyCode::Backspace if ok_selected => {
input_state.backspace();
}
KeyCode::Delete if ok_selected => {
input_state.delete();
}
KeyCode::Left if ok_selected => {
input_state.cursor_left();
}
KeyCode::Right if ok_selected => {
input_state.cursor_right();
}
KeyCode::Tab => {
ok_selected = !ok_selected;
}
KeyCode::Esc => {
input_state.cancelled = true;
return Ok(());
}
_ => {}
}
}
Event::Paste(s) if ok_selected => {
input_state.insert_str(&s);
}
_ => {}
}
}
}
}
/// Recover a .sav from its .bak backup with a rollback safety net.
fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
@@ -424,8 +525,8 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
// 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 header = format!(" {:<8} {:<16} {:<14} {:<8} {:>6} {}",
"Slot", "Description", "Game Type", "Playtime", "Size", "Date");
let header = format!(" {:<8} {:<24} {}",
"Slot", "Description", "Date");
let mut items: Vec<String> = vec![header, String::new()];
items.extend(bak_summaries
.iter()
@@ -440,15 +541,10 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
name.to_string()
};
let date = s.mtime.as_deref().unwrap_or("?");
let save_type = if s.is_online { "Multiplayer" } else { "Single Player" };
let playtime = format_playtime(s.playtime_seconds);
format!(
" {:<8} {:<16} {:<14} {:<8} {:>6} {}",
" {:<8} {:<24} {}",
label_col,
name_col,
save_type,
playtime,
format_size(s.size),
date,
)
})
@@ -467,19 +563,86 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect();
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
// Lazily-loaded full metadata cache — loaded on first highlight
let mut full_metas: Vec<Option<crate::gvas::FullMetadata>> = vec![None; bak_summaries.len()];
/// Load metadata for index `idx` if not already cached.
fn ensure_meta(
idx: usize,
bak: &[ops::BakFileSummary],
cache: &mut Vec<Option<crate::gvas::FullMetadata>>,
) {
if idx < cache.len() && cache[idx].is_none() {
cache[idx] = crate::gvas::extract_full_metadata(&bak[idx].path).ok();
}
}
/// Build right-pane display lines from cached metadata.
fn build_meta_lines<'a>(
meta: Option<&'a crate::gvas::FullMetadata>,
summary: &ops::BakFileSummary,
) -> Vec<Line<'a>> {
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
let Some(m) = meta else {
return Vec::new();
};
let pt = m.playtime_seconds.or(summary.playtime_seconds);
let playtime = format_playtime(pt);
let fields: Vec<(&str, String)> = vec![
("Slot", m.slot_name.as_deref().unwrap_or(&summary.slot).to_string()),
("Name", m.display_name.as_deref().unwrap_or("(unnamed)").to_string()),
("Playtime", playtime),
("Game Type", m.game_mode.as_deref().unwrap_or("?").to_string()),
("Mode", if m.is_online { "Multiplayer".into() } else { "Single Player".into() }),
("Was Multi", if m.was_multiplayer { "Yes".into() } else { "No".into() }),
("Branch", m.build_branch.as_deref().unwrap_or("?").to_string()),
("Build", m.build_number.map_or("?".into(), |n| n.to_string())),
];
let max_label: usize = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(6);
fields
.into_iter()
.map(|(k, v)| {
let padded_label = format!("{:<max_label$}", k);
Line::from(vec![
Span::styled(padded_label, Style::default().fg(Color::White)),
Span::raw(" "),
Span::styled(v, dim),
])
})
.collect()
}
loop {
let i = state.selected().unwrap_or(2).max(2);
state.select(Some(i));
let selected_info = filenames.get(i.saturating_sub(2)).map(|s| s.as_str());
let sel_idx = i.saturating_sub(2);
let selected_info = filenames.get(sel_idx).map(|s| s.as_str());
let meta_header = selected_info.map(|f| format!("Details for {}", f));
// Lazy-load metadata on highlight
if sel_idx < bak_summaries.len() {
ensure_meta(sel_idx, &bak_summaries, &mut full_metas);
}
let meta = full_metas.get(sel_idx).and_then(|m| m.as_ref());
let meta_lines = if let Some(m) = meta {
build_meta_lines(Some(m), &bak_summaries[sel_idx])
} else if sel_idx < bak_summaries.len() {
Vec::new()
} else {
Vec::new()
};
terminal.draw(|f| {
tui::draw_picker_with_info(
tui::draw_picker_split(
f,
&app.tui_state,
&item_refs,
&desc_refs,
&mut state,
selected_info,
meta_header.as_deref(),
&meta_lines,
);
})?;
if let Some(key) = poll_key(250)? {
@@ -715,15 +878,19 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
"Backup .ini files",
"Restore .ini files from backup",
"Delete .ini files (requires backup)",
"",
"Back",
];
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)",
"",
"Return to main menu",
];
let mut state = ListState::default().with_selected(Some(0));
const INI_SKIP: &[usize] = &[3];
let ini_max = 4usize;
loop {
terminal.draw(|f| {
@@ -732,12 +899,20 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
if let Some(key) = poll_key(250)? {
match key.code {
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
let mut i = state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !INI_SKIP.contains(&i) || i == 0 { break; }
}
state.select(Some(i));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(3)));
let mut i = state.selected().unwrap_or(0);
loop {
i = (i + 1).min(ini_max);
if !INI_SKIP.contains(&i) || i == ini_max { break; }
}
state.select(Some(i));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
@@ -745,7 +920,7 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
0 => ini_backup_action(terminal, app, &ini_path)?,
1 => ini_restore_action(terminal, app, &ini_path)?,
2 => ini_delete_action(terminal, app, &ini_path)?,
3 => break,
4 => break,
_ => {}
}
}
+164 -8
View File
@@ -78,22 +78,28 @@ impl Default for AppState {
/// Draw the main menu.
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
let items: Vec<&str> = vec![
" Set save folder",
" Set Subnautica 2 location",
" Recover save file",
"",
" Set backup location",
" Create full backup",
" Restore full backup",
" Inspect save files",
"",
" Manage UE5 Config (.ini) files",
"",
" View disclaimer",
" Exit",
];
let descs: Vec<&str> = vec![
"Enter your save folder path manually",
"Enter your Subnautica 2 save folder path (paste supported)",
"Restore a save file from a backup",
"",
"Choose where backup archives are stored (default: next to the binary)",
"Copy the savegame files to NotAlterra_Backups",
"Restore a full backup from NotAlterra_Backups",
"View detailed GVAS metadata for each save file",
"",
"Backup, restore, or delete .ini files in Config/Windows",
"",
"Re-read the disclaimer and terms of use",
"Close NotAlterra",
];
@@ -337,7 +343,7 @@ pub fn draw_picker_with_info(
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let prompt = "↑/↓ navigate Enter select Esc cancel";
let prompt = "↑/↓ navigate | Enter select | Esc cancel";
draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, selected_info);
draw_status_bar(f, chunks[3], app);
}
@@ -447,11 +453,10 @@ fn draw_select_list(
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
.highlight_symbol(" ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, list_area, state);
@@ -586,6 +591,157 @@ fn draw_select_list_with_info(
}
}
// ── pip-list renderer (for split-layout file picker) ─────────────────────
/// Render a compact pip-list without description line or prompt.
/// The pip (►) replaces the full-row background highlight.
fn draw_select_list_pip(
f: &mut Frame,
area: Rect,
items: &[&str],
state: &mut ListState,
) {
if area.height < 2 || area.width < 10 { return; }
let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == 0 {
// Header row — match right pane header color
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if i >= 2 {
// Data rows — match right pane value color
dim_val
} else {
Style::default()
};
ListItem::new(Span::raw(*item)).style(style)
})
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, area, state);
}
// ── right-pane metadata panel ────────────────────────────────────────────
/// Render the right-hand metadata pane in the split file picker.
/// Shows the filename header, a dim separator, then the provided content lines.
/// When `meta_lines` is empty, shows a placeholder message.
fn draw_right_pane(
f: &mut Frame,
area: Rect,
filename: &str,
meta_lines: &[Line],
) {
if area.height < 3 || area.width < 10 { return; }
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
// Filename header
let mut y = area.y;
let fname = if filename.len() as u16 > area.width.saturating_sub(2) {
format!("{}", &filename[..area.width.saturating_sub(3).max(1) as usize])
} else {
filename.to_string()
};
f.render_widget(
Paragraph::new(Span::styled(&fname, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
);
y += 2;
if meta_lines.is_empty() {
// Placeholder
let msg = if filename.is_empty() {
Span::styled("select a file", dim)
} else {
Span::styled("select to load metadata", dim)
};
f.render_widget(
Paragraph::new(msg),
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
);
return;
}
// Content lines
let max_lines = area.height.saturating_sub(2) as usize;
for (i, line) in meta_lines.iter().enumerate().take(max_lines) {
f.render_widget(
Paragraph::new(line.clone()),
Rect { x: area.x + 1, y: y + i as u16, width: area.width.saturating_sub(2), height: 1 },
);
}
}
// ── split-layout picker entry point ──────────────────────────────────────
/// Draw the file picker with a horizontal split: pip-style file list on the
/// left, live metadata preview on the right. Used by the .bak recover flow.
pub fn draw_picker_split(
f: &mut Frame,
app: &AppState,
items: &[&str],
_descs: &[&str],
state: &mut ListState,
selected_info: Option<&str>,
meta_lines: &[Line],
) {
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let menu_area = chunks[2];
let prompt = "↑/↓ navigate | Enter select | Esc cancel";
// Reserve bottom row of menu area for the prompt
let prompt_y = menu_area.y + menu_area.height.saturating_sub(1);
let content_area = Rect {
height: menu_area.height.saturating_sub(1),
..menu_area
};
// Split content into left (60%) and right (40%)
let halves = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(content_area);
// Left: pip list (full height of content area)
draw_select_list_pip(f, halves[0], items, state);
// Right: metadata pane
draw_right_pane(f, halves[1], selected_info.unwrap_or(""), meta_lines);
// Prompt at bottom-right of the full menu area
let prompt_len = prompt.len() as u16;
if menu_area.width > prompt_len + 2 {
f.render_widget(
Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Right),
Rect {
x: menu_area.x,
y: prompt_y,
width: menu_area.width.saturating_sub(2),
height: 1,
},
);
}
draw_status_bar(f, chunks[3], app);
}
/// Render the status bar at the bottom of the screen.
fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
draw_whale_separator(f, area, app);