From 73147b6be124ef242405f4423e4ac938c5d0b9c8 Mon Sep 17 00:00:00 2001 From: forkless Date: Tue, 9 Jun 2026 11:35:45 +0200 Subject: [PATCH] v0.4.1: persistent config, split-layout picker, backup location, security docs --- CHANGELOG.md | 52 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 51 ++++--- _release.md | 11 +- docs/BUG_REPORT_TEMPLATE.md | 2 +- docs/CODE_SIGNING_POLICY.md | 25 ++-- docs/CVE_TEMPLATE.md | 2 +- docs/DECISIONS.md | 104 ++++++++++++++ docs/GOVERNANCE.md | 4 +- docs/KNOWN_ISSUES.md | 93 +++++++++++- skills/privacy-security/SKILL.md | 5 +- src/config.rs | 130 +++++++++++++++-- src/guard.rs | 21 +-- src/main.rs | 239 ++++++++++++++++++++++++++----- src/tui.rs | 172 ++++++++++++++++++++-- 16 files changed, 804 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b49fc..f17e82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 8997d89..f94b385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ dependencies = [ [[package]] name = "notalterra" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4487395..6e556da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notalterra" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["NotAlterra"] license = "MIT" diff --git a/README.md b/README.md index 91ea123..69d624c 100644 --- a/README.md +++ b/README.md @@ -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\\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 diff --git a/_release.md b/_release.md index 1bd880e..c692335 100644 --- a/_release.md +++ b/_release.md @@ -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 diff --git a/docs/BUG_REPORT_TEMPLATE.md b/docs/BUG_REPORT_TEMPLATE.md index a54a638..72aba01 100644 --- a/docs/BUG_REPORT_TEMPLATE.md +++ b/docs/BUG_REPORT_TEMPLATE.md @@ -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 diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index 8fcd2da..802ac18 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -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 diff --git a/docs/CVE_TEMPLATE.md b/docs/CVE_TEMPLATE.md index 1d637d3..4ab809e 100644 --- a/docs/CVE_TEMPLATE.md +++ b/docs/CVE_TEMPLATE.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6bfb792..f1e5b4c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 8a7c737..b83dba5 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -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 `, `--inspect ` (`.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. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index b5ff209..e8f63fa 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -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 `, +`--inspect `, 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. diff --git a/skills/privacy-security/SKILL.md b/skills/privacy-security/SKILL.md index df3efa1..6abcb6c 100644 --- a/skills/privacy-security/SKILL.md +++ b/skills/privacy-security/SKILL.md @@ -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 diff --git a/src/config.rs b/src/config.rs index b751ac5..ea14833 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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> = 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, + pub backup_root: Option, +} + +/// 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); diff --git a/src/guard.rs b/src/guard.rs index 11f3cbe..d3abce6 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -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(); } diff --git a/src/main.rs b/src/main.rs index 6d5e6f0..e7ef4c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(terminal: &mut Terminal) -> 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(terminal: &mut Terminal) -> 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(terminal: &mut Terminal, 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(terminal: &mut Terminal, 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(terminal: &mut Terminal, 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(terminal: &mut Terminal, app: &mut App) -> Result<()> { let save_folder = ensure_save_folder(terminal, app)?; @@ -424,8 +525,8 @@ fn action_recover_bak(terminal: &mut Terminal, 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 = 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 = vec![header, String::new()]; items.extend(bak_summaries .iter() @@ -440,15 +541,10 @@ fn action_recover_bak(terminal: &mut Terminal, 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(terminal: &mut Terminal, 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> = 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>, + ) { + 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> { + 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!("{:(terminal: &mut Terminal, 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(terminal: &mut Terminal, 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(terminal: &mut Terminal, 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, _ => {} } } diff --git a/src/tui.rs b/src/tui.rs index 96c4ac7..8fb4244 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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 = 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);