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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ 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.
- [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.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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Company>SIL Global</Company>
<Authors>SIL Global</Authors>
<Product>libpalaso</Product>
<Copyright>Copyright © 2010-2025 SIL Global</Copyright>
<Copyright>Copyright © 2010-2026 SIL Global</Copyright>
<WarningsAsErrors>NU1605;CS8002</WarningsAsErrors>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
Expand Down
8 changes: 7 additions & 1 deletion Palaso.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34031.279
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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);
}
}
34 changes: 31 additions & 3 deletions SIL.Core/IO/FileLocationUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ public static string LocateExecutable(params string[] partsOfTheSubPath)
return LocateExecutable(true, partsOfTheSubPath);
}

/// <summary>
/// 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.
/// </summary>
public static string DistFilesFolderPath;

private static string[] DirectoriesHoldingFiles => new[] {string.Empty, "DistFiles",
"common" /*for WeSay*/, "src" /*for Bloom*/};

Expand All @@ -125,12 +131,23 @@ public static string LocateExecutable(params string[] partsOfTheSubPath)
/// <example>GetFileDistributedWithApplication(false, "info", "releaseNotes.htm");</example>
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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 DistFilesFolderPath public field silently overwritten by internal caching side-effect

DistFilesFolderPath serves two conflicting roles: (1) an externally-settable configuration field (documented as "test apps that need to use a distinct output folder can indicate where to find the files they need"), and (2) an internal cache updated as a side-effect of successful file searches. When a caller explicitly sets DistFilesFolderPath to a non-standard location and then a subsequent call to GetFileDistributedWithApplication or GetDirectoryDistributedWithApplication finds a different file/directory through the standard search loop (not via the cached path), the field is overwritten at FileLocationUtilities.cs:148 or FileLocationUtilities.cs:231. After the overwrite, files that existed only in the original externally-configured path become unfindable, causing ApplicationException when optional=false. For example, the TestApp at TestApps/SIL.Windows.Forms.TestApp/Program.cs:39 sets DistFilesFolderPath to <solution-root>/DistFiles. If any later lookup finds a file through the standard DirectoriesHoldingFiles search, the cache is overwritten to that directory, and subsequent lookups for files only present in the original <solution-root>/DistFiles would fail.

Prompt for agents
In SIL.Core/IO/FileLocationUtilities.cs, the DistFilesFolderPath field is used both as a user-settable configuration and as an internal auto-populated cache, which creates a conflict. The caching at lines 148 and 231 silently overwrites any externally-set value.

To fix this, separate the two concerns. One approach:
1. Keep DistFilesFolderPath as the public externally-settable field (do NOT write to it internally).
2. Add a private static field (e.g., _cachedDistFilesFolder) for the internal caching.
3. In GetFileDistributedWithApplication and GetDirectoryDistributedWithApplication, check DistFilesFolderPath first (the user-set value), then check _cachedDistFilesFolder, then do the standard search loop. Update only _cachedDistFilesFolder when a file is found through the standard search.

This preserves the optimization while ensuring the user-configured path is never silently overwritten.

Affected lines:
- Line 116: DistFilesFolderPath field declaration
- Lines 134-149: GetFileDistributedWithApplication caching logic
- Lines 218-233: GetDirectoryDistributedWithApplication (private overload) caching logic
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return path;
}
}

if (optional)
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 24 additions & 1 deletion SIL.Core/IO/PathUtilities.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -358,6 +358,29 @@ public static void OpenDirectoryInExplorer(string directory)
});
}

/// <summary>
/// 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.
/// </summary>
/// <param name="path">A file or directory path.</param>
/// <returns>
/// Parent directory paths ordered from nearest to farthest ancestor. For example,
/// <c>"/a/b/c/file.txt"</c> yields <c>"/a/b/c"</c>, <c>"/a/b"</c>, <c>"/a"</c>.
/// </returns>
public static IEnumerable<string> 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;
}
}

/// <summary>
/// Opens the file in the application associated with the file type.
/// </summary>
Expand Down
Loading
Loading