Editor/UI/TrustManagerWindow.cs
using System;
using System.Linq;
using Editor;
using Sandbox.SecBox.Bridge.Dto;
using Sandbox.SecBox.Lifecycle;
using DiagnosticsLog = Sandbox.SecBox.Bridge.DiagnosticsLog;
namespace Sandbox.SecBox.UI;
// GUI for the per-project trust store (<projectRoot>/.secbox/trust.json). Lists
// every recorded package, lets the user change its decision (Trusted / Blocked /
// Quarantined / Allowed-once / Not-reviewed) or remove the record entirely.
//
// Mutations write straight through to TrustStore.Save() - same model the
// ReviewDialog flow uses - so there's no separate dirty/Save step. The store is
// hand-editable JSON; this is just a friendlier front-end. Opened via
// secbox > Trusted Libraries...
//
// Modelled on ReviewWindow: BaseWindow + ScrollArea canvas + the same CSS idioms.
public sealed class TrustManagerWindow : BaseWindow
{
// Combo item order. Also used to map CurrentIndex back to a Decision.
static readonly Decision[] DecisionOrder =
{
Decision.TrustAlways,
Decision.AllowOnce,
Decision.NotReviewed,
Decision.Block,
Decision.Quarantine,
};
static TrustManagerWindow _instance;
string _projectRoot;
TrustStore _store;
Label _subtitle;
ScrollArea _scroll;
const string CssH1 = "font-size: 18px; font-weight: 700; color: #ffffff;";
const string CssSubtle = "color: #9aa0a6; font-size: 11px;";
const string CssCard = "background-color: #2b2b2f; border-radius: 4px; padding: 10px 12px;";
const string CssIdent = "color: #ffffff; font-size: 14px; font-weight: 700;";
const string CssMeta = "color: #9aa0a6; font-size: 11px; font-family: 'Consolas','Menlo',monospace;";
const string CssCounts = "color: #c5cad1; font-size: 11px; font-family: monospace;";
const string CssNotes = "color: #b0b6bd; font-size: 11px;";
const string CssChip = "color: white; padding: 3px 9px; border-radius: 10px; font-size: 11px; font-weight: 700;";
const string CssLabel = "color: #c5cad1; font-size: 11px;";
public static void Open()
{
if (_instance != null)
{
try { _instance.Raise(); return; }
catch { _instance = null; }
}
_instance = new TrustManagerWindow();
_instance.Show();
}
TrustManagerWindow() : base()
{
DeleteOnClose = true;
Size = new Vector2(720, 620);
WindowTitle = "secbox - trusted libraries";
SetWindowIcon("verified_user");
Layout = Layout.Column();
Layout.Margin = 16;
Layout.Spacing = 12;
BuildHeader();
_scroll = Layout.Add(new ScrollArea(this), 1);
_scroll.Canvas = new Widget(_scroll);
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Margin = 2;
_scroll.Canvas.Layout.Spacing = 8;
BuildFooter();
ReloadAndRebuild();
}
void BuildHeader()
{
var col = Layout.AddColumn();
col.Spacing = 4;
var title = new Label("Trusted libraries");
title.SetStyles(CssH1);
col.Add(title);
_subtitle = new Label("");
_subtitle.SetStyles(CssSubtle);
_subtitle.WordWrap = true;
col.Add(_subtitle);
}
void BuildFooter()
{
var row = Layout.AddRow();
row.Spacing = 8;
var reload = new Button("Reload");
reload.Icon = "refresh";
reload.Clicked = ReloadAndRebuild;
row.Add(reload);
var openJson = new Button("Open raw JSON");
openJson.Icon = "data_object";
openJson.Clicked = OnOpenRawJson;
row.Add(openJson);
row.AddStretchCell();
var close = new Button.Primary("Close");
close.Clicked = () => Close();
row.Add(close);
}
void ReloadAndRebuild()
{
_projectRoot = PackageLocator.CurrentProjectRoot();
if (string.IsNullOrEmpty(_projectRoot))
{
_store = null;
_subtitle.Text = "No project open - open a project to manage its trust store.";
RebuildList();
return;
}
try { _store = TrustStore.Load(_projectRoot); }
catch (Exception ex)
{
_store = null;
_subtitle.Text = $"Failed to load trust store: {ex.Message}";
RebuildList();
return;
}
RebuildList();
}
void RebuildList()
{
var layout = _scroll?.Canvas?.Layout;
if (layout == null) return;
layout.Clear(true);
if (_store == null)
{
AddEmptyState(layout, "Nothing to manage.");
return;
}
var entries = _store.Entries ?? new System.Collections.Generic.List<TrustEntry>();
var trusted = entries.Count(e => e.Decision == Decision.TrustAlways);
var blocked = entries.Count(e => e.Decision == Decision.Block || e.Decision == Decision.Quarantine);
var pending = entries.Count(e => e.Decision == Decision.NotReviewed);
_subtitle.Text = $"{entries.Count} package(s) · {trusted} trusted · {blocked} blocked/quarantined · {pending} not reviewed\n{_store.FilePath}";
if (entries.Count == 0)
{
AddEmptyState(layout, "No packages recorded yet. Install a library or run secbox > Scan All Libraries Now.");
return;
}
// Surface things that need attention first (not-reviewed, then blocked /
// quarantined), trusted last. Stable alphabetical within each bucket.
foreach (var e in entries.OrderBy(AttentionRank).ThenBy(e => e.PackageIdent, StringComparer.OrdinalIgnoreCase))
layout.Add(BuildEntryCard(e));
layout.AddStretchCell();
}
static int AttentionRank(TrustEntry e) => e.Decision switch
{
Decision.NotReviewed => 0,
Decision.Quarantine => 1,
Decision.Block => 2,
Decision.AllowOnce => 3,
Decision.TrustAlways => 4,
_ => 5,
};
void AddEmptyState(Layout layout, string text)
{
var l = new Label(text);
l.SetStyles(CssNotes);
l.WordWrap = true;
layout.Add(l);
layout.AddStretchCell();
}
Widget BuildEntryCard(TrustEntry entry)
{
var card = new Widget();
card.Layout = Layout.Column();
card.Layout.Spacing = 4;
card.Layout.Margin = 0;
card.SetStyles(CssCard);
// Header: ident + current-decision chip.
var headerRow = card.Layout.AddRow();
headerRow.Spacing = 8;
var ident = new Label(string.IsNullOrEmpty(entry.PackageIdent) ? "(unknown package)" : entry.PackageIdent);
ident.SetStyles(CssIdent);
ident.TextSelectable = true;
headerRow.Add(ident);
headerRow.AddStretchCell();
var chip = new Label("");
headerRow.Add(chip);
UpdateDecisionChip(chip, entry.Decision);
// Meta line: version · hash · reviewed-at.
var meta = new Label(BuildMeta(entry));
meta.SetStyles(CssMeta);
meta.WordWrap = true;
meta.TextSelectable = true;
card.Layout.Add(meta);
// Finding counts.
var counts = new Label($"Crit={entry.CriticalCount} High={entry.HighCount} Med={entry.MediumCount} Low={entry.LowCount}");
counts.SetStyles(CssCounts);
card.Layout.Add(counts);
if (!string.IsNullOrEmpty(entry.Notes))
{
var notes = new Label(entry.Notes);
notes.SetStyles(CssNotes);
notes.WordWrap = true;
card.Layout.Add(notes);
}
// Action row: decision selector + remove.
card.Layout.AddSpacingCell(4);
var actionRow = card.Layout.AddRow();
actionRow.Spacing = 6;
var decisionLabel = new Label("Decision:");
decisionLabel.SetStyles(CssLabel);
actionRow.Add(decisionLabel);
var combo = new ComboBox();
foreach (var d in DecisionOrder)
combo.AddItem(DecisionLabel(d), DecisionIcon(d));
var startIndex = Array.IndexOf(DecisionOrder, entry.Decision);
combo.CurrentIndex = startIndex < 0 ? 0 : startIndex;
// Subscribe AFTER setting the initial index so the programmatic set
// doesn't fire the handler and re-save unchanged state.
combo.ItemChanged += () => OnDecisionChanged(entry, combo, chip);
actionRow.Add(combo);
actionRow.AddStretchCell();
var remove = new Button("Remove");
remove.Icon = "delete";
remove.SetStyles("color: #ef9a9a;");
remove.Clicked = () => OnRemove(entry);
actionRow.Add(remove);
return card;
}
static string BuildMeta(TrustEntry entry)
{
var version = string.IsNullOrEmpty(entry.Version) ? null : $"v{entry.Version}";
var hash = string.IsNullOrEmpty(entry.ContentHash) ? "(no hash)" : entry.ContentHash[..Math.Min(12, entry.ContentHash.Length)] + "…";
var reviewed = entry.ReviewedAt == default ? "never reviewed" : $"reviewed {entry.ReviewedAt:yyyy-MM-dd HH:mm}";
return version == null ? $"{hash} · {reviewed}" : $"{version} · {hash} · {reviewed}";
}
void OnDecisionChanged(TrustEntry entry, ComboBox combo, Label chip)
{
var idx = combo.CurrentIndex;
if (idx < 0 || idx >= DecisionOrder.Length) return;
var newDecision = DecisionOrder[idx];
if (newDecision == entry.Decision) return;
if (_store == null) return;
entry.Decision = newDecision;
entry.ReviewedAt = DateTime.UtcNow;
entry.Notes = $"Decision set to {newDecision} via Trust Manager on {DateTime.Now:yyyy-MM-dd HH:mm}.";
try { _store.Save(); }
catch (Exception ex)
{
DiagnosticsLog.Error($"[secbox] trust manager: save failed for {entry.PackageIdent}", ex);
EditorUtility.DisplayDialog("secbox", $"Could not save trust store: {ex.Message}");
return;
}
UpdateDecisionChip(chip, newDecision);
DiagnosticsLog.Info($"[secbox] trust manager: {entry.PackageIdent} -> {newDecision}");
}
void OnRemove(TrustEntry entry)
{
if (_store == null) return;
EditorUtility.DisplayDialog(
"secbox - remove trust record",
$"Remove the trust record for {entry.PackageIdent}?\n\n"
+ "The library is not uninstalled. It will be treated as not-yet-reviewed and re-scanned on the next boot or scan.",
"Cancel", "Remove",
() =>
{
try
{
_store.Remove(entry.ContentHash);
_store.Save();
DiagnosticsLog.Info($"[secbox] trust manager: removed record for {entry.PackageIdent}");
}
catch (Exception ex)
{
DiagnosticsLog.Error($"[secbox] trust manager: remove failed for {entry.PackageIdent}", ex);
EditorUtility.DisplayDialog("secbox", $"Could not remove record: {ex.Message}");
return;
}
RebuildList();
},
icon: "🗑️");
}
void OnOpenRawJson()
{
if (_store == null || string.IsNullOrEmpty(_store.FilePath))
{
EditorUtility.DisplayDialog("secbox", "No trust store to open - open a project first.");
return;
}
if (!System.IO.File.Exists(_store.FilePath))
{
EditorUtility.DisplayDialog("secbox", $"Trust store does not exist yet at:\n{_store.FilePath}\n\nInstall a library or run a scan to create it.");
return;
}
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _store.FilePath,
UseShellExecute = true,
});
}
catch (Exception ex)
{
EditorUtility.DisplayDialog("secbox", $"Could not open: {ex.Message}\n\nPath: {_store.FilePath}");
}
}
void UpdateDecisionChip(Label chip, Decision d)
{
chip.Text = DecisionLabel(d);
chip.SetStyles($"{CssChip} background-color: {DecisionHex(d)};");
}
static string DecisionLabel(Decision d) => d switch
{
Decision.TrustAlways => "Trusted",
Decision.AllowOnce => "Allowed (session)",
Decision.NotReviewed => "Not reviewed",
Decision.Block => "Blocked",
Decision.Quarantine => "Quarantined",
_ => d.ToString(),
};
static string DecisionHex(Decision d) => d switch
{
Decision.TrustAlways => "#43a047",
Decision.AllowOnce => "#29b6f6",
Decision.NotReviewed => "#90a4ae",
Decision.Block => "#e53935",
Decision.Quarantine => "#fb8c00",
_ => "#607d8b",
};
static string DecisionIcon(Decision d) => d switch
{
Decision.TrustAlways => "verified",
Decision.AllowOnce => "schedule",
Decision.NotReviewed => "help",
Decision.Block => "block",
Decision.Quarantine => "warning",
_ => "policy",
};
public override void OnDestroyed()
{
if (_instance == this) _instance = null;
base.OnDestroyed();
}
}