Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ You are an expert C# developer assisting with the `sillsdev/libpalaso` repositor
## 1. Code Standards & Quality
- **Modern C#:** Prefer modern C# syntax (e.g., pattern matching, switch expressions, file-scoped namespaces) unless maintaining legacy consistency.
- **Null Safety:** Strictly adhere to Nullable Reference Types. Explicitly handle potential nulls; do not suppress warnings with `!` unless absolutely necessary.
- **Cross-Platform:** Remember that LibPalaso runs on Windows and Linux (Mono/.NET). Avoid Windows-specific APIs (like `Registry` or hardcoded `\` paths) unless wrapped in OS checks.
- **Cross-Platform:** Cross-Platform: Some libraries are intended to be cross-platform. In those cases, avoid Windows-specific APIs (like `Registry`) and Windows-specific assumptions (such as hardcoded path separators) unless properly guarded or abstracted.

Projects that explicitly target Windows (e.g., `net*-windows`, WinForms/WPF) may use Windows-specific APIs where appropriate. These are generally projects with names prefixed with `SIL.Windows.Forms`.

## 2. Testing
- **Framework:** Use **NUnit** for all unit tests.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [SIL.Core.Desktop] Added a constant (kBrowserCompatibleUserAgent) to RobustNetworkOperation: a browser-like User Agent string that can be used when making HTTP requests to strict servers.
- [SIL.Core] Added an Exception property to NonFatalErrorReportExpected to return the previous reported non-fatal exception.
- [SIL.Media] Added a static PlaybackErrorMessage property to AudioFactory and a public const, kDefaultPlaybackErrorMessage, that will be used as the default message if the client does not set PlaybackErrorMessage.
- [SIL.Core.Desktop] Added IAnalytics interface.
- [SIL.Windows.Forms.Privacy] Added AnalyticsProxy class, a Winforms/registry-based implementation of IAnalytics.
- [SIL.Windows.Forms.Privacy] Added PrivacyDlg as a new standard dialog to allow user to control whether analytics tracking is allowed.

### Fixed
- [SIL.DictionaryServices] Fix memory leak in LiftWriter
Expand Down
29 changes: 29 additions & 0 deletions SIL.Core.Desktop/Privacy/AllowTrackingChangedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// --------------------------------------------------------------------------------------------
#region // Copyright (c) 2026, SIL Global. All Rights Reserved.
// <copyright from='2026' to='2026' company='SIL Global'>
// Copyright (c) 2026, SIL Global. All Rights Reserved.
//
// Distributable under the terms of the MIT License (https://sil.mit-license.org/)
// </copyright>
#endregion
// --------------------------------------------------------------------------------------------
using System;

namespace SIL.Core.Desktop.Privacy
{
/// <summary>
/// Provides data for the AllowTrackingChanged event.
/// </summary>
public sealed class AllowTrackingChangedEventArgs : EventArgs
{
/// <summary>
/// Gets a value indicating whether analytics tracking is now permitted.
/// </summary>
public bool IsTrackingAllowed { get; }

public AllowTrackingChangedEventArgs(bool isTrackingAllowed)
{
IsTrackingAllowed = isTrackingAllowed;
}
}
}
75 changes: 75 additions & 0 deletions SIL.Core.Desktop/Privacy/IAnalytics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// --------------------------------------------------------------------------------------------
#region // Copyright (c) 2026, SIL Global. All Rights Reserved.
// <copyright from='2026' to='2026' company='SIL Global'>
// Copyright (c) 2026, SIL Global. All Rights Reserved.
//
// Distributable under the terms of the MIT License (https://sil.mit-license.org/)
// </copyright>
#endregion
// --------------------------------------------------------------------------------------------

using System;

namespace SIL.Core.Desktop.Privacy
{
/// <summary>
/// Provides configuration and identity information used for analytics tracking.
/// </summary>
/// <remarks>
/// Implementations determine whether analytics events may be sent,
/// based on product-level and (optionally) organization-level settings.
/// </remarks>
public interface IAnalytics
{
event EventHandler<AllowTrackingChangedEventArgs> AllowTrackingChanged;

/// <summary>
/// Gets the name of the product (suitable for displaying in the UI) sending analytics
/// events.
/// </summary>
string ProductName { get; }

/// <summary>
/// Gets the name of the organization associated with the product (suitable for displaying
/// in the UI).
/// </summary>
string OrganizationName { get; }

/// <summary>
/// Gets a value indicating whether analytics tracking is currently permitted for this product.
/// This is determined as follows:
/// 1. If a product-specific setting exists, it takes precedence.
/// 2. Otherwise, the global/organization-wide setting is used.
/// 3. If neither setting is present, tracking is allowed by default.
/// </summary>
bool AllowTracking { get; }

/// <summary>
/// Gets a value indicating whether analytics is enabled at the organization level.
/// </summary>
/// <remarks>
/// A value of <c>true</c> tracking is currently permitted at the organization level.
/// A value of <c>false</c> tracking is currently disallowed at the organization level.
/// A value of <c>null</c> no organization-level preference has been specified.
/// </remarks>
bool? OrganizationAnalyticsEnabled { get; }

/// <summary>
/// Updates with the specified product-specific tracking permission, applying the setting
/// organization-wide if so requested.
/// </summary>
/// <param name="allowTracking">A value indicating whether analytics tracking is permitted
/// for this product.</param>
/// <param name="applyOrganizationWide">A value indicating whether the update should apply
/// to all desktop programs published the organization.</param>
/// <exception cref="T:System.UnauthorizedAccessException">The settings cannot be written
/// because the user does not have the necessary access rights.</exception>
/// <remarks>
/// In practice, this *either* sets the global value (and removes the product-specific
/// setting if present) OR it sets only the product-specific setting. This ensures that if
/// a later decision is made in a different product to change the global setting, it will
/// apply to this product as well (i.e., the product-specific setting won't override it).
/// </remarks>
void Update(bool allowTracking, bool applyOrganizationWide = false);
}
}
113 changes: 113 additions & 0 deletions SIL.Windows.Forms/Privacy/AnalyticsProxy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using System.Linq;
using JetBrains.Annotations;
using Microsoft.Win32;
using SIL.Core.Desktop.Privacy;

namespace SIL.Windows.Forms.Privacy
{
/// <summary>
/// An analytics implementation that saves settings in the Windows registry.
/// </summary>
[PublicAPI]
public class AnalyticsProxy : IAnalytics
{
private const string kRegistryValueName = "Enabled";

public event EventHandler<AllowTrackingChangedEventArgs> AllowTrackingChanged;

public string ProductName { get; }

/// <summary>
/// Constructs an instance of the AnalyticsProxy class with the specified product name.
/// </summary>
/// <param name="productName">
/// The name of the product (suitable for displaying in the UI). This will be also be used
/// as part of the registry key path for storing the product-specific analytics-enabled
/// setting, unless <see cref="ProductRegistryKeyId"/> is overridden.
/// </param>
/// <exception cref="ArgumentNullException">
/// The <paramref name="productName"/> was null
/// </exception>
public AnalyticsProxy(string productName)
{
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
}

/// <summary>
/// If <see cref="ProductName"/> (the UI name of the product) is not suitable to be used
/// as part of a registry key path, this property can be overridden to provide an alternate
/// identifier. This should be unique across products published by the organization.
/// </summary>
public virtual string ProductRegistryKeyId => ProductName;

public virtual string OrganizationName { get; } = "SIL Global";

public virtual string OrganizationRegistryKeyId { get; } = "SIL";

private string OrganizationRegistryKeyPath => $@"Software\{OrganizationRegistryKeyId}";

private string GetKeyPath(string productKeyId)
{
var productPart = productKeyId != null ? $@"\{productKeyId}" : string.Empty;
return $@"{OrganizationRegistryKeyPath}{productPart}\Analytics";
}

public bool AllowTracking =>
ReadAnalyticsEnabledState(ProductRegistryKeyId) // product-specific
?? (OrganizationAnalyticsEnabled ?? true); // fall back to global, then default to true

public bool? OrganizationAnalyticsEnabled => ReadAnalyticsEnabledState(null);

public void Update(bool allowTracking, bool applyOrganizationWide = false)
{
if (applyOrganizationWide)
{
WriteAnalyticsEnabledState(null, allowTracking);
// Since the global value is being set, we must remove the product-specific setting, so that
// if a *later* decision is made in a different product to change the global setting, it
// will apply to this product as well (i.e., the product-specific setting won't override it).
RemoveProductAnalyticsEnabledSetting();
}
else
{
// The global value is *not* being set. If it was previously set in some other product, it
// will remain in effect, but the product-specific value will override it for this product.
WriteAnalyticsEnabledState(ProductRegistryKeyId, allowTracking);
}

AllowTrackingChanged?.Invoke(this, new AllowTrackingChangedEventArgs(allowTracking));
}

private bool? ReadAnalyticsEnabledState(string productKeyId)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(GetKeyPath(productKeyId));

var value = key?.GetValue(kRegistryValueName);
if (value == null)
return null;
return Convert.ToInt32(value) != 0;
}
catch
{
return null;
}
}

private void WriteAnalyticsEnabledState(string productKeyId, bool value)
{
using var key = Registry.CurrentUser.CreateSubKey(GetKeyPath(productKeyId));
key?.SetValue(kRegistryValueName, value ? 1 : 0, RegistryValueKind.DWord);
}

private void RemoveProductAnalyticsEnabledSetting()
{
using var key =
Registry.CurrentUser.OpenSubKey(GetKeyPath(ProductRegistryKeyId), true);
if (key != null && key.GetValueNames().Contains(kRegistryValueName))
key.DeleteValue(kRegistryValueName);
}
}
}
Loading
Loading