diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 04b9cfb39..54413f92e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -31,6 +31,9 @@ jobs:
with:
fetch-depth: '0'
+ - name: Install WiX Toolset v3.14
+ run: choco install wixtoolset --version=3.14.1 -y
+
- name: Build project
run: dotnet build -bl:build.binlog -c Release
diff --git a/.gitignore b/.gitignore
index c714a0b72..816918e18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,6 @@ icu4c.readme.txt
/SIL.Windows.Forms/bin-Designer/**
launchSettings.json
*.xlf
+/.claude/settings.local.json
+# Normally `.guidsForInstaller.xml` files should be checked into source control, but this is just for the SIL.Windows.Forms.TestApp installer, which we do not deploy, and we don't care about upgrade installs.
+/DistFiles/.guidsForInstaller.xml
diff --git a/AGENTS.md b/AGENTS.md
index 1bf426d2f..84936252f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56638bdf2..8f0a7de87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- [SIL.Core.Clearshare] Added new classes MetadataCore, CreativeCommonsLicenseInfo, and CustomLicenseInfo; these are Winforms-free base versions of the classes Metadata, CreativeCommonsLicense, and CustomLicense.
-- [SIL.Core.Clearshare and SIL.Windows.Forms.Clearshare] Added LicenseUtils and LicenseWithImageUtils to handle the FromXmp method for creating a license. LicenseUtils constructs a bare license object that is Winforms-independent; LicenseWithImageUtils constructs a Winforms-dependent license object with access to license images.
+- [SIL.Core.Clearshare and SIL.Windows.Forms.Clearshare] Added LicenseUtils and LicenseWithImageUtils to handle the FromXmp method for creating a license. LicenseUtils constructs a bare license object that is Winforms-independent; LicenseWithImageUtils constructs a Winforms-dependent license object with access to license images.
- [SIL.Core.Clearshare] New methods "GetIsStringAvailableForLangId" and "GetDynamicStringOrEnglish" were added to Localizer for use in LicenseInfo's "GetBestLicenseTranslation" method, to remove LicenseInfo's L10NSharp dependency.
- [SIL.Windows.Forms.Clearshare] New ILicenseWithImage interface handles "GetImage" method for Winforms-dependent licenses, implemented in CreativeCommonsLicense and CustomLicense, and formerly included in LicenseInfo.
- [SIL.Core.Clearshare] New tests MetadataBareTests are based on previous MetadataTests in SIL.Windows.Forms.Clearshare. The tests were updated to use ImageSharp instead of Winforms for handling images.
@@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [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.Windows.Forms] Added KeysExtensions class with the IsNavigationKey extension method.
+- [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.
+- [SIL.Installer] Added new package for common installer components. Initially, this includes a Privacy dialog and code to access the registry entries so users can opt out of analytics data collection.
+- [SIL.Core] Added PathUtilities.ParentDirectories extension method.
+- [SIL.Core] Added FileLocationUtilities.DistFilesFolderPath property.
### Fixed
- [SIL.DictionaryServices] Fix memory leak in LiftWriter
@@ -73,7 +79,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- [SIL.Windows.Forms] Made ContributorsListControl.GetCurrentContribution() return null in the case when a valid row is not selected.
-- [SIL.WritingSystems] Make the English names for Chinese (Simplified) and Chinese (Traditional) consistent regardless of differences in Windows CultureInfo
+- [SIL.WritingSystems] Make the English names for Chinese (Simplified) and Chinese (Traditional) consistent regardless of differences in Windows CultureInfo
## [16.1.0] - 2025-07-18
diff --git a/Directory.Build.props b/Directory.Build.props
index 198b72e20..ede699a89 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,7 +5,7 @@
SIL Global
SIL Global
libpalaso
- Copyright © 2010-2025 SIL Global
+ Copyright © 2010-2026 SIL Global
NU1605;CS8002
prompt
4
diff --git a/Palaso.sln b/Palaso.sln
index 10a5c1f5b..e286dd341 100755
--- a/Palaso.sln
+++ b/Palaso.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34031.279
@@ -126,6 +126,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SoundFieldControlTestApp",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClipboardTestApp", "TestApps\ClipboardTestApp\ClipboardTestApp.csproj", "{9C1E4175-D098-4D9C-AF00-0857B611CEDF}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SIL.Installer", "SIL.Installer\SIL.Installer.csproj", "{3DCB916C-D4A8-4D6A-A8B8-B7555631E4E1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -320,6 +322,10 @@ Global
{9C1E4175-D098-4D9C-AF00-0857B611CEDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C1E4175-D098-4D9C-AF00-0857B611CEDF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C1E4175-D098-4D9C-AF00-0857B611CEDF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3DCB916C-D4A8-4D6A-A8B8-B7555631E4E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3DCB916C-D4A8-4D6A-A8B8-B7555631E4E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3DCB916C-D4A8-4D6A-A8B8-B7555631E4E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3DCB916C-D4A8-4D6A-A8B8-B7555631E4E1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/SIL.Core.Desktop/Privacy/AllowTrackingChangedEventArgs.cs b/SIL.Core.Desktop/Privacy/AllowTrackingChangedEventArgs.cs
new file mode 100644
index 000000000..80654eb37
--- /dev/null
+++ b/SIL.Core.Desktop/Privacy/AllowTrackingChangedEventArgs.cs
@@ -0,0 +1,29 @@
+// --------------------------------------------------------------------------------------------
+#region // Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Distributable under the terms of the MIT License (https://sil.mit-license.org/)
+//
+#endregion
+// --------------------------------------------------------------------------------------------
+using System;
+
+namespace SIL.Core.Desktop.Privacy
+{
+ ///
+ /// Provides data for the AllowTrackingChanged event.
+ ///
+ public sealed class AllowTrackingChangedEventArgs : EventArgs
+ {
+ ///
+ /// Gets a value indicating whether analytics tracking is now permitted.
+ ///
+ public bool IsTrackingAllowed { get; }
+
+ public AllowTrackingChangedEventArgs(bool isTrackingAllowed)
+ {
+ IsTrackingAllowed = isTrackingAllowed;
+ }
+ }
+}
diff --git a/SIL.Core.Desktop/Privacy/IAnalytics.cs b/SIL.Core.Desktop/Privacy/IAnalytics.cs
new file mode 100644
index 000000000..9bb0c1fa9
--- /dev/null
+++ b/SIL.Core.Desktop/Privacy/IAnalytics.cs
@@ -0,0 +1,75 @@
+// --------------------------------------------------------------------------------------------
+#region // Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Distributable under the terms of the MIT License (https://sil.mit-license.org/)
+//
+#endregion
+// --------------------------------------------------------------------------------------------
+
+using System;
+
+namespace SIL.Core.Desktop.Privacy
+{
+ ///
+ /// Provides configuration and identity information used for analytics tracking.
+ ///
+ ///
+ /// Implementations determine whether analytics events may be sent,
+ /// based on product-level and (optionally) organization-level settings.
+ ///
+ public interface IAnalytics
+ {
+ event EventHandler AllowTrackingChanged;
+
+ ///
+ /// Gets the name of the product (suitable for displaying in the UI) sending analytics
+ /// events.
+ ///
+ string ProductName { get; }
+
+ ///
+ /// Gets the name of the organization associated with the product (suitable for displaying
+ /// in the UI).
+ ///
+ string OrganizationName { get; }
+
+ ///
+ /// 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.
+ ///
+ bool AllowTracking { get; }
+
+ ///
+ /// Gets a value indicating whether analytics is enabled at the organization level.
+ ///
+ ///
+ /// A value of true tracking is currently permitted at the organization level.
+ /// A value of false tracking is currently disallowed at the organization level.
+ /// A value of null no organization-level preference has been specified.
+ ///
+ bool? OrganizationAnalyticsEnabled { get; }
+
+ ///
+ /// Updates with the specified product-specific tracking permission, applying the setting
+ /// organization-wide if so requested.
+ ///
+ /// A value indicating whether analytics tracking is permitted
+ /// for this product.
+ /// A value indicating whether the update should apply
+ /// to all desktop programs published the organization.
+ /// The settings cannot be written
+ /// because the user does not have the necessary access rights.
+ ///
+ /// 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).
+ ///
+ void Update(bool allowTracking, bool applyOrganizationWide = false);
+ }
+}
diff --git a/SIL.Core/IO/FileLocationUtilities.cs b/SIL.Core/IO/FileLocationUtilities.cs
index 3533839fa..8cf2a0f01 100644
--- a/SIL.Core/IO/FileLocationUtilities.cs
+++ b/SIL.Core/IO/FileLocationUtilities.cs
@@ -109,6 +109,12 @@ public static string LocateExecutable(params string[] partsOfTheSubPath)
return LocateExecutable(true, partsOfTheSubPath);
}
+ ///
+ /// Added so that test apps that need to use a distinct output folder can indicate where to
+ /// find the files they need without having to copy them into the build directory.
+ ///
+ public static string DistFilesFolderPath;
+
private static string[] DirectoriesHoldingFiles => new[] {string.Empty, "DistFiles",
"common" /*for WeSay*/, "src" /*for Bloom*/};
@@ -125,12 +131,23 @@ public static string LocateExecutable(params string[] partsOfTheSubPath)
/// GetFileDistributedWithApplication(false, "info", "releaseNotes.htm");
public static string GetFileDistributedWithApplication(bool optional, params string[] partsOfTheSubPath)
{
+ if (DistFilesFolderPath != null)
+ {
+ var path = Path.Combine(DistFilesFolderPath, Path.Combine(partsOfTheSubPath));
+ if (File.Exists(path))
+ return path;
+ }
+
foreach (var directoryHoldingFiles in DirectoriesHoldingFiles)
{
- var path = Path.Combine(DirectoryOfApplicationOrSolution,
- directoryHoldingFiles, Path.Combine(partsOfTheSubPath));
+ var dir = Path.Combine(DirectoryOfApplicationOrSolution,
+ directoryHoldingFiles);
+ var path = Path.Combine(dir, Path.Combine(partsOfTheSubPath));
if (File.Exists(path))
+ {
+ DistFilesFolderPath = dir; // Remember this for next time.
return path;
+ }
}
if (optional)
@@ -198,11 +215,22 @@ private static string GetDirectoryDistributedWithApplication(string directory, s
if (Directory.Exists(path))
return path;
+ if (DistFilesFolderPath != null)
+ {
+ path = Path.Combine(DistFilesFolderPath, subPath);
+ if (Directory.Exists(path))
+ return path;
+ }
+
foreach (var directoryHoldingFiles in DirectoriesHoldingFiles)
{
- path = Path.Combine(directory, directoryHoldingFiles, subPath);
+ var dir = Path.Combine(directory, directoryHoldingFiles);
+ path = Path.Combine(dir, subPath);
if (Directory.Exists(path))
+ {
+ DistFilesFolderPath = dir; // Remember this for next time.
return path;
+ }
}
return null;
diff --git a/SIL.Core/IO/PathUtilities.cs b/SIL.Core/IO/PathUtilities.cs
index 4f3481a16..8d886eadf 100644
--- a/SIL.Core/IO/PathUtilities.cs
+++ b/SIL.Core/IO/PathUtilities.cs
@@ -1,4 +1,4 @@
-// Copyright (c) 2025 SIL Global
+// Copyright (c) 2025-2026 SIL Global
// This software is licensed under the MIT License (http://opensource.org/licenses/MIT)
using System;
using System.Collections.Generic;
@@ -358,6 +358,29 @@ public static void OpenDirectoryInExplorer(string directory)
});
}
+ ///
+ /// Returns an enumerable of ancestor directory paths for the given path, starting from
+ /// the immediate parent and working up to the root. If the path is rooted, the returned
+ /// paths will also be rooted.
+ ///
+ /// A file or directory path.
+ ///
+ /// Parent directory paths ordered from nearest to farthest ancestor. For example,
+ /// "/a/b/c/file.txt" yields "/a/b/c", "/a/b", "/a".
+ ///
+ public static IEnumerable ParentDirectories(this string path)
+ {
+ var current = Path.GetDirectoryName(path);
+ while (!string.IsNullOrEmpty(current))
+ {
+ yield return current;
+ var parent = Path.GetDirectoryName(current);
+ if (parent == current)
+ break;
+ current = parent;
+ }
+ }
+
///
/// Opens the file in the application associated with the file type.
///
diff --git a/SIL.Installer/Analytics.wxs b/SIL.Installer/Analytics.wxs
new file mode 100644
index 000000000..0de0453b5
--- /dev/null
+++ b/SIL.Installer/Analytics.wxs
@@ -0,0 +1,308 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "1" AND PRODUCT_ANALYTICS_ENABLED]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NOT PRODUCT_ANALYTICS_SETTING AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+ NOT PRODUCT_ANALYTICS_SETTING AND GLOBAL_ANALYTICS_SETTING = "#1"
+
+
+ NOT PRODUCT_ANALYTICS_SETTING AND GLOBAL_ANALYTICS_SETTING = "#0"
+
+
+ PRODUCT_ANALYTICS_SETTING = "#1"
+
+
+ PRODUCT_ANALYTICS_SETTING = "#0"
+
+
+
+
+ NOT PRODUCT_ANALYTICS_SETTING AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+ GLOBAL_ANALYTICS_SETTING = "#1"
+
+
+ GLOBAL_ANALYTICS_SETTING = "#0"
+
+
+ PRODUCT_ANALYTICS_SETTING AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+
+
+
+ NOT PRODUCT_ANALYTICS_SETTING
+
+
+
+ PRODUCT_ANALYTICS_SETTING = "#0" AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+
+ GLOBAL_ANALYTICS_SETTING = PRODUCT_ANALYTICS_SETTING AND GLOBAL_ANALYTICS_SETTING
+
+
+
+
+
diff --git a/SIL.Installer/AnalyticsNavigation.wxs b/SIL.Installer/AnalyticsNavigation.wxs
new file mode 100644
index 000000000..743f73d8b
--- /dev/null
+++ b/SIL.Installer/AnalyticsNavigation.wxs
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+ LicenseAccepted = "1"
+ AND NOT PRODUCT_ANALYTICS_SETTING
+ AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+
+
+ LicenseAccepted = "1"
+ AND (PRODUCT_ANALYTICS_SETTING OR GLOBAL_ANALYTICS_SETTING)
+
+
+
+
+
+
+ NOT PRODUCT_ANALYTICS_SETTING AND NOT GLOBAL_ANALYTICS_SETTING
+
+
+
+
+ PRODUCT_ANALYTICS_SETTING OR GLOBAL_ANALYTICS_SETTING
+
+
+
+
+
diff --git a/SIL.Installer/SIL.Installer.csproj b/SIL.Installer/SIL.Installer.csproj
new file mode 100644
index 000000000..3e85d1b16
--- /dev/null
+++ b/SIL.Installer/SIL.Installer.csproj
@@ -0,0 +1,29 @@
+
+
+
+
+
+ net48
+ SIL.Installer
+ SIL.Installer
+ Reusable WiX 3 installer components for SIL applications: analytics WiX fragment.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SIL.Installer/_._ b/SIL.Installer/_._
new file mode 100644
index 000000000..e69de29bb
diff --git a/SIL.Installer/build/README.md b/SIL.Installer/build/README.md
new file mode 100644
index 000000000..d619c932f
--- /dev/null
+++ b/SIL.Installer/build/README.md
@@ -0,0 +1,71 @@
+# SIL.Installer
+
+Reusable WiX 3 installer components for SIL Windows applications.
+
+## Package Contents
+
+| Package path | Description |
+|---|---|
+| `build/Analytics.wxs` | WiX fragment: analytics properties, registry components, privacy dialog, property-set custom actions |
+| `build/AnalyticsNavigation.wxs` | Optional WiX fragment: standard LicenseAgreementDlg → PrivacyDlg → VerifyReadyDlg navigation |
+| `build/SIL.Installer.targets` | Auto-imported MSBuild targets file |
+| `lib/net48/SIL.Installer.dll` | Empty placeholder DLL (satisfies NuGet NU5017; not referenced by your project) |
+
+## Usage
+
+### 1. Reference the package
+
+Add a `PackageReference` to your `.wixproj`:
+
+```xml
+
+```
+
+The `SIL.Installer.targets` file is imported automatically. It:
+- Adds `Analytics.wxs` to the WiX compiler's `Compile` items.
+- Adds `AnalyticsNavigation.wxs` to `Compile` items (opt out with `SilAnalyticsIncludeNavigation=false`).
+
+### 2. Define required preprocessor variables
+
+In your installer's `.wxs` file (or via `DefineConstants` in the `.wixproj`):
+
+```xml
+
+
+```
+
+### 3. Reference the component group
+
+In your product's `Feature` element:
+
+```xml
+
+
+
+
+```
+
+### 4. Choose a compatible WiX UI set
+
+`AnalyticsNavigation.wxs` is included automatically and wires `LicenseAgreementDlg →
+[PrivacyDlg →] VerifyReadyDlg`. It requires a UI set that routes through
+`LicenseAgreementDlg`; `WixUI_Minimal` is not supported.
+
+### 5. Customize dialog navigation (optional)
+
+The built-in navigation intentionally bypasses any intermediate dialog the UI set normally
+shows between `LicenseAgreementDlg` and `VerifyReadyDlg` (e.g. `CustomizeDlg` in
+`WixUI_FeatureTree`, `InstallDirDlg` in `WixUI_InstallDir`). If you need those dialogs to
+appear, or if you are wiring navigation yourself for any other reason, disable the built-in
+navigation and add your own `` elements connecting to `PrivacyDlg`:
+
+```xml
+
+false
+```
+
+## Build Requirements
+
+- **WiX Toolset v3.14** must be installed on the build machine.
+ - Default path: `%ProgramFiles(x86)%\WiX Toolset v3.14\SDK\`
+- CI: install via `choco install wixtoolset --version=3.14.1 -y`
diff --git a/SIL.Installer/build/SIL.Installer.targets b/SIL.Installer/build/SIL.Installer.targets
new file mode 100644
index 000000000..dace63d8e
--- /dev/null
+++ b/SIL.Installer/build/SIL.Installer.targets
@@ -0,0 +1,27 @@
+
+
+
+
+ true
+
+
+ $(DefineConstants);SilAnalyticsSkipNavigation=1
+
+
+
+
+
+
+
+
+
+
diff --git a/SIL.Windows.Forms/Privacy/AnalyticsProxy.cs b/SIL.Windows.Forms/Privacy/AnalyticsProxy.cs
new file mode 100644
index 000000000..a24c00187
--- /dev/null
+++ b/SIL.Windows.Forms/Privacy/AnalyticsProxy.cs
@@ -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
+{
+ ///
+ /// An analytics implementation that saves settings in the Windows registry.
+ ///
+ [PublicAPI]
+ public class AnalyticsProxy : IAnalytics
+ {
+ private const string kRegistryValueName = "Enabled";
+
+ public event EventHandler AllowTrackingChanged;
+
+ public string ProductName { get; }
+
+ ///
+ /// Constructs an instance of the AnalyticsProxy class with the specified product name.
+ ///
+ ///
+ /// 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 is overridden.
+ ///
+ ///
+ /// The was null
+ ///
+ public AnalyticsProxy(string productName)
+ {
+ ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
+ }
+
+ ///
+ /// If (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.
+ ///
+ 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);
+ }
+ }
+}
diff --git a/SIL.Windows.Forms/Privacy/PrivacyDlg.Designer.cs b/SIL.Windows.Forms/Privacy/PrivacyDlg.Designer.cs
new file mode 100644
index 000000000..dda4fecdf
--- /dev/null
+++ b/SIL.Windows.Forms/Privacy/PrivacyDlg.Designer.cs
@@ -0,0 +1,215 @@
+namespace SIL.Windows.Forms.Privacy
+{
+ partial class PrivacyDlg
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ this.components = new System.ComponentModel.Container();
+ System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(PrivacyDlg));
+ this._tableLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ this._labelDescription = new System.Windows.Forms.Label();
+ this._chkProductAnalytics = new System.Windows.Forms.CheckBox();
+ this._chkPropagateDecisionGlobally = new System.Windows.Forms.CheckBox();
+ this._labelRestartNote = new System.Windows.Forms.Label();
+ this._buttonOK = new System.Windows.Forms.Button();
+ this._buttonCancel = new System.Windows.Forms.Button();
+ this.locExtender = new L10NSharp.Windows.Forms.L10NSharpExtender(this.components);
+ this._tableLayoutPanel.SuspendLayout();
+ ((System.ComponentModel.ISupportInitialize)(this.locExtender)).BeginInit();
+ this.SuspendLayout();
+ //
+ // _tableLayoutPanel
+ //
+ this._tableLayoutPanel.AutoSize = true;
+ this._tableLayoutPanel.ColumnCount = 3;
+ this._tableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
+ this._tableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ this._tableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
+ this._tableLayoutPanel.Controls.Add(this._labelDescription, 0, 0);
+ this._tableLayoutPanel.Controls.Add(this._chkProductAnalytics, 0, 1);
+ this._tableLayoutPanel.Controls.Add(this._chkPropagateDecisionGlobally, 0, 2);
+ this._tableLayoutPanel.Controls.Add(this._labelRestartNote, 0, 3);
+ this._tableLayoutPanel.Controls.Add(this._buttonOK, 1, 4);
+ this._tableLayoutPanel.Controls.Add(this._buttonCancel, 2, 4);
+ this._tableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ this._tableLayoutPanel.Location = new System.Drawing.Point(20, 20);
+ this._tableLayoutPanel.Name = "_tableLayoutPanel";
+ this._tableLayoutPanel.RowCount = 5;
+ this._tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ this._tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ this._tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ this._tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ this._tableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ this._tableLayoutPanel.Size = new System.Drawing.Size(392, 218);
+ this._tableLayoutPanel.TabIndex = 0;
+ //
+ // _labelDescription
+ //
+ this._labelDescription.AutoSize = true;
+ this._tableLayoutPanel.SetColumnSpan(this._labelDescription, 3);
+ this.locExtender.SetLocalizableToolTip(this._labelDescription, null);
+ this.locExtender.SetLocalizationComment(this._labelDescription, "Param 0: product name; Param 1: Organization name (e.g., \"SIL Global\")");
+ this.locExtender.SetLocalizingId(this._labelDescription, "DialogBoxes.PrivacyDlg.DescriptionLabel");
+ this._labelDescription.Location = new System.Drawing.Point(0, 0);
+ this._labelDescription.Margin = new System.Windows.Forms.Padding(0, 0, 0, 10);
+ this._labelDescription.MaximumSize = new System.Drawing.Size(380, 0);
+ this._labelDescription.Name = "_labelDescription";
+ this._labelDescription.Size = new System.Drawing.Size(380, 65);
+ this._labelDescription.TabIndex = 0;
+ this._labelDescription.Text = resources.GetString("_labelDescription.Text");
+ //
+ // _chkProductAnalytics
+ //
+ this._chkProductAnalytics.AutoSize = true;
+ this._tableLayoutPanel.SetColumnSpan(this._chkProductAnalytics, 3);
+ this.locExtender.SetLocalizableToolTip(this._chkProductAnalytics, null);
+ this.locExtender.SetLocalizationComment(this._chkProductAnalytics, "Param 0: product name");
+ this.locExtender.SetLocalizingId(this._chkProductAnalytics, "DialogBoxes.PrivacyDlg.ShareProductAnalyticsCheckbox");
+ this._chkProductAnalytics.Location = new System.Drawing.Point(0, 85);
+ this._chkProductAnalytics.Margin = new System.Windows.Forms.Padding(0, 10, 0, 5);
+ this._chkProductAnalytics.Name = "_chkProductAnalytics";
+ this._chkProductAnalytics.Size = new System.Drawing.Size(184, 17);
+ this._chkProductAnalytics.TabIndex = 1;
+ this._chkProductAnalytics.Text = "Share anonymous {0} usage data";
+ this._chkProductAnalytics.UseVisualStyleBackColor = true;
+ //
+ // _chkPropagateDecisionGlobally
+ //
+ this._chkPropagateDecisionGlobally.AutoSize = true;
+ this._tableLayoutPanel.SetColumnSpan(this._chkPropagateDecisionGlobally, 3);
+ this._chkPropagateDecisionGlobally.Enabled = false;
+ this.locExtender.SetLocalizableToolTip(this._chkPropagateDecisionGlobally, null);
+ this.locExtender.SetLocalizationComment(this._chkPropagateDecisionGlobally, "Param 0: \"SIL Global\"");
+ this.locExtender.SetLocalizingId(this._chkPropagateDecisionGlobally, "DialogBoxes.PrivacyDlg.ApplyGloballyCheckbox");
+ this._chkPropagateDecisionGlobally.Location = new System.Drawing.Point(0, 112);
+ this._chkPropagateDecisionGlobally.Margin = new System.Windows.Forms.Padding(0, 5, 0, 0);
+ this._chkPropagateDecisionGlobally.Name = "_chkPropagateDecisionGlobally";
+ this._chkPropagateDecisionGlobally.Padding = new System.Windows.Forms.Padding(14, 0, 0, 0);
+ this._chkPropagateDecisionGlobally.Size = new System.Drawing.Size(264, 17);
+ this._chkPropagateDecisionGlobally.TabIndex = 2;
+ this._chkPropagateDecisionGlobally.Text = "Apply this change to other {0} desktop software";
+ this._chkPropagateDecisionGlobally.UseVisualStyleBackColor = true;
+ //
+ // _labelRestartNote
+ //
+ this._labelRestartNote.AutoSize = true;
+ this._tableLayoutPanel.SetColumnSpan(this._labelRestartNote, 3);
+ this._labelRestartNote.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Italic, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
+ this._labelRestartNote.ForeColor = System.Drawing.SystemColors.GrayText;
+ this.locExtender.SetLocalizableToolTip(this._labelRestartNote, null);
+ this.locExtender.SetLocalizationComment(this._labelRestartNote, "");
+ this.locExtender.SetLocalizingId(this._labelRestartNote, "DialogBoxes.PrivacyDlg.RestartNoteLabel");
+ this._labelRestartNote.Location = new System.Drawing.Point(0, 139);
+ this._labelRestartNote.Margin = new System.Windows.Forms.Padding(0, 10, 0, 0);
+ this._labelRestartNote.MaximumSize = new System.Drawing.Size(380, 0);
+ this._labelRestartNote.Name = "_labelRestartNote";
+ this._labelRestartNote.Padding = new System.Windows.Forms.Padding(14, 0, 0, 8);
+ this._labelRestartNote.Size = new System.Drawing.Size(335, 21);
+ this._labelRestartNote.TabIndex = 5;
+ this._labelRestartNote.Text = "Changes will take effect in other programs when they are restarted.";
+ this._labelRestartNote.Visible = false;
+ //
+ // _buttonOK
+ //
+ this._buttonOK.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this._buttonOK.AutoSize = true;
+ this.locExtender.SetLocalizableToolTip(this._buttonOK, null);
+ this.locExtender.SetLocalizationComment(this._buttonOK, null);
+ this.locExtender.SetLocalizingId(this._buttonOK, "Common.OK");
+ this._buttonOK.Location = new System.Drawing.Point(234, 192);
+ this._buttonOK.Margin = new System.Windows.Forms.Padding(0);
+ this._buttonOK.MinimumSize = new System.Drawing.Size(75, 26);
+ this._buttonOK.Name = "_buttonOK";
+ this._buttonOK.Size = new System.Drawing.Size(75, 26);
+ this._buttonOK.TabIndex = 3;
+ this._buttonOK.Text = "OK";
+ this._buttonOK.UseVisualStyleBackColor = true;
+ this._buttonOK.Click += new System.EventHandler(this.HandleOkButtonClick);
+ //
+ // _buttonCancel
+ //
+ this._buttonCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this._buttonCancel.AutoSize = true;
+ this._buttonCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ this.locExtender.SetLocalizableToolTip(this._buttonCancel, null);
+ this.locExtender.SetLocalizationComment(this._buttonCancel, null);
+ this.locExtender.SetLocalizingId(this._buttonCancel, "Common.Cancel");
+ this._buttonCancel.Location = new System.Drawing.Point(317, 192);
+ this._buttonCancel.Margin = new System.Windows.Forms.Padding(8, 0, 0, 0);
+ this._buttonCancel.MinimumSize = new System.Drawing.Size(75, 26);
+ this._buttonCancel.Name = "_buttonCancel";
+ this._buttonCancel.Size = new System.Drawing.Size(75, 26);
+ this._buttonCancel.TabIndex = 4;
+ this._buttonCancel.Text = "Cancel";
+ this._buttonCancel.UseVisualStyleBackColor = true;
+ //
+ // locExtender
+ //
+ this.locExtender.LocalizationManagerId = "Palaso";
+ this.locExtender.PrefixForNewItems = null;
+ //
+ // PrivacyDlg
+ //
+ this.AcceptButton = this._buttonOK;
+ this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.AutoSize = true;
+ this.CancelButton = this._buttonCancel;
+ this.ClientSize = new System.Drawing.Size(432, 258);
+ this.Controls.Add(this._tableLayoutPanel);
+ this.locExtender.SetLocalizableToolTip(this, null);
+ this.locExtender.SetLocalizationComment(this, null);
+ this.locExtender.SetLocalizingId(this, "DialogBoxes.PrivacyDlg.WindowTitle");
+ this.MaximizeBox = false;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(444, 268);
+ this.Name = "PrivacyDlg";
+ this.Padding = new System.Windows.Forms.Padding(20);
+ this.ShowIcon = false;
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Privacy Settings";
+ this._tableLayoutPanel.ResumeLayout(false);
+ this._tableLayoutPanel.PerformLayout();
+ ((System.ComponentModel.ISupportInitialize)(this.locExtender)).EndInit();
+ this.ResumeLayout(false);
+ this.PerformLayout();
+
+ }
+
+ #endregion
+
+ private System.Windows.Forms.TableLayoutPanel _tableLayoutPanel;
+ private System.Windows.Forms.Label _labelDescription;
+ private System.Windows.Forms.CheckBox _chkProductAnalytics;
+ private System.Windows.Forms.CheckBox _chkPropagateDecisionGlobally;
+ private System.Windows.Forms.Button _buttonOK;
+ private System.Windows.Forms.Button _buttonCancel;
+ private System.Windows.Forms.Label _labelRestartNote;
+ private L10NSharp.Windows.Forms.L10NSharpExtender locExtender;
+ }
+}
diff --git a/SIL.Windows.Forms/Privacy/PrivacyDlg.cs b/SIL.Windows.Forms/Privacy/PrivacyDlg.cs
new file mode 100644
index 000000000..7d18a9d61
--- /dev/null
+++ b/SIL.Windows.Forms/Privacy/PrivacyDlg.cs
@@ -0,0 +1,127 @@
+// --------------------------------------------------------------------------------------------
+#region // Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Copyright (c) 2026, SIL Global. All Rights Reserved.
+//
+// Distributable under the terms of the MIT License (https://sil.mit-license.org/)
+//
+#endregion
+// --------------------------------------------------------------------------------------------
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+using L10NSharp;
+using SIL.Core.Desktop.Privacy;
+using static System.Environment;
+using static System.Windows.Forms.MessageBoxIcon;
+
+namespace SIL.Windows.Forms.Privacy
+{
+ public partial class PrivacyDlg : Form
+ {
+ private readonly bool _initialAnalyticsEnabledValue;
+ private readonly bool _initialGlobalInSync;
+ private readonly IAnalytics _analyticsImpl;
+ private readonly string _fmtDescription;
+ private readonly string _fmtProductCheckboxLabel;
+ private readonly string _fmtOrganizationCheckboxLabel;
+
+ public Color RestartLabelColor
+ {
+ get => _labelRestartNote.ForeColor;
+ set => _labelRestartNote.ForeColor = value;
+ }
+
+ public PrivacyDlg(IAnalytics analyticsImpl)
+ {
+ _analyticsImpl = analyticsImpl ?? throw new ArgumentNullException(nameof(analyticsImpl));
+
+ InitializeComponent();
+
+ // Substitute product/brand names into the localizable format strings.
+ // NOTE: The installer privacy dialog (Analytics.wxs in SIL.Installer) contains
+ // equivalent text that must be kept in sync manually if any of these strings change.
+ // The "apply globally" checkbox label intentionally differs from the WiX version.
+ _fmtDescription = _labelDescription.Text;
+ _fmtProductCheckboxLabel = _chkProductAnalytics.Text;
+ _fmtOrganizationCheckboxLabel = _chkPropagateDecisionGlobally.Text;
+
+ _initialAnalyticsEnabledValue = _analyticsImpl.AllowTracking;
+
+ // The Global checkbox is initially unchecked and disabled.
+ // It asks whether the product-level decision should be applied globally, but at
+ // startup no new decision has been made yet.
+ // If a global decision already exists, the product checkbox reflects either that
+ // global value or a product-specific override.
+ // Regardless, the Global checkbox remains unchecked and disabled until the user
+ // changes the product-level checkbox.
+ var globalValue = _analyticsImpl.OrganizationAnalyticsEnabled;
+ _initialGlobalInSync = globalValue == _initialAnalyticsEnabledValue;
+
+ // Populate checkboxes from current runtime state.
+ _chkProductAnalytics.Checked = _initialAnalyticsEnabledValue;
+ SetInitialGlobalCheckboxState();
+
+ _chkProductAnalytics.CheckedChanged += HandleCheckboxChanged;
+ _chkPropagateDecisionGlobally.CheckedChanged += HandleCheckboxChanged;
+ }
+
+ protected override void OnLoad(EventArgs e)
+ {
+ base.OnLoad(e);
+
+ _labelDescription.Text = string.Format(_fmtDescription,
+ _analyticsImpl.ProductName, _analyticsImpl.OrganizationName);
+ _chkProductAnalytics.Text = string.Format(_fmtProductCheckboxLabel,
+ _analyticsImpl.ProductName);
+ _chkPropagateDecisionGlobally.Text = string.Format(_fmtOrganizationCheckboxLabel,
+ _analyticsImpl.OrganizationName);
+ }
+
+ private void HandleCheckboxChanged(object sender, EventArgs e)
+ {
+ if (_chkProductAnalytics.Checked == _initialAnalyticsEnabledValue)
+ SetInitialGlobalCheckboxState();
+ else
+ _chkPropagateDecisionGlobally.Enabled = true;
+
+ // Show the restart note whenever the global checkbox is checked and clicking OK
+ // would write a new or changed value to the shared SIL analytics registry key.
+ var globalWillChange = _chkPropagateDecisionGlobally.Checked &&
+ (_chkProductAnalytics.Checked != _initialAnalyticsEnabledValue || !_initialGlobalInSync);
+ _labelRestartNote.Visible = globalWillChange;
+ }
+ private void SetInitialGlobalCheckboxState()
+ {
+ _chkPropagateDecisionGlobally.Checked = false;
+ _chkPropagateDecisionGlobally.Enabled = false;
+ }
+
+ private void HandleOkButtonClick(object sender, EventArgs e)
+ {
+ DialogResult = DialogResult.OK;
+
+ if (_chkProductAnalytics.Checked != _initialAnalyticsEnabledValue)
+ {
+ try
+ {
+ _analyticsImpl.Update(_chkProductAnalytics.Checked,
+ _chkPropagateDecisionGlobally.Checked);
+ }
+ catch (UnauthorizedAccessException exception)
+ {
+ MessageBox.Show(LocalizationManager.GetString(
+ "PrivacyDialog.UnauthorizedAccess",
+ "Sorry. Your preferences could not be saved.") +
+ NewLine + NewLine + exception.Message,
+ $"{_analyticsImpl.ProductName} - {Text}", MessageBoxButtons.OK,
+ Warning);
+
+ DialogResult = DialogResult.Cancel;
+ }
+ }
+
+ Close();
+ }
+ }
+}
diff --git a/SIL.Windows.Forms/Privacy/PrivacyDlg.resx b/SIL.Windows.Forms/Privacy/PrivacyDlg.resx
new file mode 100644
index 000000000..7720f77a5
--- /dev/null
+++ b/SIL.Windows.Forms/Privacy/PrivacyDlg.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 17, 17
+
+
+ {0} collects a small amount of usage data to help {1} improve the software. This data is intended to be anonymous and does not include personal information. However, given the small number of users and the types of data collected, we cannot strictly guarantee that individuals or language communities could never be identified. You may opt out below.
+
+
\ No newline at end of file
diff --git a/TestApps/Directory.Build.props b/TestApps/Directory.Build.props
new file mode 100644
index 000000000..c8995a728
--- /dev/null
+++ b/TestApps/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+
+
+ $(AssemblyName)
+
+
\ No newline at end of file
diff --git a/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.sln b/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.sln
new file mode 100644
index 000000000..e5bd1134e
--- /dev/null
+++ b/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.37027.9 d17.14
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "SIL.Windows.Forms.TestAppInstaller", "SIL.Windows.Forms.TestAppInstaller.wixproj", "{2A256529-31BF-49DC-9F67-B069C6986228}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|x86 = Debug|x86
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2A256529-31BF-49DC-9F67-B069C6986228}.Debug|x86.ActiveCfg = Debug|x86
+ {2A256529-31BF-49DC-9F67-B069C6986228}.Debug|x86.Build.0 = Debug|x86
+ {2A256529-31BF-49DC-9F67-B069C6986228}.Release|x86.ActiveCfg = Release|x86
+ {2A256529-31BF-49DC-9F67-B069C6986228}.Release|x86.Build.0 = Release|x86
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {C412BFA9-FE9F-4436-A51D-6F6A6B21C1FC}
+ EndGlobalSection
+EndGlobal
diff --git a/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.wxs b/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.wxs
new file mode 100644
index 000000000..2a90d0b78
--- /dev/null
+++ b/TestApps/SIL.Windows.Forms.TestApp.Installer/Installer.wxs
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TestApps/SIL.Windows.Forms.TestApp.Installer/License.rtf b/TestApps/SIL.Windows.Forms.TestApp.Installer/License.rtf
new file mode 100644
index 000000000..77a44ccd2
--- /dev/null
+++ b/TestApps/SIL.Windows.Forms.TestApp.Installer/License.rtf
@@ -0,0 +1,21 @@
+{\rtf1\ansi\deff0
+{\fonttbl{\f0\froman\fcharset0 Times New Roman;}}
+\f0\fs20
+MIT License\par
+\par
+Copyright (c) 2026 SIL Global\par
+\par
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:\par
+\par
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.\par
+\par
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\par
+}
diff --git a/TestApps/SIL.Windows.Forms.TestApp.Installer/SIL.Windows.Forms.TestAppInstaller.wixproj b/TestApps/SIL.Windows.Forms.TestApp.Installer/SIL.Windows.Forms.TestAppInstaller.wixproj
new file mode 100644
index 000000000..6ce06a81d
--- /dev/null
+++ b/TestApps/SIL.Windows.Forms.TestApp.Installer/SIL.Windows.Forms.TestAppInstaller.wixproj
@@ -0,0 +1,87 @@
+
+
+
+
+
+ Release
+ x64
+ 3.0
+ {2A256529-31BF-49DC-9F67-B069C6986228}
+ 2.0
+ SIL.Windows.Forms.TestAppInstaller
+ Package
+ output\
+ obj\$(Configuration)\
+ false
+
+ ProductName=SIL.Windows.Forms.TestApp
+ SIL.Windows.Forms.TestAppInstaller
+
+
+ $(IntermediateOutputPath)GeneratedDllsDependencies.wxs
+ $(IntermediateOutputPath)GeneratedDistFiles.wxs
+
+
+
+
+
+
+
+ Analytics.wxs
+
+
+ AnalyticsNavigation.wxs
+
+
+
+
+ $(WixExtDir)WixUIExtension.dll
+ WixUIExtension
+
+
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/TestApps/SIL.Windows.Forms.TestApp.Installer/packages.config b/TestApps/SIL.Windows.Forms.TestApp.Installer/packages.config
new file mode 100644
index 000000000..cb13f6367
--- /dev/null
+++ b/TestApps/SIL.Windows.Forms.TestApp.Installer/packages.config
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/TestApps/SIL.Windows.Forms.TestApp/Program.cs b/TestApps/SIL.Windows.Forms.TestApp/Program.cs
index 0c45562d6..819869f3d 100644
--- a/TestApps/SIL.Windows.Forms.TestApp/Program.cs
+++ b/TestApps/SIL.Windows.Forms.TestApp/Program.cs
@@ -3,10 +3,13 @@
using System.Windows.Forms;
using L10NSharp;
using L10NSharp.Windows.Forms;
+using SIL.Core.Desktop.Privacy;
using SIL.IO;
using SIL.Reporting;
+using SIL.Windows.Forms.Privacy;
using SIL.Windows.Forms.Reporting;
using SIL.WritingSystems;
+using static System.Reflection.Assembly;
namespace SIL.Windows.Forms.TestApp
{
@@ -15,6 +18,8 @@ public static class Program
internal const string kSupportEmailAddress = "bogus_test_app_email_addr@sil.org";
internal static ILocalizationManager PrimaryL10NManager;
+ internal static IAnalytics AnalyticsImpl;
+
[STAThread]
public static void Main(string[] args)
{
@@ -22,10 +27,23 @@ public static void Main(string[] args)
Application.SetCompatibleTextRenderingDefault(false);
SetUpErrorHandling();
+ SetUpAnalytics();
Sldr.Initialize();
Icu.Wrapper.Init();
+ foreach (var path in GetEntryAssembly().Location.ParentDirectories())
+ {
+ if (path.EndsWith("TestApps"))
+ {
+ FileLocationUtilities.DistFilesFolderPath = Path.Combine(
+ Path.GetDirectoryName(path),
+ "DistFiles"
+ );
+ break;
+ }
+ }
+
var preferredUILocale = "fr";
if (args.Length > 0)
{
@@ -48,5 +66,10 @@ private static void SetUpErrorHandling()
ErrorReport.AddStandardProperties();
ExceptionHandler.Init(new WinFormsExceptionHandler());
}
+
+ private static void SetUpAnalytics()
+ {
+ AnalyticsImpl = new AnalyticsProxy(Application.ProductName);
+ }
}
}
\ No newline at end of file
diff --git a/TestApps/SIL.Windows.Forms.TestApp/SIL.Windows.Forms.TestApp.csproj b/TestApps/SIL.Windows.Forms.TestApp/SIL.Windows.Forms.TestApp.csproj
index 4aeab694b..66b1492b9 100644
--- a/TestApps/SIL.Windows.Forms.TestApp/SIL.Windows.Forms.TestApp.csproj
+++ b/TestApps/SIL.Windows.Forms.TestApp/SIL.Windows.Forms.TestApp.csproj
@@ -4,8 +4,8 @@
WinExe
SIL.Windows.Forms.TestApp
SIL.Windows.Forms.TestApp
- SIL.Windows.Forms.TestApp test app
- ../../output/$(Configuration)
+ SIL.Windows.Forms test app
+ output/$(Configuration)
false
diff --git a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.Designer.cs b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.Designer.cs
index 513783907..62fb74501 100644
--- a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.Designer.cs
+++ b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.Designer.cs
@@ -56,10 +56,10 @@ protected override void Dispose(bool disposing)
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
- SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper superToolTipInfoWrapper3 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper();
- SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo superToolTipInfo3 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo();
- SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper superToolTipInfoWrapper4 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper();
- SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo superToolTipInfo4 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo();
+ SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper superToolTipInfoWrapper1 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper();
+ SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo superToolTipInfo1 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo();
+ SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper superToolTipInfoWrapper2 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfoWrapper();
+ SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo superToolTipInfo2 = new SIL.Windows.Forms.SuperToolTip.SuperToolTipInfo();
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(TestAppForm));
this._cboAboutHTML = new System.Windows.Forms.ComboBox();
this.btnRefRange = new System.Windows.Forms.Button();
@@ -88,6 +88,7 @@ private void InitializeComponent()
this.btnFolderBrowserControl = new System.Windows.Forms.Button();
this.superToolTip1 = new SIL.Windows.Forms.SuperToolTip.SuperToolTip(this.components);
this.superToolTip2 = new SIL.Windows.Forms.SuperToolTip.SuperToolTip(this.components);
+ this._btnShowPrivacyDlg = new System.Windows.Forms.Button();
this.toolStrip1.SuspendLayout();
this.SuspendLayout();
//
@@ -187,23 +188,23 @@ private void InitializeComponent()
//
this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.label1.AutoSize = true;
- this.label1.Location = new System.Drawing.Point(12, 583);
+ this.label1.Location = new System.Drawing.Point(12, 606);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(149, 13);
- superToolTipInfo3.BackgroundGradientBegin = System.Drawing.Color.FromArgb(((int)(((byte)(255)))), ((int)(((byte)(128)))), ((int)(((byte)(0)))));
- superToolTipInfo3.BackgroundGradientEnd = System.Drawing.Color.Blue;
- superToolTipInfo3.BackgroundGradientMiddle = System.Drawing.Color.FromArgb(((int)(((byte)(242)))), ((int)(((byte)(246)))), ((int)(((byte)(251)))));
- superToolTipInfo3.BodyText = "This is the body text";
- superToolTipInfo3.FooterForeColor = System.Drawing.Color.Lime;
- superToolTipInfo3.FooterText = "And this is the footer";
- superToolTipInfo3.HeaderForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(64)))), ((int)(((byte)(0)))), ((int)(((byte)(0)))));
- superToolTipInfo3.HeaderText = "The header can serve as a title";
- superToolTipInfo3.OffsetForWhereToDisplay = new System.Drawing.Point(0, 0);
- superToolTipInfo3.ShowFooter = true;
- superToolTipInfo3.ShowFooterSeparator = true;
- superToolTipInfoWrapper3.SuperToolTipInfo = superToolTipInfo3;
- superToolTipInfoWrapper3.UseSuperToolTip = true;
- this.superToolTip1.SetSuperStuff(this.label1, superToolTipInfoWrapper3);
+ superToolTipInfo1.BackgroundGradientBegin = System.Drawing.Color.FromArgb(((int)(((byte)(255)))), ((int)(((byte)(128)))), ((int)(((byte)(0)))));
+ superToolTipInfo1.BackgroundGradientEnd = System.Drawing.Color.Blue;
+ superToolTipInfo1.BackgroundGradientMiddle = System.Drawing.Color.FromArgb(((int)(((byte)(242)))), ((int)(((byte)(246)))), ((int)(((byte)(251)))));
+ superToolTipInfo1.BodyText = "This is the body text";
+ superToolTipInfo1.FooterForeColor = System.Drawing.Color.Lime;
+ superToolTipInfo1.FooterText = "And this is the footer";
+ superToolTipInfo1.HeaderForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(64)))), ((int)(((byte)(0)))), ((int)(((byte)(0)))));
+ superToolTipInfo1.HeaderText = "The header can serve as a title";
+ superToolTipInfo1.OffsetForWhereToDisplay = new System.Drawing.Point(0, 0);
+ superToolTipInfo1.ShowFooter = true;
+ superToolTipInfo1.ShowFooterSeparator = true;
+ superToolTipInfoWrapper1.SuperToolTipInfo = superToolTipInfo1;
+ superToolTipInfoWrapper1.UseSuperToolTip = true;
+ this.superToolTip1.SetSuperStuff(this.label1, superToolTipInfoWrapper1);
this.label1.TabIndex = 1;
this.label1.Text = "Hover over me to see a tooltip";
//
@@ -211,18 +212,18 @@ private void InitializeComponent()
//
this.label2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.label2.AutoSize = true;
- this.label2.Location = new System.Drawing.Point(12, 603);
+ this.label2.Location = new System.Drawing.Point(12, 626);
this.label2.Name = "label2";
this.label2.Size = new System.Drawing.Size(140, 13);
- superToolTipInfo4.BackgroundGradientBegin = System.Drawing.Color.FromArgb(((int)(((byte)(255)))), ((int)(((byte)(255)))), ((int)(((byte)(255)))));
- superToolTipInfo4.BackgroundGradientEnd = System.Drawing.Color.FromArgb(((int)(((byte)(202)))), ((int)(((byte)(218)))), ((int)(((byte)(239)))));
- superToolTipInfo4.BackgroundGradientMiddle = System.Drawing.Color.FromArgb(((int)(((byte)(242)))), ((int)(((byte)(246)))), ((int)(((byte)(251)))));
- superToolTipInfo4.BodyText = resources.GetString("superToolTipInfo4.BodyText");
- superToolTipInfo4.OffsetForWhereToDisplay = new System.Drawing.Point(0, 0);
- superToolTipInfo4.ShowHeader = false;
- superToolTipInfoWrapper4.SuperToolTipInfo = superToolTipInfo4;
- superToolTipInfoWrapper4.UseSuperToolTip = true;
- this.superToolTip2.SetSuperStuff(this.label2, superToolTipInfoWrapper4);
+ superToolTipInfo2.BackgroundGradientBegin = System.Drawing.Color.FromArgb(((int)(((byte)(255)))), ((int)(((byte)(255)))), ((int)(((byte)(255)))));
+ superToolTipInfo2.BackgroundGradientEnd = System.Drawing.Color.FromArgb(((int)(((byte)(202)))), ((int)(((byte)(218)))), ((int)(((byte)(239)))));
+ superToolTipInfo2.BackgroundGradientMiddle = System.Drawing.Color.FromArgb(((int)(((byte)(242)))), ((int)(((byte)(246)))), ((int)(((byte)(251)))));
+ superToolTipInfo2.BodyText = resources.GetString("superToolTipInfo2.BodyText");
+ superToolTipInfo2.OffsetForWhereToDisplay = new System.Drawing.Point(0, 0);
+ superToolTipInfo2.ShowHeader = false;
+ superToolTipInfoWrapper2.SuperToolTipInfo = superToolTipInfo2;
+ superToolTipInfoWrapper2.UseSuperToolTip = true;
+ this.superToolTip2.SetSuperStuff(this.label2, superToolTipInfoWrapper2);
this.label2.TabIndex = 1;
this.label2.Text = "Hover for simple, long tooltip";
//
@@ -383,11 +384,23 @@ private void InitializeComponent()
//
this.superToolTip2.FadingInterval = 10;
//
+ // _btnShowPrivacyDlg
+ //
+ this._btnShowPrivacyDlg.Location = new System.Drawing.Point(12, 577);
+ this._btnShowPrivacyDlg.Margin = new System.Windows.Forms.Padding(2);
+ this._btnShowPrivacyDlg.Name = "_btnShowPrivacyDlg";
+ this._btnShowPrivacyDlg.Size = new System.Drawing.Size(157, 23);
+ this._btnShowPrivacyDlg.TabIndex = 17;
+ this._btnShowPrivacyDlg.Text = "Privacy Dialog";
+ this._btnShowPrivacyDlg.UseVisualStyleBackColor = true;
+ this._btnShowPrivacyDlg.Click += new System.EventHandler(this._btnShowPrivacyDlg_Click);
+ //
// TestAppForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- this.ClientSize = new System.Drawing.Size(187, 627);
+ this.ClientSize = new System.Drawing.Size(187, 650);
+ this.Controls.Add(this._btnShowPrivacyDlg);
this.Controls.Add(this._cboAboutHTML);
this.Controls.Add(this.btnRefRange);
this.Controls.Add(this._btnShowFadingMessage);
@@ -450,5 +463,6 @@ private void InitializeComponent()
private System.Windows.Forms.Button _btnShowFadingMessage;
private System.Windows.Forms.Button btnRefRange;
private System.Windows.Forms.ComboBox _cboAboutHTML;
+ private System.Windows.Forms.Button _btnShowPrivacyDlg;
}
}
diff --git a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.cs b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.cs
index bc50172e0..3780d763d 100644
--- a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.cs
+++ b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.cs
@@ -28,6 +28,7 @@
using SIL.Windows.Forms.Extensions;
using SIL.Windows.Forms.FileSystem;
using SIL.Windows.Forms.LocalizationIncompleteDlg;
+using SIL.Windows.Forms.Privacy;
using static System.Windows.Forms.MessageBoxButtons;
namespace SIL.Windows.Forms.TestApp
@@ -58,6 +59,18 @@ public TestAppForm()
_localizationIncompleteViewModel, additionalNamedLocales:new Dictionary {
{ "Some untranslated language", WellKnownSubtags.UnlistedLanguage } });
_cboAboutHTML.SelectedIndex = 0;
+
+ SetPrivacyDlgButtonColor(Program.AnalyticsImpl.AllowTracking);
+
+ Program.AnalyticsImpl.AllowTrackingChanged += (sender, args) =>
+ {
+ SetPrivacyDlgButtonColor(args.IsTrackingAllowed);
+ };
+ }
+
+ void SetPrivacyDlgButtonColor(bool allowed)
+ {
+ _btnShowPrivacyDlg.BackColor = allowed ? Color.LightGreen : Color.LightPink;
}
private void IssueAnalyticsRequest()
@@ -674,5 +687,12 @@ private void btnRefRange_Click(object sender, EventArgs e)
using (var dlg = new ScrReferenceFilterDlg())
dlg.ShowDialog();
}
+
+ private void _btnShowPrivacyDlg_Click(object sender, EventArgs e)
+ {
+ using var dlg = new PrivacyDlg(Program.AnalyticsImpl);
+ dlg.RestartLabelColor = Color.Red;
+ dlg.ShowDialog(this);
+ }
}
}
\ No newline at end of file
diff --git a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.resx b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.resx
index 4551031a1..df03420b4 100644
--- a/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.resx
+++ b/TestApps/SIL.Windows.Forms.TestApp/TestAppForm.resx
@@ -126,7 +126,7 @@
17, 17
-
+
Spare ribs chicken shoulder flank, meatball sausage corned beef turducken doner bacon jowl fatback kielbasa. Shankle cow ground round buffalo ham shank. Meatball ribeye cow chuck. Doner sirloin cupim ground round rump turkey tail flank. Jerky meatball boudin biltong shankle filet mignon burgdoggen.