Research covering WPF Generic Host wiring, MSAL per-tenant token cache (MsalCacheHelper), CommunityToolkit.Mvvm async patterns, dynamic resx localization, Serilog setup, JSON write-then-replace, and ObservableCollection threading rules. Includes validation architecture and test gap list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
43 KiB
Phase 1: Foundation - Research
Researched: 2026-04-02 Domain: WPF/.NET 10, MVVM, MSAL authentication, multi-tenant session management, structured logging, localization Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
| Decision | Value |
|---|---|
| Runtime | .NET 10 LTS + WPF |
| MVVM framework | CommunityToolkit.Mvvm 8.4.2 |
| SharePoint library | PnP.Framework 1.18.0 |
| Auth | MSAL.NET 4.83.1 + Extensions.Msal 4.83.3 + Desktop 4.82.1 |
| Token cache | MsalCacheHelper — one IPublicClientApplication per ClientId |
| DI host | Microsoft.Extensions.Hosting 10.x |
| Logging | Serilog 4.3.1 + rolling file sink → %AppData%\SharepointToolbox\logs\ |
| JSON | System.Text.Json (built-in) |
| JSON persistence | Write-then-replace (file.tmp → validate → File.Move) + SemaphoreSlim(1) per file |
| Async pattern | AsyncRelayCommand everywhere — zero async void handlers |
| Trimming | PublishTrimmed=false — accept ~150–200 MB EXE |
| Architecture | 4-layer MVVM: View → ViewModel → Service → Infrastructure |
| Cross-VM messaging | WeakReferenceMessenger for tenant-switched events |
| Session holder | Singleton SessionManager — only class that holds ClientContext objects |
| Localization | .resx resource files (EN default, FR overlay) |
Shell Layout (defaults applied — not re-litigatable)
MainWindowwith topToolBar, centerTabControl, bottom dockedRichTextBoxlog panel (150 px, always visible)StatusBarat very bottom: tenant name | operation status | progress %- Toolbar (L→R):
ComboBox(220 px, tenant list) →Button "Connect"→Button "Manage Profiles..."→ separator →Button "Clear Session" - Profile fields: Name, Tenant URL, Client ID — matches
{ name, tenantUrl, clientId }JSON exactly - All feature tabs stubbed with "Coming soon" placeholder except Settings (profile management + language)
Progress + Cancel UX (locked)
- Per-tab:
ProgressBar+TextBlock+Button "Cancel"— visible only whenIsRunning CancellationTokenSourceowned by each ViewModel, recreated per operationIProgress<OperationProgress>whereOperationProgress = { int Current, int Total, string Message }- Log panel writes every meaningful progress event (timestamped)
StatusBarupdates from active tab viaWeakReferenceMessenger
Error Surface UX (locked)
- Non-fatal: red log panel entry + per-tab status summary — no modal
- Fatal/blocking:
MessageBox.Showmodal + "Copy to Clipboard" button - No toasts in Phase 1
- Log format:
HH:mm:ss [LEVEL] Message— green=info, orange=warning, red=error - Global handlers:
Application.DispatcherUnhandledException+TaskScheduler.UnobservedTaskException - Empty catch block = build defect; enforced in code review
JSON Compatibility (locked — live user data)
| File | Schema |
|---|---|
Sharepoint_Export_profiles.json |
{ "profiles": [{ "name": "...", "tenantUrl": "...", "clientId": "..." }] } |
Sharepoint_Settings.json |
{ "dataFolder": "...", "lang": "en" } |
Localization (locked)
Strings.resx(EN/neutral default),Strings.fr.resx(FR overlay)- Key naming mirrors existing PowerShell convention:
tab.perms,btn.run.scan,menu.language, etc. - Dynamic switching:
CultureInfo.CurrentUICultureswap +WeakReferenceMessengerbroadcast - FR strings stubbed with EN fallback in Phase 1
Infrastructure Patterns (Phase 1 required deliverables)
SharePointPaginationHelper— static helper wrappingCamlQuery+ListItemCollectionPositionlooping,RowLimit ≤ 2000AsyncRelayCommandcanonical example —FeatureViewModelbase showingCancellationTokenSource+IsRunning+IProgress<OperationProgress>+OperationCanceledExceptionhandlingObservableCollectionthreading rule — accumulate inList<T>on background, thenDispatcher.InvokeAsyncwithnew ObservableCollection<T>(list)ExecuteQueryRetryAsyncwrapper — wraps PnP Framework retry; surfaces retry events as log + progress messagesClientContextdisposal — alwaysawait using; unit tests verifyDispose()on cancellation
Deferred Ideas (OUT OF SCOPE for Phase 1)
- Log panel collapsibility
- Dark/light theme toggle
- Toast/notification system
- FR locale completeness (Phase 5)
- User access export, storage charts, simplified permissions view </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| FOUND-01 | Application built with C#/WPF (.NET 10 LTS) using MVVM architecture | Generic Host DI pattern, CommunityToolkit.Mvvm ObservableObject/RelayCommand stack confirmed |
| FOUND-02 | Multi-tenant profile registry — create, rename, delete, switch tenant profiles | ProfileService using System.Text.Json + write-then-replace pattern; ComboBox bound to ObservableCollection |
| FOUND-03 | Multi-tenant session caching — stay authenticated across tenant switches | MsalCacheHelper per ClientId (one IPublicClientApplication per tenant), AcquireTokenSilent flow |
| FOUND-04 | Interactive Azure AD OAuth login via browser — no client secrets | MSAL PublicClientApplicationBuilder + AcquireTokenInteractive; PnP AuthenticationManager.CreateWithInteractiveLogin |
| FOUND-05 | All long-running operations report progress to the UI in real-time | IProgress + Progress (marshals to UI thread automatically) |
| FOUND-06 | User can cancel any long-running operation mid-execution | CancellationTokenSource per ViewModel; AsyncRelayCommand.Cancel(); OperationCanceledException handling |
| FOUND-07 | All errors surface to the user with actionable messages — no silent failures | Global DispatcherUnhandledException + TaskScheduler.UnobservedTaskException; empty-catch policy |
| FOUND-08 | Structured logging for diagnostics | Serilog 4.3.1 + Serilog.Sinks.File (rolling daily) → %AppData%\SharepointToolbox\logs\ |
| FOUND-09 | Localization system supporting English and French with dynamic language switching | Strings.resx + Strings.fr.resx; singleton TranslationSource + WeakReferenceMessenger broadcast |
| FOUND-10 | JSON-based local storage compatible with current app format | System.Text.Json; existing field names preserved exactly; write-then-replace with SemaphoreSlim(1) |
| FOUND-12 | Configurable data output folder for exports | SettingsService reads/writes Sharepoint_Settings.json; FolderBrowserDialog in Settings tab |
| </phase_requirements> |
Summary
Phase 1 establishes the entire skeleton on which all feature phases build. The technical choices are fully locked and research-validated. The stack (.NET 10 + WPF + CommunityToolkit.Mvvm + MSAL + PnP.Framework + Serilog + System.Text.Json) is internally consistent, widely documented, and has no version conflicts identified.
The three highest-risk areas for planning are: (1) WPF + Generic Host integration — the WPF STA threading model requires explicit plumbing that is not in the default Host template; (2) MSAL per-tenant token cache scoping — the MsalCacheHelper must be instantiated with a unique cache file name per ClientId, and the IPublicClientApplication instance must be kept alive in SessionManager for AcquireTokenSilent to work across tenant switches; (3) Dynamic localization without a restart — WPF's standard x:Static bindings to generated .resx classes are evaluated at startup only, so a TranslationSource singleton bound to INotifyPropertyChanged (or MarkupExtension returning a Binding) is required for runtime culture switching.
Primary recommendation: Build the Generic Host wiring, SessionManager, and TranslationSource in Wave 1 of the plan. All other components depend on DI being up and the culture system being in place.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| CommunityToolkit.Mvvm | 8.4.2 | ObservableObject, RelayCommand, AsyncRelayCommand, WeakReferenceMessenger | Microsoft-maintained; source generator MVVM; replaces MVVM Light |
| Microsoft.Extensions.Hosting | 10.x | Generic Host — DI container, lifetime, configuration | Official .NET hosting model; Serilog integrates via UseSerilog() |
| MSAL.NET (Microsoft.Identity.Client) | 4.83.1 | Public client OAuth2 interactive login | Official Microsoft identity library for desktop |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | MsalCacheHelper — cross-platform encrypted file token cache | Required for persistent token cache on desktop |
| Microsoft.Identity.Client.Broker | 4.82.1 | WAM (Windows Auth Manager) broker support | Better Windows 11 SSO; falls back gracefully |
| PnP.Framework | 1.18.0 | AuthenticationManager, ClientContext, CSOM operations | Only library containing PnP Provisioning Engine |
| Serilog | 4.3.1 | Structured logging | De-facto .NET logging library |
| Serilog.Sinks.File | (latest) | Rolling daily log file | The modern replacement for deprecated Serilog.Sinks.RollingFile |
| Serilog.Extensions.Hosting | (latest) | host.UseSerilog() integration | Wires Serilog into ILogger DI |
| System.Text.Json | built-in (.NET 10) | JSON serialization/deserialization | Zero dependency; sufficient for flat profile/settings schemas |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| Microsoft.Extensions.DependencyInjection | 10.x | DI abstractions (bundled with Hosting) | Service registration and resolution |
| xUnit | 2.x | Unit testing | ViewModel and service layer tests |
| Moq or NSubstitute | latest | Mocking in tests | Isolate services in ViewModel tests |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| CommunityToolkit.Mvvm | Prism | Prism is heavier, module-oriented; overkill for single-assembly app |
| Serilog.Sinks.File | NLog or log4net | Serilog integrates cleanly with Generic Host; NLog would work but adds config file complexity |
| System.Text.Json | Newtonsoft.Json | Newtonsoft handles more edge cases but is unnecessary for the flat schemas here |
Installation:
dotnet add package CommunityToolkit.Mvvm --version 8.4.2
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Identity.Client --version 4.83.1
dotnet add package Microsoft.Identity.Client.Extensions.Msal --version 4.83.3
dotnet add package Microsoft.Identity.Client.Broker --version 4.82.1
dotnet add package PnP.Framework --version 1.18.0
dotnet add package Serilog
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Extensions.Hosting
Architecture Patterns
Recommended Project Structure
SharepointToolbox/
├── App.xaml / App.xaml.cs # Generic Host entry point, global exception handlers
├── Core/
│ ├── Models/
│ │ ├── TenantProfile.cs # { Name, TenantUrl, ClientId }
│ │ └── OperationProgress.cs # record { int Current, int Total, string Message }
│ ├── Messages/
│ │ ├── TenantSwitchedMessage.cs
│ │ └── LanguageChangedMessage.cs
│ └── Helpers/
│ ├── SharePointPaginationHelper.cs
│ └── ExecuteQueryRetryHelper.cs
├── Infrastructure/
│ ├── Persistence/
│ │ ├── ProfileRepository.cs # write-then-replace + SemaphoreSlim(1)
│ │ └── SettingsRepository.cs
│ ├── Auth/
│ │ └── MsalClientFactory.cs # creates and caches IPublicClientApplication per ClientId
│ └── Logging/
│ └── LogPanelSink.cs # custom Serilog sink → RichTextBox
├── Services/
│ ├── SessionManager.cs # singleton, owns all ClientContext instances
│ ├── ProfileService.cs
│ └── SettingsService.cs
├── Localization/
│ ├── TranslationSource.cs # singleton INotifyPropertyChanged; ResourceManager wrapper
│ ├── Strings.resx # EN (neutral default)
│ └── Strings.fr.resx # FR overlay
├── ViewModels/
│ ├── MainWindowViewModel.cs
│ ├── ProfileManagementViewModel.cs
│ ├── FeatureViewModelBase.cs # canonical async pattern: CTS + IsRunning + IProgress
│ └── Tabs/
│ └── SettingsViewModel.cs
└── Views/
├── MainWindow.xaml
├── Dialogs/
│ └── ProfileManagementDialog.xaml
└── Tabs/
└── SettingsView.xaml
Pattern 1: Generic Host + WPF Wiring
What: Replace WPF's default StartupUri-based startup with a static Main that builds a Generic Host, then resolves MainWindow from DI.
When to use: Required for all DI-injected ViewModels and services in WPF.
Example:
// App.xaml.cs
// Source: https://formatexception.com/2024/02/adding-host-to-wpf-for-dependency-injection/
public partial class App : Application
{
[STAThread]
public static void Main(string[] args)
{
using IHost host = Host.CreateDefaultBuilder(args)
.UseSerilog((ctx, cfg) => cfg
.WriteTo.File(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30))
.ConfigureServices(RegisterServices)
.Build();
host.Start();
App app = new();
app.InitializeComponent();
app.MainWindow = host.Services.GetRequiredService<MainWindow>();
app.MainWindow.Visibility = Visibility.Visible;
app.Run();
}
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
{
services.AddSingleton<SessionManager>();
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<MsalClientFactory>();
services.AddSingleton<MainWindowViewModel>();
services.AddTransient<ProfileManagementViewModel>();
services.AddSingleton<MainWindow>();
}
}
<!-- App.xaml: remove StartupUri, keep x:Class -->
<Application x:Class="SharepointToolbox.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources/>
</Application>
<!-- SharepointToolbox.csproj: override StartupObject, demote App.xaml from ApplicationDefinition -->
<PropertyGroup>
<StartupObject>SharepointToolbox.App</StartupObject>
</PropertyGroup>
<ItemGroup>
<ApplicationDefinition Remove="App.xaml" />
<Page Include="App.xaml" />
</ItemGroup>
Pattern 2: AsyncRelayCommand Canonical Pattern (FeatureViewModelBase)
What: Base class for all feature ViewModels demonstrating CancellationTokenSource lifecycle, IsRunning binding, IProgress wiring, and graceful OperationCanceledException handling.
When to use: Every feature tab ViewModel inherits from this or replicates the pattern.
// Source: https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/asyncrelaycommand
public abstract class FeatureViewModelBase : ObservableRecipient
{
private CancellationTokenSource? _cts;
[ObservableProperty]
private bool _isRunning;
[ObservableProperty]
private string _statusMessage = string.Empty;
[ObservableProperty]
private int _progressValue;
public IAsyncRelayCommand RunCommand { get; }
public RelayCommand CancelCommand { get; }
protected FeatureViewModelBase()
{
RunCommand = new AsyncRelayCommand(ExecuteAsync, () => !IsRunning);
CancelCommand = new RelayCommand(() => _cts?.Cancel(), () => IsRunning);
}
private async Task ExecuteAsync()
{
_cts = new CancellationTokenSource();
IsRunning = true;
StatusMessage = string.Empty;
try
{
var progress = new Progress<OperationProgress>(p =>
{
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
StatusMessage = p.Message;
});
await RunOperationAsync(_cts.Token, progress);
}
catch (OperationCanceledException)
{
StatusMessage = "Operation cancelled.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
// Log via Serilog ILogger injected into derived class
}
finally
{
IsRunning = false;
_cts.Dispose();
_cts = null;
}
}
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
}
Pattern 3: MSAL Per-Tenant Token Cache
What: One IPublicClientApplication per ClientId, backed by a per-ClientId MsalCacheHelper file. SessionManager (singleton) holds the dictionary and performs AcquireTokenSilent before falling back to AcquireTokenInteractive.
When to use: Every SharePoint authentication flow.
// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/how-to/token-cache-serialization
public class MsalClientFactory
{
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _cacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "auth");
public async Task<IPublicClientApplication> GetOrCreateAsync(string clientId)
{
await _lock.WaitAsync();
try
{
if (_clients.TryGetValue(clientId, out var existing))
return existing;
var storageProps = new StorageCreationPropertiesBuilder(
$"msal_{clientId}.cache", _cacheDir)
.Build();
var pca = PublicClientApplicationBuilder
.Create(clientId)
.WithDefaultRedirectUri()
.WithLegacyCacheCompatibility(false)
.Build();
var helper = await MsalCacheHelper.CreateAsync(storageProps);
helper.RegisterCache(pca.UserTokenCache);
_clients[clientId] = pca;
return pca;
}
finally { _lock.Release(); }
}
}
Pattern 4: Dynamic Localization (TranslationSource + MarkupExtension)
What: A singleton TranslationSource implements INotifyPropertyChanged. XAML binds to it via an indexer [key]. When CurrentCulture changes, PropertyChanged fires for all keys simultaneously, refreshing every bound string in the UI — no restart required.
When to use: All localizable strings in XAML.
// TranslationSource.cs — singleton, INotifyPropertyChanged
public class TranslationSource : INotifyPropertyChanged
{
public static readonly TranslationSource Instance = new();
private ResourceManager _resourceManager = Strings.ResourceManager;
private CultureInfo _currentCulture = CultureInfo.CurrentUICulture;
public string this[string key] =>
_resourceManager.GetString(key, _currentCulture) ?? $"[{key}]";
public CultureInfo CurrentCulture
{
get => _currentCulture;
set
{
if (_currentCulture == value) return;
_currentCulture = value;
Thread.CurrentThread.CurrentUICulture = value;
// Raise PropertyChanged with null/empty = "all properties changed"
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
<!-- XAML usage — no restart needed -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance},
Path=[tab.perms]}" />
// Language switch handler (in SettingsViewModel)
// Broadcasts so StatusBar and other VMs reset any cached strings
TranslationSource.Instance.CurrentCulture = new CultureInfo("fr");
WeakReferenceMessenger.Default.Send(new LanguageChangedMessage("fr"));
Pattern 5: WeakReferenceMessenger for Tenant Switching
What: When the user selects a different tenant, MainWindowViewModel sends a TenantSwitchedMessage. All feature ViewModels that inherit ObservableRecipient register for this message and reset their state.
// Message definition (in Core/Messages/)
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
{
public TenantSwitchedMessage(TenantProfile profile) : base(profile) { }
}
// MainWindowViewModel sends on ComboBox selection change
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(selectedProfile));
// FeatureViewModelBase registers in OnActivated (ObservableRecipient lifecycle)
protected override void OnActivated()
{
Messenger.Register<TenantSwitchedMessage>(this, (r, m) =>
r.OnTenantSwitched(m.Value));
}
Pattern 6: JSON Write-Then-Replace
What: Prevents corrupt files on crash during write. Validate JSON before replacing.
// ProfileRepository.cs
private readonly SemaphoreSlim _writeLock = new(1, 1);
public async Task SaveAsync(IReadOnlyList<TenantProfile> profiles)
{
await _writeLock.WaitAsync();
try
{
var json = JsonSerializer.Serialize(
new { profiles },
new JsonSerializerOptions { WriteIndented = true });
var tmpPath = _filePath + ".tmp";
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
// Validate round-trip before replacing
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
finally { _writeLock.Release(); }
}
Pattern 7: ObservableCollection Threading Rule
What: Never modify an ObservableCollection<T> from a Task.Run background thread. The bound ItemsControl will throw or silently malfunction.
// In FeatureViewModel — collect on background, assign on UI thread
var results = new List<SiteItem>();
await Task.Run(async () =>
{
// ... enumerate, add to results ...
}, ct);
// Switch back to UI thread for collection assignment
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Items = new ObservableCollection<SiteItem>(results);
});
Anti-Patterns to Avoid
async voidevent handlers: UseAsyncRelayCommandinstead.async voidswallows exceptions silently and is untestable.- Direct
ObservableCollection.Add()from background thread: Causes cross-threadInvalidOperationException. Always use the dispatcher +new ObservableCollection<T>(list)pattern. - Single
IPublicClientApplicationfor all tenants: MSAL's token cache is scoped per app instance. Sharing one instance for multiple ClientIds causes tenant bleed. Each ClientId must have its own PCA. - Holding
ClientContextin ViewModels:ClientContextis expensive and not thread-safe. OnlySessionManagerholds it; ViewModels call a service method that takes the URL and returns results. x:Staticbindings to generated resx class:Properties.Strings.SomeKeyis resolved once at startup. It will not update whenCurrentUICulturechanges. UseTranslationSourcebinding instead.await usingonClientContextwithout cancellation check: PnP CSOM operations do not respectCancellationTokenat the HTTP level in all paths. Checkct.ThrowIfCancellationRequested()before eachExecuteQuerycall.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Token cache file encryption on Windows | Custom DPAPI wrapper | MsalCacheHelper (Extensions.Msal) |
Handles DPAPI, Mac keychain, Linux SecretService, and fallback; concurrent access safe |
| Async command with cancellation | Custom ICommand implementation |
AsyncRelayCommand from CommunityToolkit.Mvvm |
Handles re-entrancy, IsRunning, CanExecute propagation, source-generated attributes |
| Cross-VM broadcast | Events on a static class | WeakReferenceMessenger.Default |
Prevents memory leaks; no strong reference from sender to recipient |
| Retry on SharePoint throttle | Custom retry loop | Wrap PnP Framework's built-in retry in ExecuteQueryRetryAsync |
PnP already handles 429 backoff; wrapper just exposes events for progress reporting |
| CSOM list pagination | Manual rowlimit + while loop | SharePointPaginationHelper (built in Phase 1) |
Forgetting ListItemCollectionPosition on large lists causes silent data truncation at 5000 items |
| Rolling log file | Custom ILogger sink |
Serilog.Sinks.File with rollingInterval: RollingInterval.Day |
Note: Serilog.Sinks.RollingFile is deprecated — use Serilog.Sinks.File |
Key insight: The highest-value "don't hand-roll" is SharePointPaginationHelper. The existing PowerShell app likely has silent list threshold failures. Building this helper correctly in Phase 1 is what prevents PERM-07 and every other list-enumeration feature from hitting the 5,000-item wall.
Common Pitfalls
Pitfall 1: WPF STA Thread + Generic Host Conflict
What goes wrong: Host.CreateDefaultBuilder creates a multi-threaded environment. WPF requires the UI thread to be STA. If Main is not explicitly marked [STAThread], or if Application.Run() is called from the wrong thread, the application crashes at startup with a threading exception.
Why it happens: The default Program.cs generated by the WPF template uses [STAThread] on Main and calls Application.Run() directly. When replacing with Generic Host, the entry point changes and the STA attribute must be manually preserved.
How to avoid: Mark static void Main with [STAThread]. Remove StartupUri from App.xaml. Add <StartupObject> to the csproj. Demote App.xaml from ApplicationDefinition to Page.
Warning signs: InvalidOperationException: The calling thread must be STA at startup.
Pitfall 2: MSAL Token Cache Sharing Across Tenants
What goes wrong: One IPublicClientApplication is created and reused for all tenants. Tokens from tenant A contaminate the cache for tenant B, causing silent auth failures or incorrect user context.
Why it happens: IPublicClientApplication has one UserTokenCache. The cache keys internally include the ClientId; if multiple tenants use the same ClientId (which is possible in multi-tenant Azure AD apps), the cache is shared and AcquireTokenSilent may return a token for the wrong tenant account.
How to avoid: Create one IPublicClientApplication per ClientId, backed by a cache file named msal_{clientId}.cache. If two profiles share a ClientId, they share the PCA (same ClientId = same app registration), but switching requires calling AcquireTokenSilent with the correct account from pca.GetAccountsAsync().
Warning signs: User is authenticated as wrong tenant after switch; MsalUiRequiredException on switch despite being previously logged in.
Pitfall 3: Dynamic Localization Not Updating All Strings
What goes wrong: Language is switched via CultureInfo, but 30–40% of strings in the UI still show the old language. Specifically, strings bound via x:Static to the generated resource class accessor (e.g., {x:Static p:Strings.SaveButton}) are resolved at load time and never re-queried.
Why it happens: The WPF design-time resource designer generates static string properties. x:Static retrieves the value once. No INotifyPropertyChanged mechanism re-fires.
How to avoid: Use TranslationSource.Instance[key] binding pattern for all strings. Never use x:Static on the generated Strings class for UI text. The TranslationSource.PropertyChanged with an empty string key triggers WPF to re-evaluate all bindings on the source object simultaneously.
Warning signs: Some strings update on language switch, others don't; exactly the strings using x:Static are the ones that don't update.
Pitfall 4: Empty catch Swallows SharePoint Exceptions
What goes wrong: A catch (Exception) block with no body (or only a comment) causes SharePoint operations to silently fail. The user sees a blank result grid with no error message, and the log shows nothing.
Why it happens: PnP CSOM throws ServerException with SharePoint error codes. Developers add broad catch blocks during development to "handle errors later" and forget to complete them.
How to avoid: Enforce the project policy from day one: every catch block must log-and-recover, log-and-rethrow, or log-and-surface. Code review rejects any empty or comment-only catch. Serilog's structured logging makes logging trivial.
Warning signs: Operations complete in ~0ms, return zero results, log shows no entry.
Pitfall 5: ClientContext Not Disposed on Cancellation
What goes wrong: ClientContext holds an HTTP connection to SharePoint. If cancellation is requested and the ClientContext is abandoned rather than disposed, connections accumulate. Long-running sessions leak sockets.
Why it happens: The await using pattern is dropped when developers switch from the canonical pattern to a try/catch block and forget to add the finally { ctx.Dispose(); }.
How to avoid: Enforce await using in all code touching ClientContext. Unit tests verify Dispose() is called even when OperationCanceledException is thrown (mock ClientContext and assert Dispose call count).
Warning signs: SocketException or connection timeout errors appearing after the application has been running for several hours; memory growth over a long session.
Pitfall 6: ObservableCollection Modified from Background Thread
What goes wrong: Add() or Clear() called on an ObservableCollection from inside Task.Run. WPF's CollectionView throws NotSupportedException: This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread.
Why it happens: Developers call Items.Add(item) inside a for loop that runs on a background thread, which feels natural but violates WPF's cross-thread collection rule.
How to avoid: Accumulate results in a plain List<T> on the background thread. When the operation completes (or at batch boundaries), await Application.Current.Dispatcher.InvokeAsync(() => Items = new ObservableCollection<T>(list)).
Warning signs: InvalidOperationException or NotSupportedException with "Dispatcher thread" in the message, occurring only when the result set is large enough to trigger background processing.
Code Examples
Verified patterns from official sources:
MsalCacheHelper Desktop Setup
// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/how-to/token-cache-serialization
var storageProperties = new StorageCreationPropertiesBuilder(
$"msal_{clientId}.cache",
Path.Combine(Environment.GetFolderPath(
Environment.SpecialFolder.ApplicationData), "SharepointToolbox", "auth"))
.Build();
var pca = PublicClientApplicationBuilder
.Create(clientId)
.WithDefaultRedirectUri()
.WithLegacyCacheCompatibility(false)
.Build();
var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
cacheHelper.RegisterCache(pca.UserTokenCache);
AcquireTokenSilent with Interactive Fallback
// Source: MSAL.NET documentation pattern
public async Task<string> GetAccessTokenAsync(
IPublicClientApplication pca,
string[] scopes,
CancellationToken ct)
{
var accounts = await pca.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await pca.AcquireTokenSilent(scopes, accounts.FirstOrDefault())
.ExecuteAsync(ct);
}
catch (MsalUiRequiredException)
{
result = await pca.AcquireTokenInteractive(scopes)
.WithUseEmbeddedWebView(false)
.ExecuteAsync(ct);
}
return result.AccessToken;
}
PnP AuthenticationManager Interactive Login
// Source: https://pnp.github.io/pnpframework/api/PnP.Framework.AuthenticationManager.html
var authManager = AuthenticationManager.CreateWithInteractiveLogin(
clientId: profile.ClientId,
tenantId: null, // null = common endpoint (multi-tenant)
redirectUrl: "http://localhost");
await using var ctx = await authManager.GetContextAsync(profile.TenantUrl, ct);
// ctx is a SharePoint CSOM ClientContext ready for ExecuteQueryAsync
WeakReferenceMessenger Send + Register
// Source: https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/messenger
// Define message
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
{
public TenantSwitchedMessage(TenantProfile p) : base(p) { }
}
// Send (in MainWindowViewModel)
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(selected));
// Register (in ObservableRecipient-derived ViewModel)
protected override void OnActivated()
{
Messenger.Register<TenantSwitchedMessage>(this, (r, m) =>
r.HandleTenantSwitch(m.Value));
}
Serilog Setup with Rolling File
// Source: https://github.com/serilog/serilog-sinks-file
// NOTE: Use Serilog.Sinks.File — Serilog.Sinks.RollingFile is DEPRECATED
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(
path: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Global Exception Handlers in App.xaml.cs
// App.xaml.cs — wire in Application constructor or OnStartup
Application.Current.DispatcherUnhandledException += (_, e) =>
{
Log.Fatal(e.Exception, "Unhandled dispatcher exception");
MessageBox.Show(
$"An unexpected error occurred:\n\n{e.Exception.Message}\n\n" +
"Check the log file for details.",
"Unexpected Error",
MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true; // prevent crash; or set false to let it crash
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Error(e.Exception, "Unobserved task exception");
e.SetObserved(); // prevent process termination
};
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Serilog.Sinks.RollingFile NuGet | Serilog.Sinks.File with rollingInterval param |
~2018 | Rolling file is deprecated; same behavior, different package |
MSAL v2 TokenCacheCallback |
MsalCacheHelper.RegisterCache() |
MSAL 4.x | Much simpler; handles encryption and cross-platform automatically |
| ADAL.NET | MSAL.NET | 2020+ | ADAL is end-of-life; all new auth must use MSAL |
async void event handlers |
AsyncRelayCommand |
CommunityToolkit.Mvvm era | async void is an anti-pattern; toolkit makes the right thing easy |
x:Static on resx |
TranslationSource binding |
No standard date | Required for runtime culture switch without restart |
| WPF app without DI | Generic Host + WPF | .NET Core 3+ | Enables testability, Serilog wiring, and lifetime management |
Deprecated/outdated:
Serilog.Sinks.RollingFile: Deprecated; replaced bySerilog.Sinks.File. Do not add this package.Microsoft.Toolkit.Mvvm(old namespace): Superseded byCommunityToolkit.Mvvm. Same toolkit, new package ID.ADAL.NET(Microsoft.IdentityModel.Clients.ActiveDirectory): End-of-life. Use MSAL only.MvvmLight(GalaSoft): Unmaintained. CommunityToolkit.Mvvm is the successor.
Open Questions
-
PnP AuthenticationManager vs raw MSAL for token acquisition
- What we know:
PnP.Framework.AuthenticationManager.CreateWithInteractiveLoginwraps MSAL internally and produces aClientContext. There is also a constructor accepting an externalIAuthenticationProvider. - What's unclear: Whether passing an externally-managed
IPublicClientApplication(fromMsalClientFactory) intoAuthenticationManageris officially supported in PnP.Framework 1.18, or if we must create a new PCA insideAuthenticationManagerand bypassMsalClientFactory. - Recommendation: In Wave 1, spike with
CreateWithInteractiveLogin(clientId, ...)— accept that PnP creates its own internal PCA. If we need to share the token cache with a separately-created PCA, use theIAuthenticationProviderconstructor overload.
- What we know:
-
WAM Broker behavior on Windows 10 LTSC
- What we know:
Microsoft.Identity.Client.Brokerenables WAM on Windows 11. The locked runtime decision includes it. - What's unclear: Behavior on the user's Windows 10 IoT LTSC environment. WAM may not be available or may fall back silently.
- Recommendation: Configure MSAL with
.WithDefaultRedirectUri()as fallback and do not hard-require WAM. Test on Windows 10 LTSC before shipping.
- What we know:
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | xUnit 2.x |
| Config file | none — see Wave 0 |
| Quick run command | dotnet test --filter "Category=Unit" --no-build |
| Full suite command | dotnet test --no-build |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| FOUND-01 | App starts, MainWindow resolves from DI | smoke | dotnet test --filter "FullyQualifiedName~AppStartupTests" -x |
❌ Wave 0 |
| FOUND-02 | ProfileService: create/rename/delete/load profiles; JSON written correctly | unit | dotnet test --filter "FullyQualifiedName~ProfileServiceTests" -x |
❌ Wave 0 |
| FOUND-03 | MsalClientFactory: unique PCA per ClientId; same ClientId returns cached instance | unit | dotnet test --filter "FullyQualifiedName~MsalClientFactoryTests" -x |
❌ Wave 0 |
| FOUND-04 | SessionManager: AcquireTokenSilent called before Interactive; MsalUiRequiredException triggers interactive | unit (mock MSAL) | dotnet test --filter "FullyQualifiedName~SessionManagerTests" -x |
❌ Wave 0 |
| FOUND-05 | FeatureViewModelBase: IProgress updates ProgressValue and StatusMessage on UI thread | unit | dotnet test --filter "FullyQualifiedName~FeatureViewModelBaseTests" -x |
❌ Wave 0 |
| FOUND-06 | FeatureViewModelBase: CancelCommand calls CTS.Cancel(); operation stops; IsRunning resets to false | unit | dotnet test --filter "FullyQualifiedName~FeatureViewModelBaseTests" -x |
❌ Wave 0 |
| FOUND-07 | Global exception handlers log and surface (verify log written + MessageBox shown) | integration | manual-only (UI dialog) | — |
| FOUND-08 | Serilog writes to rolling file in %AppData%\SharepointToolbox\logs\ | integration | dotnet test --filter "FullyQualifiedName~LoggingIntegrationTests" -x |
❌ Wave 0 |
| FOUND-09 | TranslationSource: switching CurrentCulture fires PropertyChanged with empty key; string lookup uses new culture | unit | dotnet test --filter "FullyQualifiedName~TranslationSourceTests" -x |
❌ Wave 0 |
| FOUND-10 | ProfileRepository: write-then-replace atomicity; SemaphoreSlim prevents concurrent writes; corrupt JSON on tmp does not replace original | unit | dotnet test --filter "FullyQualifiedName~ProfileRepositoryTests" -x |
❌ Wave 0 |
| FOUND-12 | SettingsService: reads/writes Sharepoint_Settings.json; dataFolder field round-trips correctly | unit | dotnet test --filter "FullyQualifiedName~SettingsServiceTests" -x |
❌ Wave 0 |
Sampling Rate
- Per task commit:
dotnet test --filter "Category=Unit" --no-build - Per wave merge:
dotnet test --no-build - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
SharepointToolbox.Tests/SharepointToolbox.Tests.csproj— xUnit test project, add packages: xunit, xunit.runner.visualstudio, Moq (or NSubstitute), Microsoft.NET.Test.SdkSharepointToolbox.Tests/Services/ProfileServiceTests.cs— covers FOUND-02, FOUND-10SharepointToolbox.Tests/Services/SettingsServiceTests.cs— covers FOUND-12SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs— covers FOUND-03SharepointToolbox.Tests/Auth/SessionManagerTests.cs— covers FOUND-04SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs— covers FOUND-05, FOUND-06SharepointToolbox.Tests/Localization/TranslationSourceTests.cs— covers FOUND-09SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs— covers FOUND-08
Sources
Primary (HIGH confidence)
- AsyncRelayCommand — Microsoft Learn (CommunityToolkit) — AsyncRelayCommand API, IsRunning, CancellationToken, IProgress patterns
- Messenger — Microsoft Learn (CommunityToolkit) — WeakReferenceMessenger, Send/Register patterns, ValueChangedMessage
- Token Cache Serialization — Microsoft Learn (MSAL.NET) — MsalCacheHelper desktop setup, StorageCreationPropertiesBuilder, per-user cache
- PnP Framework AuthenticationManager API — CreateWithInteractiveLogin overloads, GetContextAsync
- Serilog.Sinks.File GitHub — modern rolling file sink (RollingFile deprecated)
- Existing project files:
Sharepoint_Settings.json,lang/fr.json,Sharepoint_ToolBox.ps1:1-152— exact JSON schemas and localization keys confirmed
Secondary (MEDIUM confidence)
- Adding Host to WPF for DI — FormatException (2024) — Generic Host + WPF wiring pattern (verified against Generic Host official docs)
- Custom Resource MarkupExtension — Microsoft DevBlogs — MarkupExtension for resx (verified pattern approach)
- NuGet: CommunityToolkit.Mvvm 8.4.2 — version confirmed
Tertiary (LOW confidence)
- Multiple WebSearch results on WPF localization patterns (2012–2020 vintage, not 2025-specific). The
TranslationSourcesingleton pattern is consistent across sources but no single authoritative 2025 doc was found. Implementation is straightforward enough to treat as MEDIUM.
Metadata
Confidence breakdown:
- Standard stack: HIGH — all packages are official, versions verified on NuGet, no version conflicts identified
- Architecture: HIGH — Generic Host + WPF pattern is well-documented for .NET Core+; MSAL per-tenant pattern verified against official MSAL docs
- Pitfalls: HIGH — pitfalls 1–4 are documented in official sources; pitfalls 5–6 are well-known WPF threading behaviors with extensive community documentation
- Localization (TranslationSource): MEDIUM — the
INotifyPropertyChangedsingleton approach is the standard community pattern for dynamic resx switching; no single authoritative Microsoft doc covers it end-to-end - PnP Framework auth integration: MEDIUM —
AuthenticationManager.CreateWithInteractiveLoginAPI is documented; exact behavior when combining with externalMsalClientFactoryneeds a validation spike
Research date: 2026-04-02 Valid until: 2026-05-02 (30 days — stable libraries, conservative estimate)