Skip to content

Commit 9ffc717

Browse files
committed
feat(settings): add device transfer conflict review
1 parent 9679ae1 commit 9ffc717

25 files changed

Lines changed: 372 additions & 57 deletions

Directory.Build.props

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>28.0.0-alpha.17</Version>
3+
<Version>28.0.0-alpha.18</Version>
44
<VersionPrefix>28.0.0</VersionPrefix>
5-
<VersionSuffix>alpha.17</VersionSuffix>
5+
<VersionSuffix>alpha.18</VersionSuffix>
66
<AssemblyVersion>28.0.0.0</AssemblyVersion>
77
<FileVersion>28.0.0.0</FileVersion>
8-
<InformationalVersion>28.0.0-alpha.17</InformationalVersion>
9-
<LifeOSDisplayVersion>v28.0.0-alpha.17</LifeOSDisplayVersion>
10-
<LifeOSReleaseName>v28 Accounts and Local-First Sync Foundation</LifeOSReleaseName>
8+
<InformationalVersion>28.0.0-alpha.18</InformationalVersion>
9+
<LifeOSDisplayVersion>v28.0.0-alpha.18</LifeOSDisplayVersion>
10+
<LifeOSReleaseName>v28 Device Transfer and Conflict Review</LifeOSReleaseName>
1111
</PropertyGroup>
1212
</Project>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.IO;
2+
using System.Text.Json;
3+
using System.Windows;
4+
using System.Windows.Automation;
5+
using System.Windows.Controls;
6+
using System.Windows.Media;
7+
using LifeOS.Core.DeviceTransfer;
8+
using LifeOS.Core.Forms;
9+
using LifeOS.Shared.DeviceTransfer;
10+
using LifeOS.Shared.Storage;
11+
12+
namespace LifeOS.Desktop;
13+
14+
public sealed class DeviceTransferWorkspaceView : UserControl
15+
{
16+
private readonly bool _demo; private List<DeviceTransferReview> _reviews = []; private IReadOnlyList<FormFieldIssue> _issues = []; private UserFacingProblem? _problem; private string? _notice;
17+
private string _source = "", _destination = "", _key = "", _local = "", _incoming = "";
18+
public DeviceTransferWorkspaceView(bool demo) { _demo = demo; Background = Brush("#0C1220"); Foreground = Brushes.White; FontFamily = new FontFamily("Segoe UI"); try { _reviews = DeviceTransferStorage.Load(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { _problem = UserFacingProblemFactory.FromException(ex, "load device transfer reviews"); } Render(); }
19+
20+
private void Render()
21+
{
22+
int conflicts = _reviews.Count(review => review.State == DeviceTransferState.ConflictReview), duplicates = _reviews.Count(review => review.State == DeviceTransferState.Duplicate), resolved = _reviews.Count(review => review.State is DeviceTransferState.Resolved or DeviceTransferState.Rejected);
23+
StackPanel root = new() { Margin = new Thickness(24) };
24+
root.Children.Add(Text(_demo ? "PORTFOLIO DEMO • ISOLATED" : "ORDINARY MODE • LOCAL REVIEW", 11, "#AFA4FF", FontWeights.SemiBold)); root.Children.Add(Text("Device Transfer & Conflict Review", 30, "#FFFFFF", FontWeights.Bold)); root.Children.Add(Text("Compare transfer fingerprints and record an explicit conflict decision. No payload is copied, merged, uploaded or promoted to authoritative state here.", 14, "#B8C5D8")); if (_notice is not null) root.Children.Add(Text(_notice, 12, "#83D4B3", FontWeights.SemiBold)); if (_problem is not null) root.Children.Add(Problem(_problem));
25+
WrapPanel metrics = new() { Margin = new Thickness(0, 16, 0, 8) }; metrics.Children.Add(Metric("Reviews", _reviews.Count.ToString(), "Local manifests")); metrics.Children.Add(Metric("Conflicts", conflicts.ToString(), "Decision required")); metrics.Children.Add(Metric("Duplicates", duplicates.ToString(), "No mutation")); metrics.Children.Add(Metric("Resolved", resolved.ToString(), "Decision ledger only")); root.Children.Add(metrics); root.Children.Add(Capture()); root.Children.Add(Heading("Transfer review queue", 21, new Thickness(0, 20, 0, 6)));
26+
if (_reviews.Count == 0) root.Children.Add(Card("No transfer manifests yet", "Ordinary mode does not seed devices, record keys, payloads or fingerprints.", "#151F30")); else foreach (DeviceTransferReview review in _reviews) root.Children.Add(ReviewCard(review));
27+
LocalStoreHealth health = DeviceTransferStorage.Inspect(); root.Children.Add(Heading("Transfer boundary", 21, new Thickness(0, 20, 0, 6))); root.Children.Add(Card("Versioned device-transfer-review store", $"State: {health.State}. Only manifest metadata and explicit decisions are stored. Payload transport, decryption and authoritative replacement are unavailable.", "#152437")); Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, Content = root };
28+
}
29+
30+
private UIElement Capture()
31+
{
32+
StackPanel body = new(); body.Children.Add(Heading("Stage a transfer manifest", 20)); body.Children.Add(Text("Use SHA-256 fingerprints computed outside this review surface. Required fields are validated before save.", 12, "#A9B6CA")); WrapPanel row = new(); row.Children.Add(Input("Source device *", "transfer-source", "DeviceTransfer.Source", _source, value => _source = value, 480)); row.Children.Add(Input("Destination device *", "transfer-destination", "DeviceTransfer.Destination", _destination, value => _destination = value, 480)); body.Children.Add(row); body.Children.Add(Input("Record key *", "transfer-record-key", "DeviceTransfer.RecordKey", _key, value => _key = value, 970)); body.Children.Add(Input("Local SHA-256 *", "transfer-local-fingerprint", "DeviceTransfer.LocalFingerprint", _local, value => _local = value, 970)); body.Children.Add(Input("Incoming SHA-256 *", "transfer-incoming-fingerprint", "DeviceTransfer.IncomingFingerprint", _incoming, value => _incoming = value, 970)); Button add = Button("Stage transfer review", "DeviceTransfer.Stage", false); add.Click += (_, _) => Stage(); body.Children.Add(add); return Panel(body, new Thickness(0, 14, 0, 0));
33+
}
34+
35+
private UIElement ReviewCard(DeviceTransferReview review)
36+
{
37+
StackPanel body = new(); DockPanel header = new(); TextBlock state = Text(review.State.ToString().ToUpperInvariant(), 11, "#AFA4FF", FontWeights.SemiBold); DockPanel.SetDock(state, Dock.Right); header.Children.Add(state); header.Children.Add(Heading(review.RecordKey, 18)); body.Children.Add(header); body.Children.Add(Text($"{review.SourceDevice}{review.DestinationDevice}", 12, "#A9B6CA")); body.Children.Add(Text($"Local {Short(review.LocalFingerprint)} • Incoming {Short(review.IncomingFingerprint)}", 12, "#E7ECF4", FontWeights.SemiBold));
38+
if (review.State == DeviceTransferState.ConflictReview) { WrapPanel actions = new(); foreach ((string label, DeviceTransferResolution resolution) in Decisions()) { Button button = Button(label, $"DeviceTransfer.Resolve.{resolution}.{review.Id:N}", true); button.Click += (_, _) => Resolve(review, resolution); actions.Children.Add(button); } body.Children.Add(actions); } else body.Children.Add(Text(review.Resolution == DeviceTransferResolution.None ? "Matching fingerprint; no transfer action was taken." : $"Decision: {review.Resolution}. Authoritative data was not changed.", 12, "#A9B6CA")); return Panel(body, new Thickness(0, 8, 0, 0));
39+
}
40+
41+
private void Stage()
42+
{
43+
DeviceTransferDraft draft = new(_source, _destination, _key, _local, _incoming); FormValidationResult validation = DeviceTransferService.Validate(draft); _issues = validation.Issues; if (!validation.IsValid) { _problem = new UserFacingProblem("device-transfer-validation-failed", "Review the transfer manifest", "No transfer review was staged because one or more fields are invalid.", "Correct the highlighted fields, then try again.", true); Render(); return; }
44+
try { DeviceTransferReview review = DeviceTransferService.Create(draft, DateTimeOffset.UtcNow); List<DeviceTransferReview> candidate = _reviews.Append(review).ToList(); DeviceTransferStorage.Save(candidate); _reviews = candidate; _source = _destination = _key = _local = _incoming = ""; _issues = []; _problem = null; _notice = review.State == DeviceTransferState.Duplicate ? "Recorded a duplicate fingerprint. No payload action occurred." : "Staged a conflict for explicit review."; Render(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) { _problem = UserFacingProblemFactory.FromException(ex, "save the transfer review"); Render(); }
45+
}
46+
47+
private void Resolve(DeviceTransferReview review, DeviceTransferResolution resolution) { try { DeviceTransferReview changed = DeviceTransferService.Resolve(review, resolution, DateTimeOffset.UtcNow); List<DeviceTransferReview> candidate = _reviews.Select(value => value.Id == review.Id ? changed : value).ToList(); DeviceTransferStorage.Save(candidate); _reviews = candidate; _notice = $"Recorded {resolution}. No authoritative record was replaced."; _problem = null; Render(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or InvalidOperationException) { _problem = UserFacingProblemFactory.FromException(ex, "resolve the transfer conflict"); Render(); } }
48+
private static IReadOnlyList<(string, DeviceTransferResolution)> Decisions() => [("Keep local", DeviceTransferResolution.KeepLocal), ("Keep incoming candidate", DeviceTransferResolution.KeepIncomingCandidate), ("Keep both candidates", DeviceTransferResolution.KeepBothCandidates), ("Reject incoming", DeviceTransferResolution.RejectIncoming)];
49+
private static string Short(string value) => value.Length < 12 ? value : value[..12] + "…";
50+
private UIElement Input(string label, string fieldId, string id, string value, Action<string> changed, double width) { StackPanel field = new() { Width = width, Margin = new Thickness(0, 7, 10, 0) }; field.Children.Add(Text(label, 12, "#C7D2E3", FontWeights.SemiBold)); TextBox input = new() { Text = value, MinHeight = 38, Background = Brush("#101827"), Foreground = Brushes.White, BorderBrush = Brush("#3A4B66"), Padding = new Thickness(10, 7, 10, 7) }; AutomationProperties.SetAutomationId(input, id); input.TextChanged += (_, _) => changed(input.Text); field.Children.Add(input); string error = string.Join(" ", _issues.Where(issue => issue.FieldId == fieldId).Select(issue => issue.Message)); if (error.Length > 0) field.Children.Add(Text(error, 11, "#FF7788", FontWeights.SemiBold)); return field; }
51+
private static Border Problem(UserFacingProblem p) { StackPanel body = new(); body.Children.Add(Heading($"{p.Title} ({p.Code})", 16)); body.Children.Add(Text(p.Detail, 12, "#E1E7F0")); body.Children.Add(Text($"Next: {p.RecoveryAction}", 12, "#C5AECF")); return new Border { Background = Brush("#251925"), BorderBrush = Brush("#C95F75"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(15), Margin = new Thickness(0, 12, 0, 0), Child = body }; }
52+
private static Button Button(string label, string id, bool secondary) { Button button = new() { Content = label, Background = Brush(secondary ? "#25334A" : "#315E91"), Foreground = Brushes.White, BorderBrush = Brush(secondary ? "#405472" : "#477DB4"), Padding = new Thickness(14, 7, 14, 7), Margin = new Thickness(0, 8, 8, 0), MinHeight = 36 }; AutomationProperties.SetAutomationId(button, id); return button; }
53+
private static Border Metric(string label, string value, string detail) { StackPanel body = new(); body.Children.Add(Text(label, 11, "#9EACC0")); body.Children.Add(Text(value, 22, "#FFFFFF", FontWeights.SemiBold)); body.Children.Add(Text(detail, 11, "#9EACC0")); return new Border { Width = 230, MinHeight = 100, Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(14), Margin = new Thickness(0, 0, 10, 10), Child = body }; }
54+
private static Border Card(string title, string body, string background) { StackPanel content = new(); content.Children.Add(Heading(title, 16)); content.Children.Add(Text(body, 12, "#C2CDDC")); return new Border { Background = Brush(background), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = new Thickness(0, 8, 0, 0), Child = content }; }
55+
private static Border Panel(UIElement child, Thickness margin) => new() { Background = Brush("#151F30"), BorderBrush = Brush("#31445F"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(9), Padding = new Thickness(16), Margin = margin, Child = child }; private static TextBlock Heading(string text, double size, Thickness? margin = null) => new() { Text = text, FontSize = size, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, TextWrapping = TextWrapping.Wrap, Margin = margin ?? new Thickness(0, 0, 0, 4) }; private static TextBlock Text(string text, double size, string color, FontWeight? weight = null) => new() { Text = text, FontSize = size, FontWeight = weight ?? FontWeights.Normal, Foreground = Brush(color), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 3, 0, 0) }; private static SolidColorBrush Brush(string value) => new((Color)ColorConverter.ConvertFromString(value));
56+
}

LifeOS.Desktop/LifeOS.Desktop.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
</PropertyGroup>
1616

1717
<PropertyGroup Label="CurrentProductVersion">
18-
<Version>28.0.0-alpha.17</Version>
18+
<Version>28.0.0-alpha.18</Version>
1919
<AssemblyVersion>28.0.0.0</AssemblyVersion>
2020
<FileVersion>28.0.0.0</FileVersion>
21-
<InformationalVersion>28.0.0-alpha.17</InformationalVersion>
21+
<InformationalVersion>28.0.0-alpha.18</InformationalVersion>
2222
</PropertyGroup></Project>

LifeOS.Desktop/MainWindow.V8Routes.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public partial class MainWindow
2626
"daily-operating-flow",
2727
"daily-state",
2828
"desktop-release",
29+
"device-transfer-review",
2930
"documentation-hub",
3031
"email-radar",
3132
"evidence-automation",
@@ -193,6 +194,9 @@ public void OpenV8Module(string routeId)
193194
case "desktop-release":
194195
ShowDesktopReleasePage();
195196
break;
197+
case "device-transfer-review":
198+
MainContentControl.Content = new DeviceTransferWorkspaceView(false);
199+
break;
196200
case "documentation-hub":
197201
ShowProductLanePage(routeId);
198202
break;

LifeOS.Desktop/V8ShellWindow.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@
373373
<StackPanel>
374374
<TextBlock Text="Companion and integration evidence remain review-first. No pending transfer mutates a workspace automatically." Style="{StaticResource LifeOS.Text.Body}" />
375375
<Button Content="Open Local Account &amp; Sync Foundation" Tag="local-account-sync" Click="OpenModule_Click" HorizontalAlignment="Left" Margin="0,12,0,0" Style="{StaticResource LifeOS.Button}" AutomationProperties.AutomationId="Workspace.Module.local-account-sync" />
376+
<Button Content="Open Device Transfer &amp; Conflict Review" Tag="device-transfer-review" Click="OpenModule_Click" HorizontalAlignment="Left" Margin="0,8,0,0" Style="{StaticResource LifeOS.Button}" AutomationProperties.AutomationId="Workspace.Module.device-transfer-review" />
376377
</StackPanel>
377378
</Border>
378379

LifeOS.Desktop/V8ShellWindow.xaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,12 @@ private void OpenModule(string routeId)
12031203
_preferences.ExperienceMode == V8ExperienceMode.PortfolioDemo));
12041204
return;
12051205
}
1206+
if (string.Equals(routeId, "device-transfer-review", StringComparison.OrdinalIgnoreCase))
1207+
{
1208+
ShowEmbeddedModule(routeId, title, subtitle, new DeviceTransferWorkspaceView(
1209+
_preferences.ExperienceMode == V8ExperienceMode.PortfolioDemo));
1210+
return;
1211+
}
12061212

12071213
MainWindow legacyModule = new()
12081214
{

LifeOS.Desktop/WorkspaceCatalog.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ private static WorkspaceModuleDefinition Navigate(
341341
{
342342
Route("settings-safety", "Settings / Safety", "Canonical settings", "Underlying safety profile and local settings evidence."),
343343
Route("local-account-sync", "Local Account & Sync", "SG-188 active", "Validated local workspace identity, manual-transfer posture and visible configuration gates without cloud authentication or background sync."),
344+
Route("device-transfer-review", "Device Transfer & Conflict Review", "SG-192 active", "Fingerprint-only transfer manifests, duplicate detection and explicit conflict decisions without payload movement or automatic merge."),
344345
Route("local-data-recovery", "Local Data & Recovery", "SG-136 ready", "Versioned local stores, migration and backup health, recoverable Trash and explicit restore controls."),
345346
Route("control-plane", "Privacy, Backup & Emergency Control", "SG-111 ready", "Sensitive-category permissions, integrity-checked backup/restore, audit and global Emergency Stop."),
346347
Route("beta-readiness", "Private Beta Readiness", "SG-82 ready", "Setup now/later/decline choices and desktop/mobile/web release validation."),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using LifeOS.Core.DeviceTransfer;
2+
using LifeOS.Shared.Storage;
3+
4+
namespace LifeOS.Shared.DeviceTransfer;
5+
6+
public sealed class DeviceTransferRepository
7+
{
8+
private readonly VersionedJsonLocalStore<List<DeviceTransferReview>> _store;
9+
public DeviceTransferRepository(string filePath, Func<List<DeviceTransferReview>>? emptyFactory = null, Func<DateTimeOffset>? utcNow = null) { _store = new VersionedJsonLocalStore<List<DeviceTransferReview>>(filePath, "device-transfer-review", 1, emptyFactory ?? (() => []), Normalize, utcNow: utcNow); }
10+
public LocalStoreLoadResult<List<DeviceTransferReview>> LoadResult() => _store.Load();
11+
public List<DeviceTransferReview> Load() => LoadResult().Value;
12+
public void Save(IEnumerable<DeviceTransferReview> reviews) => _store.Save(reviews.ToList());
13+
public LocalStoreHealth Inspect() => _store.Inspect();
14+
public IReadOnlyList<LocalStoreTrashEntry> ListTrash() => _store.ListTrash();
15+
public LocalStoreTrashEntry MoveToTrash() => _store.MoveToTrash();
16+
public void RestoreTrash(string entryId) => _store.RestoreTrash(entryId);
17+
private static List<DeviceTransferReview> Normalize(List<DeviceTransferReview> reviews) => reviews.Select(DeviceTransferService.Normalize).OrderBy(review => review.State != DeviceTransferState.ConflictReview).ThenByDescending(review => review.UpdatedUtc).ToList();
18+
}
19+
20+
public static class DeviceTransferStorage
21+
{
22+
private const string FileName = "device-transfer-review.json";
23+
public static string FilePath => LocalAppDataPath.GetFilePath(FileName);
24+
private static DeviceTransferRepository Repository() => new(FilePath, LocalAppDataPath.IsPortfolioDemoMode ? Demo : () => []);
25+
public static List<DeviceTransferReview> Load() => Repository().Load();
26+
public static void Save(IEnumerable<DeviceTransferReview> reviews) => Repository().Save(reviews);
27+
public static LocalStoreHealth Inspect() => Repository().Inspect();
28+
public static IReadOnlyList<LocalStoreTrashEntry> ListTrash() => Repository().ListTrash();
29+
public static void RestoreTrash(string entryId) => Repository().RestoreTrash(entryId);
30+
public static void Reset() { if (File.Exists(FilePath)) Repository().MoveToTrash(); }
31+
private static List<DeviceTransferReview> Demo() { DateTimeOffset now = DateTimeOffset.UtcNow; return [DeviceTransferService.Create(new DeviceTransferDraft("Fictional Laptop", "Fictional Desktop", "project/demo-1", new string('a', 64), new string('b', 64)), now.AddHours(-2)), DeviceTransferService.Create(new DeviceTransferDraft("Fictional Phone", "Fictional Desktop", "note/demo-2", new string('c', 64), new string('c', 64)), now.AddHours(-1))]; }
32+
}

0 commit comments

Comments
 (0)