-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainWindow.SystemImage.cs
More file actions
731 lines (688 loc) · 33.9 KB
/
Copy pathMainWindow.SystemImage.cs
File metadata and controls
731 lines (688 loc) · 33.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using GuardWui3.Models;
using GuardWui3.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace GuardWui3;
// System Image page: wbadmin availability probe, image settings save and
// schedule, the elevated on-demand image run with log tailing, and the
// recovery-media / restore-help launchers.
public sealed partial class MainWindow : Window
{
// =====================================================================
// SYSTEM IMAGE
// =====================================================================
// wbadmin is absent on some editions (notably Home). Probe once, lazily, and
// self-disable the on-demand and scheduled imaging if it's missing; recovery
// media and the restore help stay available regardless.
private async void CheckImageAvailability()
{
bool ok = await System.Threading.Tasks.Task.Run(() => SystemImageScript.IsWbadminAvailable());
_imageAvailable = ok;
if (!ok)
{
BtnCreateImage.IsEnabled = false;
BtnViewImages.IsEnabled = false;
ChkImageSchedule.IsChecked = false;
ChkImageSchedule.IsEnabled = false;
UpdateImageScheduleEnabledState();
}
RefreshImageStatus(announce: false);
// First visit with image settings saved but no image made yet: show
// the destination space, as the File Backup page does on launch. Once
// images exist, the status line carries the last run's health instead
// and the space figure refreshes only on a manual save. Silent
// (announce:false) so it does not speak over the nav's page
// announcement; the amber dot still flags a tight destination.
if (_imageAvailable && File.Exists(GuardPaths.SystemImageScriptPath) && !_imageDirty
&& BackupHealth.ReadLog(GuardPaths.SystemImageLogPath) is null)
StartImageSpaceCheck(announce: false);
}
private void RefreshImageStatus(bool announce = true)
{
if (StatusBarText == null) return;
// Terse on purpose, like RefreshScriptStatus: one bar line.
if (!_imageAvailable)
{
_imageStatusBrush = new SolidColorBrush(StatusAmber);
_imageStatusText = "System imaging is unavailable on this Windows edition (wbadmin not found). Recovery media still works.";
}
else if (!File.Exists(GuardPaths.SystemImageScriptPath))
{
_imageStatusBrush = new SolidColorBrush(StatusAmber);
_imageStatusText = "No image settings saved yet - choose a destination and click Save Settings.";
}
else if (_imageDirty)
{
_imageStatusBrush = new SolidColorBrush(StatusAmber);
_imageStatusText = "Unsaved changes - click Save Settings to apply them.";
}
else if (_imageTaskStale)
{
_imageStatusBrush = new SolidColorBrush(StatusAmber);
_imageStatusText = "GUARD's folder has moved - click Save Settings to repoint the scheduled image (needs Administrator approval).";
}
else
{
// Same health-first idea as the File Backup status: once images
// have run, report how the last one went rather than when the
// settings file was written. The SYSTEM scheduled image cannot
// toast (session 0), so this line is where its outcome surfaces.
var now = DateTime.Now;
var last = BackupHealth.ReadLog(GuardPaths.SystemImageLogPath);
if (last is null)
{
_imageStatusBrush = new SolidColorBrush(StatusGreen);
_imageStatusText = "Image settings saved. No image created yet.";
}
else
{
string when = BackupHealth.FriendlyWhen(last.When, now);
var expected = _cfg.ImageScheduleEnabled
? BackupHealth.PreviousScheduledImage(_cfg.ImageCadence, _cfg.ImageWeeklyDay,
_cfg.ImageMonthlyDay, _cfg.ImageScheduleTime, now)
: null;
bool amber = true;
string text;
if (last.Outcome == RunOutcome.Errors)
text = "Last system image had errors (" + when + ") - open the last log.";
else if (last.Outcome == RunOutcome.DidNotComplete)
text = "Last system image did not complete (" + when + ") - open the last log.";
else if (BackupHealth.IsOverdue(last, expected, now))
text = "System image overdue - last succeeded " + when + ".";
else
{
amber = false;
text = "Last system image succeeded " + when + ".";
}
_imageStatusBrush = new SolidColorBrush(amber ? StatusAmber : StatusGreen);
_imageStatusText = text;
}
}
UpdateImageSaveEnabled();
CommitPageStatus(2, announce);
}
// Mirror of UpdateSaveEnabled for the System Image page: disable Save once the
// saved script matches the config, keep it enabled for unsaved edits or a
// first save. A running image owns the button (SetImageBusy). On disabling a
// focused Save, fall to Create Image (or Recovery Media if imaging is
// unavailable and Create Image is disabled) so focus is never stranded.
private void UpdateImageSaveEnabled()
{
if (BtnSaveImage == null || _imageRunning) return;
bool enable = _imageDirty || _imageTaskStale || !File.Exists(GuardPaths.SystemImageScriptPath);
// See UpdateSaveEnabled: guard on the XAML root so the constructor-time
// seeded status does not query focus before Content.XamlRoot exists.
if (!enable && BtnSaveImage.IsEnabled && Content?.XamlRoot is not null &&
ReferenceEquals(FocusManager.GetFocusedElement(Content.XamlRoot), BtnSaveImage))
(BtnCreateImage.IsEnabled ? BtnCreateImage : BtnRecoveryMedia).Focus(FocusState.Programmatic);
BtnSaveImage.IsEnabled = enable;
}
private void SetImageStatusText(string text, bool announce = true)
{
_imageStatusText = text;
CommitPageStatus(2, announce);
}
// ---- dirty tracking ----
private void OnImageDirtyChanged(object sender, TextChangedEventArgs e)
{
UpdateImageTargetKindLabel();
_imageDirty = true;
RefreshImageStatus();
}
private void OnImageDirtySelection(object sender, SelectionChangedEventArgs e) { _imageDirty = true; RefreshImageStatus(); }
// The destination kind is derived from the path (a UNC is a share, anything
// else a local/external disk), so the two can never disagree. The caption
// reflects what the current path means for retention.
private static string ClassifyImageTarget(string? path) =>
(path ?? "").Trim().StartsWith(@"\\") ? "NetworkShare" : "LocalDisk";
private void UpdateImageTargetKindLabel()
{
if (LblImageTargetKind == null) return;
string p = (TxtImageTarget.Text ?? "").Trim();
string msg;
if (p.Length == 0)
msg = "Enter a drive such as E:\\ for a local or external disk, or a path such as \\\\server\\share for a network share.";
else if (p.StartsWith(@"\\"))
msg = "This is a network share: only the most recent image is kept, and a scheduled image cannot sign in to it.";
else
{
msg = "This is a local or external disk: several past images are kept automatically.";
// wbadmin only takes a volume for local disks (TargetArg reduces
// the path to "E:"), so a typed or browsed subfolder is silently
// ignored - say so rather than let the user hunt for it.
if (p.Length > 3 && p[1] == ':')
msg += " Images always go to the drive's root (in a WindowsImageBackup folder); the folder part of this path is ignored.";
}
LblImageTargetKind.Text = msg;
// Spoken when focus lands on the destination field (the visible captions are
// AccessibilityView=Raw, so this is how a screen reader hears the kind).
Microsoft.UI.Xaml.Automation.AutomationProperties.SetHelpText(TxtImageTarget, msg);
}
private void OnImageMonthlyDayChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) { _imageDirty = true; RefreshImageStatus(); }
private void OnImageTimeChanged(TimePicker sender, TimePickerSelectedValueChangedEventArgs args) { _imageDirty = true; RefreshImageStatus(); }
private void OnImageScheduleEnabledChanged(object sender, RoutedEventArgs e)
{
UpdateImageScheduleEnabledState();
_imageDirty = true;
RefreshImageStatus();
}
private void OnImageCadenceChanged(object sender, RoutedEventArgs e)
{
UpdateImageCadenceRows();
_imageDirty = true;
RefreshImageStatus();
}
private void UpdateImageScheduleEnabledState()
{
// StackPanel has no IsEnabled (it is a Panel, not a Control), so grey out
// the interactive leaves directly, matching UpdateScheduleEnabledState.
bool on = ChkImageSchedule.IsChecked == true;
if (ImageCadenceRadios != null) ImageCadenceRadios.IsEnabled = on;
if (CmbImageWeeklyDay != null) CmbImageWeeklyDay.IsEnabled = on;
if (NumImageMonthlyDay != null) NumImageMonthlyDay.IsEnabled = on;
if (TimeImage != null) TimeImage.IsEnabled = on;
}
private void UpdateImageCadenceRows()
{
if (ImageWeeklyDayRow == null || ImageMonthlyDayRow == null) return;
ImageWeeklyDayRow.Visibility = RbImageWeekly.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
ImageMonthlyDayRow.Visibility = RbImageMonthly.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
}
// ---- harvest / save ----
private void HarvestImageUi()
{
_cfg.ImageTarget = (TxtImageTarget.Text ?? "").Trim();
_cfg.ImageTargetKind = ClassifyImageTarget(_cfg.ImageTarget);
_cfg.ImageScheduleEnabled = ChkImageSchedule.IsChecked == true;
_cfg.ImageCadence = RbImageMonthly.IsChecked == true ? "Monthly"
: RbImageDaily.IsChecked == true ? "Daily" : "Weekly";
_cfg.ImageScheduleTime = FormatScheduleTime(TimeImage.SelectedTime, _cfg.ImageScheduleTime);
int idx = CmbImageWeeklyDay.SelectedIndex;
if (idx >= 0 && idx < _imageDayOrder.Length) _cfg.ImageWeeklyDay = _imageDayOrder[idx];
if (!double.IsNaN(NumImageMonthlyDay.Value))
_cfg.ImageMonthlyDay = Math.Clamp((int)NumImageMonthlyDay.Value, 1, 28);
}
private static string ImageScheduleSignature(Settings c) =>
(c.ImageScheduleEnabled ? "1" : "0") + "|" + c.ImageCadence + "|" +
c.ImageScheduleTime + "|" + c.ImageWeeklyDay + "|" + c.ImageMonthlyDay;
// Returns false (after showing a message) when a required value is missing or
// the target is the system drive. Writes the ini + image script, then applies
// the scheduled task only when a schedule-affecting setting changed (the apply
// needs a UAC prompt, so an unchanged save must not re-prompt).
private async System.Threading.Tasks.Task<bool> SaveImageAsync()
{
HarvestImageUi();
if (string.IsNullOrEmpty(_cfg.ImageTarget))
{
await ShowMessageAsync("GUARD", "Enter an image destination first.\n\nType a drive (like E:\\) or a network share path, or use Browse to pick one.");
return false;
}
// Same reasoning as the backup destination: a quote would corrupt the
// generated script's set "TARGET=..." line.
if (_cfg.ImageTarget.Contains('"'))
{
await ShowMessageAsync("GUARD", "The image destination cannot contain quote (\") characters.");
return false;
}
if (_cfg.ImageTargetKind == "LocalDisk" && SystemImageScript.IsSystemDrive(_cfg.ImageTarget))
{
await ShowMessageAsync("GUARD", "The image destination cannot be on the same drive as Windows.\n\nA system image includes the Windows drive, so it must be written to a separate disk or a network share. Choose another destination.");
return false;
}
if (_imageSaving) return false;
_imageSaving = true;
try
{
try
{
// Section-scoped: never commits the File Backup page's unsaved
// edits (see SettingsStore.SaveSystemImage).
SettingsStore.SaveSystemImage(_cfg);
SystemImageScript.Write(_cfg);
}
catch (Exception ex)
{
// Same guard as SaveAllAsync: a failed write must not escape
// this async-void path and crash GUARD.
await ShowMessageAsync("GUARD", "Could not save the settings:\n\n" + ex.Message
+ "\n\nGUARD writes its settings and image script into the folder GUARD.exe is in, so that folder must be writable.");
return false;
}
_imageDirty = false;
// Explicit confirmation, like SaveAllAsync's: the health line
// returns at the next launch, page revisit, or run end.
_imageStatusBrush = new SolidColorBrush(StatusGreen);
SetImageStatusText("Image settings saved.");
UpdateImageSaveEnabled();
string sig = ImageScheduleSignature(_cfg);
if (sig != _lastImageScheduleSig)
{
var applied = await System.Threading.Tasks.Task.Run(() => ScheduledTasks.ApplySystemImage(_cfg));
_imageTaskError = applied.Error;
if (applied.Error == null)
{
_lastImageScheduleSig = sig;
// A successful re-apply also repoints a task left behind by
// a folder move (the stale flag forced sig to differ).
_imageTaskStale = false;
UpdateImageSaveEnabled();
}
LblImageNextRun.Text = applied.NextRun == null
? "Next run: (no scheduled image)" : "Next run: " + applied.NextRun;
}
else
{
_imageTaskError = null;
}
return true;
}
finally { _imageSaving = false; }
}
private async void OnSaveImage(object sender, RoutedEventArgs e)
{
if (!await SaveImageAsync()) return;
if (_imageTaskError != null)
{
await ShowMessageAsync("GUARD", "Settings saved, but scheduling the system image reported a problem:\n\n" + _imageTaskError);
return;
}
if (_cfg.ImageScheduleEnabled && _cfg.ImageTargetKind == "NetworkShare")
await ShowMessageAsync("GUARD", "Note: a scheduled image runs as SYSTEM, which cannot supply network share sign-in details. If the scheduled image cannot reach the share, store images on a local or external disk for the schedule, or create images to the share on demand.");
StartImageSpaceCheck();
}
// Advisory free-space check appended to the saved-status line, like the File
// Backup space check. No precise image-size estimate (the source is the whole
// system drive); a low free-space floor is flagged as a warning.
private async void StartImageSpaceCheck(bool announce = true)
{
int seq = ++_imageSpaceSeq;
string baseText = _imageStatusText;
SetImageStatusText(baseText + " Checking free space...", announce);
long? free = await System.Threading.Tasks.Task.Run(() => SaveValidation.TryGetFreeSpace(_cfg.ImageTarget));
if (seq != _imageSpaceSeq || _imageDirty) return;
string extra;
if (free is long freeBytes)
{
extra = " Free space: " + SaveValidation.FormatBytes(freeBytes) + ".";
if (freeBytes < 32L * 1024 * 1024 * 1024)
{
extra += " Warning: may be too small for a full image.";
_imageStatusBrush = new SolidColorBrush(StatusAmber);
}
}
else
{
extra = " Free space could not be checked.";
}
SetImageStatusText(baseText + extra, announce);
}
// ---- run ----
private async void OnCreateImageNow(object sender, RoutedEventArgs e) => await RunImage();
private async System.Threading.Tasks.Task RunImage()
{
if (_imageRunning)
{
await ShowMessageAsync("GUARD", "A system image is already running. Wait for it to finish, or press Stop Image to cancel it.");
return;
}
// wbadmin cannot run two operations against the same target at once;
// View Existing Images also elevates and can take several seconds, so
// block a launch that would race it (BtnCreateImage is disabled while
// listing runs, but this is a defensive second check).
if (_imageListing)
{
await ShowMessageAsync("GUARD", "Reading the list of existing system images. Wait for it to finish before creating a new image.");
return;
}
if (!_imageAvailable)
{
await ShowMessageAsync("GUARD", "System imaging is not available on this edition of Windows (the wbadmin tool was not found).");
return;
}
if (!await SaveImageAsync()) return;
if (_imageTaskError != null)
await ShowMessageAsync("GUARD", "Settings saved, but scheduling the system image reported a problem:\n\n" + _imageTaskError);
if (!File.Exists(GuardPaths.SystemImageScriptPath))
{
await ShowMessageAsync("GUARD", "Image script not found. Click Save Settings first.");
return;
}
if (!await ShowConfirmAsync("GUARD",
"Create a full system image now?\n\nThis can take a long time and needs Administrator approval. You can keep using your PC while it runs.",
"Create", "Cancel")) return;
TxtImageOutput.Text = "";
AppendOut(TxtImageOutput, "> Creating system image to " + _cfg.ImageTarget + "\r\n");
// Tail only THIS run's lines (startAtEnd). The previous run's log is
// still on disk until the elevated script truncates it after the UAC
// prompt; reading from 0 would replay it, inflating the volume tally
// so the bar jumped straight to 100%. LogTail's shrink guard rewinds
// to 0 when the truncation lands.
_imageTail = new LogTail(GuardPaths.SystemImageLogPath, startAtEnd: true);
_imageTotalVols = 0;
_imageDoneVols = 0;
_imageOverall = 0;
_imageStopRequested = false;
// Indeterminate until the first volume percent arrives: wbadmin spends the
// first stretch taking a VSS snapshot with no percentage, and a determinate
// 0% there reads as stalled.
SetImageProgressIndeterminate("Starting system image...");
ShowStatusBarProgress(2, true);
_imageRunning = true;
SetImageBusy(true);
string? err = null;
bool ok = false;
try
{
// Output can't cross the elevation boundary (see RunPowerShellElevated),
// so the elevated script writes to the log and we tail it for progress
// while it runs; the exit code is the authoritative result. PsQuote:
// the path sits in a single-quoted PowerShell string, so an apostrophe
// in the install path would otherwise end the string and kill the run.
string elevated = "& cmd.exe /c '\"" + PsQuote(GuardPaths.SystemImageScriptPath) + "\"'; exit $LASTEXITCODE";
var runTask = System.Threading.Tasks.Task.Run(() => ProcessRunner.RunPowerShellElevated(elevated, out err));
while (!runTask.IsCompleted)
{
await System.Threading.Tasks.Task.Delay(700);
PumpImageLog();
}
PumpImageLog();
ok = await runTask;
}
catch (Exception ex) { err = ex.Message; }
finally
{
ShowStatusBarProgress(2, false);
_imageRunning = false;
SetImageBusy(false);
// The elevated run just rewrote the image log; repaint the health
// line silently (the outcome announcement below does the talking).
RefreshImageStatus(announce: false);
}
string outcome;
if (ok)
{
outcome = "System image completed successfully.";
SetImageProgressDeterminate(100, outcome);
}
else if (_imageStopRequested)
{
outcome = "System image stopped.";
SetImageProgressDeterminate(_imageOverall, outcome);
}
else if (err != null && err.Contains("declined"))
{
outcome = "System image cancelled - Administrator approval was declined.";
SetImageProgressDeterminate(0, "System image cancelled.");
}
else
{
outcome = "System image failed. See the output details and the last log.";
SetImageProgressDeterminate(_imageOverall, "System image failed.");
}
AppendOut(TxtImageOutput, "\r\n--- " + outcome + " ---\r\n");
if (ok && _cfg.ImageTargetKind == "NetworkShare")
{
string? winre = await System.Threading.Tasks.Task.Run(() => SystemImageScript.ResolveUncToIp(_cfg.ImageTarget));
AppendOut(TxtImageOutput,
"\r\nTo restore from this image later: boot the recovery USB, choose System Image\r\n" +
"Recovery, and when it asks for the network location enter " + (winre ?? _cfg.ImageTarget) + "\r\n" +
"(the recovery tool cannot look up server names, so use the IP address" + (winre != null ? " shown" : "") + ").\r\n");
}
AnnounceSettled(outcome, 2000);
}
// Read new lines appended to the image log by the elevated run and feed
// them to the parser; LogTail handles offsets, rewinds and partial lines.
private void PumpImageLog()
{
if (_imageTail == null) return;
foreach (var line in _imageTail.ReadNewLines())
HandleImageLine(line);
}
// wbadmin reports progress per volume ("copied (NN%)"), resetting to 0% for each
// new volume. Showing the raw per-volume percent makes the bar jump to 100% when
// the small EFI partition finishes, then restart at 0% - which looks like it
// finished and started over. Instead, fold the per-volume percents into one
// monotonic overall figure using the volume count from wbadmin's plan line.
// Everything that is not a percent line is echoed to the output box.
private void HandleImageLine(string? data)
{
if (string.IsNullOrWhiteSpace(data)) return;
// Volume count from the plan line, e.g. "This will back up (EFI System
// Partition),(C:) to E:." Counting "(" is robust to comma/spacing style.
if (_imageTotalVols == 0)
{
var plan = Regex.Match(data, @"back\s*up\s+(.+?)\s+to\b", RegexOptions.IgnoreCase);
if (plan.Success)
{
int vols = 0;
foreach (char c in plan.Groups[1].Value) if (c == '(') vols++;
if (vols > 0) _imageTotalVols = vols;
}
}
// A finished volume (not the final "backup operation completed" summary).
if (Regex.IsMatch(data, @"backup of volume.*completed successfully", RegexOptions.IgnoreCase))
_imageDoneVols++;
var m = Regex.Match(data, @"copied\s*\(\s*(\d+)\s*%\s*\)", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups[1].Value, out int pct))
{
// A stop was requested but wbadmin is still winding down; hold the
// "Stopping..." state rather than let a late percent line move the bar,
// which would look like the image resumed.
if (_imageStopRequested) { SetImageProgressIndeterminate("Stopping system image..."); return; }
if (_imageTotalVols > 0)
{
double overall = (_imageDoneVols + pct / 100.0) / _imageTotalVols * 100.0;
if (overall < _imageOverall) overall = _imageOverall; // never go backwards
if (overall > 100) overall = 100;
_imageOverall = overall;
SetImageProgressDeterminate(overall, "Creating system image... " + (int)overall + "%");
}
else
{
// Total not known yet (plan line unseen): keep it indeterminate
// rather than show a misleading per-volume percent.
SetImageProgressIndeterminate("Creating system image...");
}
return;
}
AppendOut(TxtImageOutput, data + "\r\n");
}
private void SetImageProgressDeterminate(double pct, string text)
{
DispatcherQueue.TryEnqueue(() =>
{
ImageProgress.IsIndeterminate = false;
ImageProgress.Maximum = 100; ImageProgress.Value = pct;
ImageProgressLabel.Text = text;
});
// Visibility stays with ShowStatusBarProgress: this also paints the post-run
// outcome (100% / stopped / failed) after the bar has been hidden, and must
// not re-show it.
Progress(2, p => { p.Indeterminate = false; p.Max = 100; p.Value = pct; p.Text = text; });
}
private void SetImageProgressIndeterminate(string text)
{
DispatcherQueue.TryEnqueue(() =>
{
ImageProgress.IsIndeterminate = true;
ImageProgressLabel.Text = text;
});
Progress(2, p => { p.Indeterminate = true; p.Text = text; });
}
// Mirror of SetFileBusy; the shared Begin/EndRunBusy carry the focus
// discipline (see MainWindow.xaml.cs).
private void SetImageBusy(bool busy)
{
SetNavBusy(2, busy);
if (busy)
{
_imageRunLauncher = BeginRunBusy(BtnStopImage, BtnSaveImage, BtnCreateImage);
BtnSaveImage.IsEnabled = false;
BtnCreateImage.IsEnabled = false;
BtnRecoveryMedia.IsEnabled = false;
BtnViewImages.IsEnabled = false;
}
else
{
BtnCreateImage.IsEnabled = _imageAvailable;
BtnRecoveryMedia.IsEnabled = true;
BtnViewImages.IsEnabled = _imageAvailable && !_imageListing;
UpdateImageSaveEnabled();
EndRunBusy(BtnStopImage, _imageRunLauncher);
_imageRunLauncher = null;
}
}
// The elevated image can't be killed from this un-elevated process, so stopping
// goes through wbadmin's own "stop job", which needs its own elevation. The
// running image then exits with an error, which the run treats as "stopped".
private async void OnStopImage(object sender, RoutedEventArgs e)
{
if (!_imageRunning || _imageStopRequested) return;
_imageStopRequested = true;
// Stopping needs its own elevation (a second UAC prompt) and wbadmin then
// takes a few seconds to wind down, so the run does not end the instant Stop
// is pressed. Show a clear "stopping" state at once - bar to indeterminate
// and spoken - instead of leaving the bar creeping as if nothing happened.
// HandleImageLine freezes further percent updates while _imageStopRequested
// is set, and the run's end path swaps in "System image stopped."
SetImageProgressIndeterminate("Stopping system image...");
AnnounceNotification("Stopping system image...");
string? err = null;
bool ok = await System.Threading.Tasks.Task.Run(
() => ProcessRunner.RunPowerShellElevated("wbadmin stop job -quiet; exit 0", out err));
if (!ok)
{
_imageStopRequested = false;
// The image is still running; resume showing its real progress.
SetImageProgressDeterminate(_imageOverall, "Creating system image... " + (int)_imageOverall + "%");
await ShowMessageAsync("GUARD", "Could not stop the system image"
+ (err != null ? " - " + err : "") + "\n\nIt will keep running.");
}
}
// ---- existing images ----
private bool _imageListing;
// Lists what wbadmin already holds on the destination. wbadmin needs
// Administrator even to QUERY, so this is a button (one consented UAC per
// click), not an automatic probe; output comes back through a log file
// like the image run's, since it cannot cross the elevation boundary.
private async void OnViewExistingImages(object sender, RoutedEventArgs e)
{
if (_imageListing || _imageRunning) return;
if (!_imageAvailable)
{
await ShowMessageAsync("GUARD", "System imaging is not available on this edition of Windows (the wbadmin tool was not found).");
return;
}
HarvestImageUi();
if (string.IsNullOrEmpty(_cfg.ImageTarget))
{
await ShowMessageAsync("GUARD", "Enter an image destination first.\n\nType a drive (like E:\\) or a network share path, or use Browse to pick one.");
return;
}
if (_cfg.ImageTarget.Contains('"'))
{
await ShowMessageAsync("GUARD", "The image destination cannot contain quote (\") characters.");
return;
}
_imageListing = true;
BtnViewImages.IsEnabled = false;
// wbadmin cannot run two operations against the same target at once;
// block Create Image Now for the same reason RunImage checks _imageListing.
BtnCreateImage.IsEnabled = false;
TxtImageOutput.Text = "";
AppendOut(TxtImageOutput, "> Listing system images on " + _cfg.ImageTarget + "\r\n\r\n");
AnnounceNotification("Checking the destination for existing system images. This needs Administrator approval.");
string target = SystemImageScript.TargetArg(_cfg);
string log = GuardPaths.ImageVersionsLogPath;
// *> catches wbadmin's stderr too ("No backups were found..." lands
// there); the exit code is not trusted for success (localized wbadmin
// uses it inconsistently for the empty case), the text is the answer.
string script =
"& wbadmin get versions ('-backupTarget:' + '" + PsQuote(target) + "') *> '" + PsQuote(log) + "'\n" +
"exit 0";
string? err = null;
bool ok;
try
{
ok = await System.Threading.Tasks.Task.Run(
() => ProcessRunner.RunPowerShellElevated(script, out err));
}
finally
{
_imageListing = false;
BtnViewImages.IsEnabled = !_imageRunning;
BtnCreateImage.IsEnabled = _imageAvailable && !_imageRunning;
}
string outcome;
if (!ok)
{
outcome = err != null && err.Contains("declined")
? "Image list cancelled - Administrator approval was declined."
: "Could not list the images" + (err != null ? " - " + err : ".");
AppendOut(TxtImageOutput, outcome + "\r\n");
}
else
{
string text = "";
try { text = File.ReadAllText(log).Trim(); } catch { }
if (text.Length == 0) text = "(wbadmin returned no output.)";
AppendOut(TxtImageOutput, text + "\r\n");
// "Backup time:" opens each version block in wbadmin's listing;
// counting them beats parsing free text. Localized output just
// falls back to the neutral wording.
int count = CountOccurrences(text, "Backup time:");
outcome = count > 0
? "Found " + count + " system image version" + (count == 1 ? "" : "s") + " on the destination. Details are in the output."
: "The image list is ready in the output details.";
}
AnnounceSettled(outcome, 2000);
}
private static int CountOccurrences(string text, string token)
{
int n = 0;
for (int i = text.IndexOf(token, StringComparison.OrdinalIgnoreCase); i >= 0;
i = text.IndexOf(token, i + token.Length, StringComparison.OrdinalIgnoreCase)) n++;
return n;
}
private static string PsQuote(string s) => s.Replace("'", "''");
private async void OnBrowseImageTarget(object sender, RoutedEventArgs e) => await BrowseInto(TxtImageTarget);
private async void OnTestImageTarget(object sender, RoutedEventArgs e) => await TestConnection(TxtImageTarget.Text);
private void OnOpenImageLog(object sender, RoutedEventArgs e) => OpenPath(GuardPaths.SystemImageLogPath, "No log found yet. Create a system image first.");
private async void OnRestoreHelp(object sender, RoutedEventArgs e)
{
string target = (TxtImageTarget.Text ?? "").Trim();
var dlg = new Views.SystemImageRestoreHelpDialog(target) { XamlRoot = Content.XamlRoot };
// Resolve the share's server name to an IP off the UI thread so a slow or
// failing DNS lookup never delays the dialog; the await resumes on the UI
// thread, upgrading the text once it lands (no-op if it could not resolve).
if (ClassifyImageTarget(target) == "NetworkShare")
_ = ResolveRestoreIpAsync(dlg, target);
await ShowDialogAsync(dlg);
}
private static async System.Threading.Tasks.Task ResolveRestoreIpAsync(
Views.SystemImageRestoreHelpDialog dlg, string target)
{
string? ip = await System.Threading.Tasks.Task.Run(() => SystemImageScript.ResolveUncToIp(target));
dlg.SetResolvedIp(ip);
}
private async void OnCreateRecoveryMedia(object sender, RoutedEventArgs e)
{
var dlg = new Views.RecoveryMediaDialog { XamlRoot = Content.XamlRoot, WindowHandle = WindowHandle };
await ShowDialogAsync(dlg);
}
}