Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/01-foundation/01-RESEARCH.md
Dev 724fdc550d chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:19:03 +02:00

843 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~150200 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)
- `MainWindow` with top `ToolBar`, center `TabControl`, bottom docked `RichTextBox` log panel (150 px, always visible)
- `StatusBar` at 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 when `IsRunning`
- `CancellationTokenSource` owned by each ViewModel, recreated per operation
- `IProgress<OperationProgress>` where `OperationProgress = { int Current, int Total, string Message }`
- Log panel writes every meaningful progress event (timestamped)
- `StatusBar` updates from active tab via `WeakReferenceMessenger`
### Error Surface UX (locked)
- Non-fatal: red log panel entry + per-tab status summary — no modal
- Fatal/blocking: `MessageBox.Show` modal + "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.CurrentUICulture` swap + `WeakReferenceMessenger` broadcast
- FR strings stubbed with EN fallback in Phase 1
### Infrastructure Patterns (Phase 1 required deliverables)
1. `SharePointPaginationHelper` — static helper wrapping `CamlQuery` + `ListItemCollectionPosition` looping, `RowLimit ≤ 2000`
2. `AsyncRelayCommand` canonical example — `FeatureViewModel` base showing `CancellationTokenSource` + `IsRunning` + `IProgress<OperationProgress>` + `OperationCanceledException` handling
3. `ObservableCollection` threading rule — accumulate in `List<T>` on background, then `Dispatcher.InvokeAsync` with `new ObservableCollection<T>(list)`
4. `ExecuteQueryRetryAsync` wrapper — wraps PnP Framework retry; surfaces retry events as log + progress messages
5. `ClientContext` disposal — always `await using`; unit tests verify `Dispose()` 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<OperationProgress> + Progress<T> (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<T> 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:**
```bash
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:**
```csharp
// 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>();
}
}
```
```xml
<!-- 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>
```
```xml
<!-- 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<OperationProgress> wiring, and graceful OperationCanceledException handling.
**When to use:** Every feature tab ViewModel inherits from this or replicates the pattern.
```csharp
// 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.
```csharp
// 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.
```csharp
// 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;
}
```
```xml
<!-- XAML usage — no restart needed -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance},
Path=[tab.perms]}" />
```
```csharp
// 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.
```csharp
// 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.
```csharp
// 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.
```csharp
// 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 void` event handlers:** Use `AsyncRelayCommand` instead. `async void` swallows exceptions silently and is untestable.
- **Direct `ObservableCollection.Add()` from background thread:** Causes cross-thread `InvalidOperationException`. Always use the dispatcher + `new ObservableCollection<T>(list)` pattern.
- **Single `IPublicClientApplication` for 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 `ClientContext` in ViewModels:** `ClientContext` is expensive and not thread-safe. Only `SessionManager` holds it; ViewModels call a service method that takes the URL and returns results.
- **`x:Static` bindings to generated resx class:** `Properties.Strings.SomeKey` is resolved once at startup. It will not update when `CurrentUICulture` changes. Use `TranslationSource` binding instead.
- **`await using` on `ClientContext` without cancellation check:** PnP CSOM operations do not respect `CancellationToken` at the HTTP level in all paths. Check `ct.ThrowIfCancellationRequested()` before each `ExecuteQuery` call.
---
## 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 3040% 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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
```csharp
// 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 by `Serilog.Sinks.File`. Do not add this package.
- `Microsoft.Toolkit.Mvvm` (old namespace): Superseded by `CommunityToolkit.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
1. **PnP AuthenticationManager vs raw MSAL for token acquisition**
- What we know: `PnP.Framework.AuthenticationManager.CreateWithInteractiveLogin` wraps MSAL internally and produces a `ClientContext`. There is also a constructor accepting an external `IAuthenticationProvider`.
- What's unclear: Whether passing an externally-managed `IPublicClientApplication` (from `MsalClientFactory`) into `AuthenticationManager` is officially supported in PnP.Framework 1.18, or if we must create a new PCA inside `AuthenticationManager` and bypass `MsalClientFactory`.
- 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 the `IAuthenticationProvider` constructor overload.
2. **WAM Broker behavior on Windows 10 LTSC**
- What we know: `Microsoft.Identity.Client.Broker` enables 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.
---
## 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<OperationProgress> 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.Sdk
- [ ] `SharepointToolbox.Tests/Services/ProfileServiceTests.cs` — covers FOUND-02, FOUND-10
- [ ] `SharepointToolbox.Tests/Services/SettingsServiceTests.cs` — covers FOUND-12
- [ ] `SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs` — covers FOUND-03
- [ ] `SharepointToolbox.Tests/Auth/SessionManagerTests.cs` — covers FOUND-04
- [ ] `SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs` — covers FOUND-05, FOUND-06
- [ ] `SharepointToolbox.Tests/Localization/TranslationSourceTests.cs` — covers FOUND-09
- [ ] `SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs` — covers FOUND-08
---
## Sources
### Primary (HIGH confidence)
- [AsyncRelayCommand — Microsoft Learn (CommunityToolkit)](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/asyncrelaycommand) — AsyncRelayCommand API, IsRunning, CancellationToken, IProgress patterns
- [Messenger — Microsoft Learn (CommunityToolkit)](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/messenger) — WeakReferenceMessenger, Send/Register patterns, ValueChangedMessage
- [Token Cache Serialization — Microsoft Learn (MSAL.NET)](https://learn.microsoft.com/en-us/entra/msal/dotnet/how-to/token-cache-serialization) — MsalCacheHelper desktop setup, StorageCreationPropertiesBuilder, per-user cache
- [PnP Framework AuthenticationManager API](https://pnp.github.io/pnpframework/api/PnP.Framework.AuthenticationManager.html) — CreateWithInteractiveLogin overloads, GetContextAsync
- [Serilog.Sinks.File GitHub](https://github.com/serilog/serilog-sinks-file) — 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)](https://formatexception.com/2024/02/adding-host-to-wpf-for-dependency-injection/) — Generic Host + WPF wiring pattern (verified against Generic Host official docs)
- [Custom Resource MarkupExtension — Microsoft DevBlogs](https://devblogs.microsoft.com/ifdef-windows/use-a-custom-resource-markup-extension-to-succeed-at-ui-string-globalization/) — MarkupExtension for resx (verified pattern approach)
- [NuGet: CommunityToolkit.Mvvm 8.4.2](https://www.nuget.org/packages/CommunityToolkit.Mvvm/) — version confirmed
### Tertiary (LOW confidence)
- Multiple WebSearch results on WPF localization patterns (20122020 vintage, not 2025-specific). The `TranslationSource` singleton 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 14 are documented in official sources; pitfalls 56 are well-known WPF threading behaviors with extensive community documentation
- Localization (TranslationSource): MEDIUM — the `INotifyPropertyChanged` singleton 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.CreateWithInteractiveLogin` API is documented; exact behavior when combining with external `MsalClientFactory` needs a validation spike
**Research date:** 2026-04-02
**Valid until:** 2026-05-02 (30 days — stable libraries, conservative estimate)