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

43 KiB
Raw Blame History

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 + 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

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 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

// 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 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 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)

Secondary (MEDIUM confidence)

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)