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>
843 lines
43 KiB
Markdown
843 lines
43 KiB
Markdown
# 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)
|
||
|
||
- `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 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
|
||
|
||
```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 (2012–2020 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 1–4 are documented in official sources; pitfalls 5–6 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)
|