This document contains important learnings and patterns discovered while developing EyeGuard with Claude Code.
The StoreContext API requires proper initialization with a window handle to function correctly, especially for purchase dialogs.
Key Requirements:
- Must be called on the UI thread
- Needs a valid window handle via
WinRT.Interop.WindowNative.GetWindowHandle(window) - Must initialize with
WinRT.Interop.InitializeWithWindow.Initialize(storeContext, windowHandle)
Common Issues:
RequestPurchaseAsync()throws "Invalid window handle (0x80070578)" if not properly initialized- Error message: "This function must be called from a UI thread"
Solution Pattern:
private void EnsureStoreContext()
{
if (_storeContext == null)
{
_storeContext = StoreContext.GetDefault();
var app = Application.Current as App;
if (app != null && app.SecretWindow != null)
{
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(app.SecretWindow);
WinRT.Interop.InitializeWithWindow.Initialize(_storeContext, windowHandle);
}
}
}When working with Microsoft Store add-ons, there's an important distinction:
- Product ID (InAppOfferToken): The identifier you configure in Partner Center (e.g., "eyeguardsettingsaddon")
- Store ID: The actual ID used in the Products dictionary (e.g., "9N6MPSMNK7QD")
When calling RequestPurchaseAsync(), you must use the Store ID, not the Product ID.
Finding the Store ID at runtime:
StoreProductQueryResult addonsResult = await _storeContext.GetAssociatedStoreProductsAsync(
new string[] { "Durable" });
// Iterate through products to find by InAppOfferToken
foreach (var kvp in addonsResult.Products)
{
if (kvp.Value.InAppOfferToken == SETTINGS_ADDON_PRODUCT_ID)
{
return kvp.Value; // Contains StoreId property
}
}Implement a multi-layer caching strategy for license checks to handle network outages gracefully:
- In-memory cache: Short-term (5 minutes) to reduce API calls
- Persistent cache: Store in
ApplicationData.LocalSettingsfor offline fallback - Fallback logic: On Store API failure, use cached status
Benefits:
- Users retain access during internet outages if previously verified
- Reduces unnecessary Store API calls
- Better user experience with offline support
Pattern:
try
{
bool isPurchased = await CheckLicenseStatusAsync();
_cachedLicenseStatus = isPurchased;
SettingsService.Instance.CachedSettingsAddonPurchased = isPurchased; // Persist
return isPurchased;
}
catch (Exception ex)
{
var cachedStatus = SettingsService.Instance.CachedSettingsAddonPurchased;
return cachedStatus ?? false; // Default to not purchased if never verified
}WinUI 3 can throw reentrancy errors when doing async work during page load events.
Error:
WinRT originate error - 0x80004005: 'Reentrancy was detected in this XAML application'
Solution - Use DispatcherQueue:
private void HomePage_Loaded(object sender, RoutedEventArgs e)
{
// Defer async work to avoid reentrancy
DispatcherQueue.TryEnqueue(async () =>
{
await CheckAndShowAddonPromotion();
});
}For creating button menus with quick-select options:
<DropDownButton Content="Pause Breaks">
<DropDownButton.Flyout>
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem Text="30 minutes" Click="QuickPause30Minutes_Click"/>
<MenuFlyoutItem Text="1 hour" Click="QuickPause1Hour_Click"/>
<MenuFlyoutSeparator/>
<MenuFlyoutItem Text="Custom..." Click="QuickPauseCustom_Click"/>
</MenuFlyout>
</DropDownButton.Flyout>
</DropDownButton>For better UX, sliders should update display values in real-time but only save settings when the user releases the slider.
Benefits:
- Prevents excessive setting saves and event triggers while dragging
- Reduces unnecessary work (e.g., timer resets) during adjustment
- Provides immediate visual feedback
Implementation:
// In constructor, attach both events
BreakIntervalSlider.ValueChanged += BreakIntervalSlider_ValueChanged;
BreakIntervalSlider.ManipulationCompleted += BreakIntervalSlider_ManipulationCompleted;
// ValueChanged updates display only
private void BreakIntervalSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
int value = (int)e.NewValue;
BreakIntervalValueText.Text = value.ToString();
}
// ManipulationCompleted saves the setting
private void BreakIntervalSlider_ManipulationCompleted(object sender, ManipulationCompletedRoutedEventArgs e)
{
int value = (int)BreakIntervalSlider.Value;
SettingsService.Instance.BreakInterval = value;
}XAML:
<Slider x:Name="BreakIntervalSlider"
ManipulationMode="TranslateX"
... />Use conditional compilation to ensure production builds always use real Store API:
public enum StoreTestMode
{
DEV_NOT_PURCHASED, // Simulate no license
DEV_PURCHASED, // Simulate has license
STORE // Real Store API
}
#if DEBUG
// Can be changed for testing in Debug builds
public const StoreTestMode TEST_MODE = StoreTestMode.STORE;
#else
// Release builds always use real Store
public const StoreTestMode TEST_MODE = StoreTestMode.STORE;
#endifThis prevents accidentally shipping debug/test modes in production while allowing easy testing during development.
For Windows development, configure Git to use CRLF:
git config core.autocrlf trueThis eliminates warnings about CRLF/LF conversion during commits.
Centralize app settings in a singleton service with:
- Property getters/setters backed by
ApplicationData.LocalSettings - Events for settings changes (e.g.,
BreakIntervalChanged) - Validation and clamping of values
- Default values for first run
Enforce licensing at the UI visibility level rather than in individual handlers:
- Hide/show controls based on license status on page load
- Avoids redundant license checks in every button handler
- Cleaner, more maintainable code
- Single source of truth for license enforcement
When a setting changes that affects an active state (like a running timer), decide intelligently whether to apply the change immediately.
Pattern: Only update active state when the new value would trigger an earlier or more urgent action.
Example:
private void OnSettingChanged(object sender, int newValue)
{
// Only update if new value is more restrictive/urgent than current state
if (newValue < currentStateValue)
{
UpdateCurrentState(newValue);
}
// Otherwise, let current state complete naturally
}When storing file paths in settings, validate they exist before use and provide fallback behavior.
Pattern:
public string FileSetting
{
get
{
if (TryGetStoredValue(out var filePath))
{
// Trust app resource URIs
if (filePath.StartsWith("ms-appx://"))
return filePath;
// Validate filesystem paths
if (System.IO.File.Exists(filePath))
return filePath;
}
return DEFAULT_VALUE;
}
}Benefits:
- Graceful degradation if user deletes files
- No crashes from missing resources
- Transparent fallback behavior
When allowing users to select media files, provide preview functionality with duration display and validation.
Key components:
- Duration extraction: Load media temporarily to read metadata
- Play/Stop toggle: Allow users to preview and abort
- Validation warnings: Alert if media doesn't meet requirements
- Async loading: Use TaskCompletionSource to wait for MediaOpened event
Pattern:
private async void UpdateMediaInfo(string mediaPath)
{
var tempPlayer = new MediaPlayer { Source = MediaSource.CreateFromUri(new Uri(mediaPath)) };
var tcs = new TaskCompletionSource<bool>();
tempPlayer.MediaOpened += (s, e) => tcs.TrySetResult(true);
tempPlayer.MediaFailed += (s, e) => tcs.TrySetResult(false);
await tcs.Task;
var duration = tempPlayer.PlaybackSession.NaturalDuration;
// Display duration, show warnings if needed
tempPlayer.Dispose();
}Use Grid layout to add non-intrusive informational footers to full-screen or centered UI.
Pattern:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Main content (centered or fullscreen) -->
<ContentControl Grid.Row="0" />
<!-- Footer message (bottom-aligned) -->
<StackPanel Grid.Row="1" Margin="20">
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Opacity="0.7"/>
</StackPanel>
</Grid>Use cases:
- Feature announcements
- Tips and help text
- Version update notifications
- Action prompts without dialogs
When working with user-selected times:
- Always display pickers in local timezone
- Store internally as UTC (
DateTime.ToUniversalTime()) - Convert back to local for display (
DateTime.ToLocalTime()) - Add UI hints: "Select date and time in your local timezone"
Pattern:
// User selects local time
var localDateTime = PauseDatePicker.Date + PauseTimePicker.Time;
var localDateTimeObj = localDateTime.DateTime;
// Convert to UTC for storage
var utcDateTime = localDateTimeObj.ToUniversalTime();
SettingsService.Instance.PauseUntil = utcDateTime;
// Convert back to local for display
var localTime = pauseUntil.Value.ToLocalTime();
PauseDatePicker.Date = new DateTimeOffset(localTime.Date);