Skip to content

Commit e599cbc

Browse files
ImPanickclaude
authored andcommitted
feat(app): Recovery screen wired to the recovery engine (UI pass)
Second UI-pass step: the first real feature screen on the navigation shell. Wires the already-built, tested Broken Save Recovery Core (WP-16..18 + advisor) to a page — the recovery engine finally has a face. No Core changes. - RecoveryViewModel: pick a save profile (lists via HomeService) → Scan (RecoveryPlanner.Plan on a background thread) → a per-file plan (file → restore-from- game-backup / restore-from-IUUT-backup / rebuild-skeleton / cannot-recover) with the partial-recovery flag → Repair (RecoveryService.ExecuteAsync: full-folder backup zip into %AppData%\IUUT\RecoveryBackups, then restore/template) → report (changed/failed counts, master-backup name) + the RecoveryAdvisor advisories (Steam Cloud / conflicted copies / CFA / coherence). Re-scans after repair. CA1031 pragmas at the UI boundary. - RecoveryView: profile selector + Scan, a scrollable plan list + partial-recovery warning + advisories, and a Repair button gated on CanRepair. The repair confirm dialog lives in the view code-behind (VM stays WPF-free). - App DI: registers the recovery services (HealthScanService, BackupChainWalker, TemplateRepairService, RecoveryPlanner, RecoveryAdvisor, RecoveryService) and enables ValidateOnBuild — the whole DI graph (incl. this page's chain) is validated at startup, so a wiring break fails fast rather than on first navigation. Verified: dotnet build -c Debug and -c Release 0/0, dotnet format clean, governance-lint clean, 221 Core tests unaffected, and a smoke launch confirms the app renders AND the full DI graph validates at startup (RecoveryViewModel → RecoveryPlanner/RecoveryService → all deps constructed cleanly). Click-through visual QA is owner-run. §0 updated. Agent: claude-code/2.1.149 Consulted: AGENTS.md, .agent/CONSTITUTION.md#III,#IX, .agent/CODE_STYLE.md#3,#7, docs/IUUT-PROJECT-DOCUMENTATION.md#10.1,#11.3,#12.1, docs/UI-DESIGN-CONCEPT.md Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5a71575 commit e599cbc

5 files changed

Lines changed: 392 additions & 22 deletions

File tree

docs/IMPLEMENTATION-PLAN.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,17 @@ the big-endian Adler-32 trailer via `ZLibStream` — round-trip-verified, re-sta
2828
SHA-1 load check passes) + `MountsModel`/`MountEditService` + **`FlagsFileCodec`** (the 82-byte
2929
`flags_*.dat` binary, FString-prefixed SteamID + LE u32 flags) + `FlagsEditService`. **221 tests.**
3030

31-
**What's LEFT (all non-Core):** the entire **UI pass** (all parked: Home polish, Recovery screen,
32-
Custom screens incl. Stash grid, Troubleshooting modal, Settings UI, Advanced/Raw viewer, Game
33-
Tuning tab) + **WP-15 MVP manual test** + **WP-33 release hardening** (release.yml, SHA256SUMS +
34-
attestation, portable zip, INSTALL verify) + **WP-34 tag v1.0.0** + the future **Phase 7 Game
35-
Tuning** Core (GT-1..GT-6, post-v1.0). Core editing for every save file now exists and is tested.
31+
**UI pass IN PROGRESS (`src/IUUT.App/`):** the Glass Console is being unparked.
32+
- **Done:** navigation **shell** (in-window page swap — `INavigationService`/`ShellViewModel`,
33+
Home extracted to `Views/HomeView`, DataTemplate page rendering, Back/Home nav bar) + the
34+
**Recovery screen** (`RecoveryViewModel`/`RecoveryView` wired to `RecoveryPlanner`/`RecoveryService`/
35+
`RecoveryAdvisor`: pick save → Scan → per-file plan + advisories → confirm → Repair → report).
36+
DI now uses `ValidateOnBuild` (whole graph validated at startup). Owner-run visual QA pending.
37+
- **Remaining UI:** Custom shell + category screens (Account/Characters/Accolades-Bestiary/Stash),
38+
Settings, Advanced/Raw viewer, Troubleshooting modal, Game Tuning tab, Home polish.
39+
- **Then:** **WP-15** MVP manual test (real save, in-game verify), **WP-33** release hardening
40+
(release.yml, SHA256SUMS + attestation, portable zip, INSTALL verify), **WP-34** tag v1.0.0, and
41+
the post-v1.0 **Phase 7 Game Tuning** Core (GT-1..GT-6). Every save-file Core exists + is tested.
3642

3743
**Phase 4 Core done (WP-23 + WP-25, `src/IUUT.Core/...`):** `MetaInventoryModel`/`MetaItem`/
3844
`ItemDynamicProperty` + parser/serializer + `StashEditService` (mint 32-hex-uppercase

src/IUUT.App/App.xaml.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using IUUT.Core.Abstractions;
55
using IUUT.Core.Catalog;
66
using IUUT.Core.Io;
7+
using IUUT.Core.Recovery;
78
using IUUT.Core.Services;
89
using Microsoft.Extensions.DependencyInjection;
910

@@ -63,13 +64,27 @@ private static ServiceProvider ConfigureServices()
6364
sp.GetRequiredService<IClock>()));
6465
services.AddSingleton<HomeService>();
6566

67+
// --- Broken Save Recovery (master §11.3, §12.1) -----------------------
68+
services.AddSingleton<HealthScanService>();
69+
services.AddSingleton<BackupChainWalker>();
70+
services.AddSingleton<TemplateRepairService>();
71+
services.AddSingleton<RecoveryPlanner>();
72+
services.AddSingleton<RecoveryAdvisor>();
73+
services.AddSingleton<RecoveryService>();
74+
6675
// --- UI shell + pages -------------------------------------------------
6776
services.AddSingleton<ShellViewModel>();
6877
services.AddSingleton<INavigationService>(sp => sp.GetRequiredService<ShellViewModel>());
6978
services.AddSingleton<HomeViewModel>();
7079
services.AddSingleton<RecoveryViewModel>();
7180
services.AddSingleton<MainWindow>();
7281

73-
return services.BuildServiceProvider();
82+
// ValidateOnBuild constructs every registration at startup, so a broken DI graph
83+
// (e.g. a page view-model's service) fails fast here rather than on first navigation.
84+
return services.BuildServiceProvider(new ServiceProviderOptions
85+
{
86+
ValidateOnBuild = true,
87+
ValidateScopes = true,
88+
});
7489
}
7590
}
Lines changed: 232 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,241 @@
1+
using System.Collections.ObjectModel;
2+
using System.IO;
13
using CommunityToolkit.Mvvm.ComponentModel;
4+
using CommunityToolkit.Mvvm.Input;
5+
using IUUT.Core.Recovery;
6+
using IUUT.Core.Services;
27

38
namespace IUUT.App.ViewModels;
49

510
/// <summary>
6-
/// The Broken Save Recovery page view-model. Stub for the navigation-shell step; the next step
7-
/// wires it to <c>HealthScanService</c> / <c>RecoveryPlanner</c> / <c>RecoveryService</c>.
11+
/// The Broken Save Recovery page (master §10.1, §11.3): pick a save profile → Scan
12+
/// (<see cref="RecoveryPlanner"/>) → review the per-file plan → Repair
13+
/// (<see cref="RecoveryService"/>: master backup zip, then restore/template) → see the report +
14+
/// advisories. Binding-shape only; the confirm dialog lives in the view.
815
/// </summary>
916
public sealed class RecoveryViewModel : ObservableObject
1017
{
11-
/// <summary>Status / placeholder text.</summary>
12-
public string StatusMessage { get; } =
13-
"Recovery is fully built in Core (scan → plan → master-backup → restore/template → report). " +
14-
"This screen is being wired to it now.";
18+
private const string BackupsFolderName = "RecoveryBackups";
19+
20+
private readonly HomeService _home;
21+
private readonly RecoveryPlanner _planner;
22+
private readonly RecoveryService _service;
23+
private readonly AppPaths _paths;
24+
25+
private RecoveryPlan? _plan;
26+
private HomeSaveSlot? _selectedSlot;
27+
private bool _isBusy;
28+
private bool _partialRecovery;
29+
private string _statusMessage = "Pick a save profile, then Scan it for problems.";
30+
31+
/// <summary>Creates the Recovery page over the Home, planner, executor, and app-paths services.</summary>
32+
public RecoveryViewModel(HomeService home, RecoveryPlanner planner, RecoveryService service, AppPaths paths)
33+
{
34+
ArgumentNullException.ThrowIfNull(home);
35+
ArgumentNullException.ThrowIfNull(planner);
36+
ArgumentNullException.ThrowIfNull(service);
37+
ArgumentNullException.ThrowIfNull(paths);
38+
_home = home;
39+
_planner = planner;
40+
_service = service;
41+
_paths = paths;
42+
43+
Slots = [];
44+
ActionLines = [];
45+
Advisories = [];
46+
LoadSavesCommand = new AsyncRelayCommand(LoadSavesAsync);
47+
ScanCommand = new AsyncRelayCommand(ScanAsync, () => SelectedSlot is not null && !IsBusy);
48+
}
49+
50+
/// <summary>Discovered save profiles to recover.</summary>
51+
public ObservableCollection<HomeSaveSlot> Slots { get; }
52+
53+
/// <summary>One human-readable line per file the plan would touch.</summary>
54+
public ObservableCollection<string> ActionLines { get; }
55+
56+
/// <summary>Post-repair advisories (Steam Cloud, conflicted copies, CFA, coherence).</summary>
57+
public ObservableCollection<string> Advisories { get; }
58+
59+
/// <summary>(Re)lists save profiles.</summary>
60+
public IAsyncRelayCommand LoadSavesCommand { get; }
61+
62+
/// <summary>Scans the selected profile and builds the recovery plan.</summary>
63+
public IAsyncRelayCommand ScanCommand { get; }
64+
65+
/// <summary>The profile to recover.</summary>
66+
public HomeSaveSlot? SelectedSlot
67+
{
68+
get => _selectedSlot;
69+
set
70+
{
71+
if (SetProperty(ref _selectedSlot, value))
72+
{
73+
ResetPlan();
74+
ScanCommand.NotifyCanExecuteChanged();
75+
}
76+
}
77+
}
78+
79+
/// <summary>True while a scan or repair is in flight.</summary>
80+
public bool IsBusy
81+
{
82+
get => _isBusy;
83+
private set
84+
{
85+
if (SetProperty(ref _isBusy, value))
86+
{
87+
ScanCommand.NotifyCanExecuteChanged();
88+
OnPropertyChanged(nameof(CanRepair));
89+
}
90+
}
91+
}
92+
93+
/// <summary>True when the plan can only partially recover (some files template-repaired / unrecoverable).</summary>
94+
public bool PartialRecovery
95+
{
96+
get => _partialRecovery;
97+
private set => SetProperty(ref _partialRecovery, value);
98+
}
99+
100+
/// <summary>Status-bar message.</summary>
101+
public string StatusMessage
102+
{
103+
get => _statusMessage;
104+
private set => SetProperty(ref _statusMessage, value);
105+
}
106+
107+
/// <summary>Whether there is recoverable work to apply (drives the Repair button).</summary>
108+
public bool CanRepair => _plan is not null && _plan.HasWork && !IsBusy;
109+
110+
private async Task LoadSavesAsync()
111+
{
112+
IsBusy = true;
113+
try
114+
{
115+
var state = await _home.LoadAsync(HomeService.DefaultSaveRoot);
116+
Slots.Clear();
117+
foreach (var slot in state.Slots)
118+
{
119+
Slots.Add(slot);
120+
}
121+
122+
SelectedSlot = Slots.Count > 0 ? Slots[0] : null;
123+
StatusMessage = Slots.Count > 0 ? "Select a profile, then Scan." : "No save profiles found.";
124+
}
125+
#pragma warning disable CA1031 // UI boundary: surface, never crash.
126+
catch (Exception ex)
127+
{
128+
StatusMessage = $"Could not list saves: {ex.Message}";
129+
}
130+
#pragma warning restore CA1031
131+
finally
132+
{
133+
IsBusy = false;
134+
}
135+
}
136+
137+
private async Task ScanAsync()
138+
{
139+
if (SelectedSlot is null)
140+
{
141+
return;
142+
}
143+
144+
IsBusy = true;
145+
try
146+
{
147+
await PopulatePlanAsync(SelectedSlot.FolderPath);
148+
StatusMessage = _plan is { HasWork: true }
149+
? $"{ActionLines.Count} file(s) need recovery — review, then Repair."
150+
: "This save looks healthy — nothing to repair.";
151+
}
152+
#pragma warning disable CA1031 // UI boundary: surface, never crash.
153+
catch (Exception ex)
154+
{
155+
StatusMessage = $"Scan failed: {ex.Message}";
156+
}
157+
#pragma warning restore CA1031
158+
finally
159+
{
160+
IsBusy = false;
161+
}
162+
}
163+
164+
/// <summary>Applies the current plan (master backup → restore/template) and re-scans. Call after a user confirm.</summary>
165+
public async Task RepairAsync()
166+
{
167+
if (_plan is null || !_plan.HasWork)
168+
{
169+
return;
170+
}
171+
172+
IsBusy = true;
173+
try
174+
{
175+
_paths.EnsureStateRoot();
176+
var backupDir = Path.Combine(_paths.StateRoot, BackupsFolderName);
177+
var report = await _service.ExecuteAsync(_plan, backupDir);
178+
179+
Advisories.Clear();
180+
foreach (var advisory in report.Advisories)
181+
{
182+
Advisories.Add(advisory);
183+
}
184+
185+
var zip = report.MasterBackupZipPath is null ? "(none)" : Path.GetFileName(report.MasterBackupZipPath);
186+
StatusMessage = report.Succeeded
187+
? $"Recovered {report.ChangedCount} file(s). Full backup: {zip}."
188+
: $"Recovered {report.ChangedCount}; {report.FailedCount} could not be recovered. Full backup: {zip}.";
189+
190+
await PopulatePlanAsync(SelectedSlot!.FolderPath); // refresh — files should now be healthy
191+
}
192+
#pragma warning disable CA1031 // UI boundary: surface, never crash.
193+
catch (Exception ex)
194+
{
195+
StatusMessage = $"Repair failed: {ex.Message}";
196+
}
197+
#pragma warning restore CA1031
198+
finally
199+
{
200+
IsBusy = false;
201+
}
202+
}
203+
204+
private async Task PopulatePlanAsync(string folder)
205+
{
206+
ResetPlan();
207+
var plan = await Task.Run(() => _planner.Plan(folder));
208+
_plan = plan;
209+
210+
foreach (var action in plan.Actions.Where(a => a.Outcome != RecoveryOutcome.AlreadyOk))
211+
{
212+
var note = string.IsNullOrEmpty(action.Note) ? "" : $" ({action.Note})";
213+
ActionLines.Add($"{action.RelativePath}{Describe(action.Outcome)}{note}");
214+
}
215+
216+
if (ActionLines.Count == 0)
217+
{
218+
ActionLines.Add("All files parse cleanly — no recovery needed.");
219+
}
220+
221+
PartialRecovery = plan.PartialRecovery;
222+
OnPropertyChanged(nameof(CanRepair));
223+
}
224+
225+
private void ResetPlan()
226+
{
227+
_plan = null;
228+
ActionLines.Clear();
229+
PartialRecovery = false;
230+
OnPropertyChanged(nameof(CanRepair));
231+
}
232+
233+
private static string Describe(RecoveryOutcome outcome) => outcome switch
234+
{
235+
RecoveryOutcome.RestoreFromGameBackup => "restore from game backup",
236+
RecoveryOutcome.RestoreFromIuutBackup => "restore from IUUT backup",
237+
RecoveryOutcome.TemplateRepair => "rebuild skeleton (data lost — partial)",
238+
RecoveryOutcome.Unrecoverable => "cannot recover automatically",
239+
_ => "ok",
240+
};
15241
}

0 commit comments

Comments
 (0)