mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-20 18:18:57 +02:00
v0.4.1: persistent config, split-layout picker, backup location, security docs
This commit is contained in:
@@ -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
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "notalterra"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "notalterra"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
authors = ["NotAlterra"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user