From eac64bc4f0c382b7e903ad474035000504fc55a2 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 13 Oct 2026 16:02:53 -0400 Subject: [PATCH 1/9] fix: isolate installed FieldWorks from dev paths (LT-22840) Dev launches wrote per-user overrides that made installed FLEx load incompatible configuration from a worktree. Stop the code/data and ICU writes, and keep the launch registry preflight read-only. Ignore per-user source-tree code/data paths in installed directory lookup. Provide an explicit selective repair with a WhatIf preview, preserving ProjectsDir and overrides outside identifiable source trees. Verified failing lookup regressions before the fix, full FwUtilsTests, and registry repair/preflight checks on PowerShell 5.1 and 7. --- .claude/skills/fieldworks-winapp/SKILL.md | 8 +- .../navigation/launch-or-attach.md | 25 ++-- .../scripts/Resolve-FieldWorksDevRegistry.ps1 | 111 ++---------------- Build/Repair-DeveloperRegistry.Tests.ps1 | 108 +++++++++++++++++ Build/Repair-DeveloperRegistry.ps1 | 57 +++++++++ Build/mkall.targets | 18 --- Src/Common/FwUtils/FwDirectoryFinder.cs | 44 ++++--- .../FwUtilsTests/FwDirectoryFinderTests.cs | 90 ++++++++++++++ 8 files changed, 308 insertions(+), 153 deletions(-) create mode 100644 Build/Repair-DeveloperRegistry.Tests.ps1 create mode 100644 Build/Repair-DeveloperRegistry.ps1 diff --git a/.claude/skills/fieldworks-winapp/SKILL.md b/.claude/skills/fieldworks-winapp/SKILL.md index 641d7a13d8..becc0fc3a2 100644 --- a/.claude/skills/fieldworks-winapp/SKILL.md +++ b/.claude/skills/fieldworks-winapp/SKILL.md @@ -118,10 +118,10 @@ verifies that launch will succeed. - `references/research.md`: source-backed rationale for this structure. - `scripts/Preflight-WinFormsMcp.ps1`: pre-session check (node/npx, package, build, `.mcp.json`). - `scripts/Set-FieldWorksLegacyMode.ps1`: forces `UIMode=Legacy` — run before EVERY launch. -- `scripts/Resolve-FieldWorksDevRegistry.ps1`: aligns the dev registry (`RootCodeDir`/`RootDataDir`) to - this worktree before launch; auto-realigns when the other worktree is idle, else prints `RESULT=ASK_USER`. - A build running from inside a source tree resolves code and data from that tree and - ignores these values; they govern a build launched from outside one. +- `scripts/Resolve-FieldWorksDevRegistry.ps1`: read-only preview of code/data/ICU overrides + pointing into a source tree. Dev builds use their own `DistFiles`; installed releases can + be redirected by those shared overrides. `Build/Repair-DeveloperRegistry.ps1 -WhatIf` + previews an explicit repair; applying it preserves `ProjectsDir` and non-source paths. - `references/headless-rendering.md`: why FieldWorks needs a display-bound desktop; what works/doesn't for invisible capture (winforms-mcp HEADLESS does NOT render FieldWorks; a Virtual Display Driver was tried and abandoned — see the doc for why; use visible capture, or RDP for true invisibility). Starts with the diff --git a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md index 8ef6dd97a1..fc0dc06968 100644 --- a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md +++ b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md @@ -23,14 +23,13 @@ returns empty and `winforms_take_screenshot` is blank even though the process is ``` - `Set-FieldWorksLegacyMode.ps1` forces `UIMode=Legacy` in libpalaso's settings store (`%LOCALAPPDATA%\SIL\SIL FieldWorks\*\user.config`). -- `Resolve-FieldWorksDevRegistry.ps1` ensures `HKCU\SOFTWARE\SIL\FieldWorks\9` `RootCodeDir`/`RootDataDir` - point at THIS worktree's `DistFiles` (a mismatch leaves the main window unbuilt — blank). It auto-realigns - when the other worktree is NOT active (no FieldWorks.exe running from it) and was NOT used in the last 24h - (registry key write time). If it prints `RESULT=ASK_USER`, the other worktree may be active — **ask the - user** before realigning, then re-run with `-Force` if they approve. - Since `FwDirectoryFinder` learned to anchor on the source tree it runs from, an exe built from a current - worktree already reads its own `DistFiles`; keep running the script for older builds, and for the other - registry values (`ProjectsDir`), which are still shared across worktrees. +- `Resolve-FieldWorksDevRegistry.ps1` previews code/data/ICU registry overrides pointing into a + source tree. It is read-only, including with `-Force`. Dev builds use their own `DistFiles`; + these shared overrides can redirect an installed release into incompatible worktree files. + For an explicit repair, preview `Build/Repair-DeveloperRegistry.ps1 -WhatIf`, then run it + without `-WhatIf` after closing installed FieldWorks. It preserves `ProjectsDir` and + overrides outside identifiable source trees. It cannot identify a checkout whose solution + file has been deleted or moved. See the script headers and `../references/mcp-setup.md`. @@ -99,12 +98,10 @@ See the script headers and `../references/mcp-setup.md`. relaunch (Step 0 above). If it is STILL blank in Legacy mode, check whether the process memory is *flat* (via `tasklist`): flat ≈ loaded-but-stuck (a modal that is not enumerated, or the main window failed to build), climbing ≈ still loading - (a Debug build + Sena 3 migration can take minutes — keep waiting). Also verify - the dev registry points the running exe at its OWN DistFiles: - `HKCU\SOFTWARE\SIL\FieldWorks\9` `RootCodeDir`/`RootDataDir` should match the - worktree of the launched `Output/Debug/FieldWorks.exe`; a mismatch (a different - worktree's DistFiles) can leave the main window unbuilt. Aligning it is a dev - registry change — confirm with the user before editing. + (a Debug build + Sena 3 migration can take minutes -- keep waiting). A current dev build + resolves code and data from its own source tree. For installed releases, run the read-only + registry preflight to check for worktree overrides; use the explicit repair described above. + ## Exit diff --git a/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 b/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 index 760887e098..1794e681ce 100644 --- a/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 +++ b/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 @@ -1,114 +1,19 @@ <# .SYNOPSIS - Ensure the FieldWorks dev registry (RootCodeDir/RootDataDir) points at THIS worktree before launching, - auto-realigning when it is safe to do so. - + Previews source-tree registry overrides that can redirect an installed FieldWorks. .DESCRIPTION - A FieldWorks dev build resolves its code/config (DistFiles: parts, layouts, configuration) from - HKCU\SOFTWARE\SIL\FieldWorks\9 RootCodeDir/RootDataDir. When those point at a DIFFERENT worktree than - the exe launched, the main window can fail to build (blank window, empty UIA tree), so this - skill aligns them to the running worktree before launch. - - Because that registry is shared across all worktrees, this script will NOT clobber it if the other - worktree is actively relying on it. "Actively using it" = - (a) a FieldWorks.exe process is currently running from the other worktree's tree, OR - (b) the FieldWorks registry key was last written within the last 24 hours (FieldWorks writes these - dirs on startup, so a recent write ~= a recent launch from some worktree). - If neither holds, the script realigns the registry to this worktree automatically. If either holds, - it prints `RESULT=ASK_USER` and changes nothing, so the caller can ask the user before realigning. - + Dev builds resolve code and data from their own source tree. This preflight is + read-only; use Build/Repair-DeveloperRegistry.ps1 explicitly to remove overrides. .PARAMETER Force - Realign regardless of the active-use check (use after the user approves). - + Accepted for existing callers. The preflight remains read-only. .OUTPUTS - A final line `RESULT=` for the caller to branch on. + RESULT=READ_ONLY after the preview completes. #> [CmdletBinding()] param([switch]$Force) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path -$thisDist = Join-Path $repoRoot 'DistFiles' -$regPath = 'HKCU:\SOFTWARE\SIL\FieldWorks\9' - -function Norm([string]$p) { if ([string]::IsNullOrWhiteSpace($p)) { return '' } try { return ([System.IO.Path]::GetFullPath($p)).TrimEnd('\').ToLowerInvariant() } catch { return $p.TrimEnd('\').ToLowerInvariant() } } - -if (-not (Test-Path $regPath)) { - Write-Host "[OK] No FieldWorks dev registry key yet; the exe will set it on first launch." -ForegroundColor Green - Write-Host "RESULT=NO_REGISTRY"; return -} - -$props = Get-ItemProperty -Path $regPath -$curCode = [string]$props.RootCodeDir -$curData = [string]$props.RootDataDir -Write-Host "This worktree DistFiles : $thisDist" -Write-Host "Registry RootCodeDir : $curCode" -Write-Host "Registry RootDataDir : $curData" - -if ((Norm $curCode) -eq (Norm $thisDist) -and (Norm $curData) -eq (Norm $thisDist)) { - Write-Host "[OK] Registry already points at this worktree." -ForegroundColor Green - Write-Host "RESULT=ALREADY_ALIGNED"; return -} - -function Set-DevDirs([string]$dist) { - Set-ItemProperty -Path $regPath -Name RootCodeDir -Value $dist - Set-ItemProperty -Path $regPath -Name RootDataDir -Value $dist -} - -if ($Force) { - Set-DevDirs $thisDist - Write-Host "[OK] (-Force) Realigned RootCodeDir/RootDataDir to this worktree." -ForegroundColor Green - Write-Host "RESULT=REALIGNED"; return -} - -# The "other" worktree the registry currently points at (parent of its DistFiles). -$otherRoot = if ($curCode) { (Split-Path $curCode -Parent) } else { '' } - -# (a) Active now: a FieldWorks.exe running from the other worktree's tree. -$activeProc = $null -try { - $activeProc = Get-CimInstance Win32_Process -Filter "Name='FieldWorks.exe'" -ErrorAction SilentlyContinue | - Where-Object { $_.ExecutablePath -and $otherRoot -and (Norm $_.ExecutablePath).StartsWith((Norm $otherRoot)) } | - Select-Object -First 1 -} catch {} - -# (b) Used in the last 24h: the registry key's last-write time (FieldWorks writes these dirs on startup). -$recent = $false -try { - $sig = @' -using System; -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; -public static class FwRegLW { - [DllImport("advapi32.dll", SetLastError=true)] - public static extern int RegQueryInfoKey(SafeRegistryHandle hKey, IntPtr c, IntPtr cc, IntPtr r, - IntPtr sk, IntPtr msk, IntPtr mc, IntPtr v, IntPtr mvn, IntPtr mvl, IntPtr sd, - out System.Runtime.InteropServices.ComTypes.FILETIME ft); -} -'@ - if (-not ('FwRegLW' -as [type])) { Add-Type -TypeDefinition $sig } - $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('SOFTWARE\SIL\FieldWorks\9') - $ft = New-Object System.Runtime.InteropServices.ComTypes.FILETIME - if ([FwRegLW]::RegQueryInfoKey($key.Handle, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [ref]$ft) -eq 0) { - # FILETIME fields are signed Int32; reinterpret their raw bits as unsigned before combining. - $high = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($ft.dwHighDateTime), 0) - $low = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($ft.dwLowDateTime), 0) - $lastWrite = [DateTime]::FromFileTimeUtc((([long]$high) -shl 32) -bor [long]$low) - Write-Host "Registry key last written: $($lastWrite.ToLocalTime())" - $recent = $lastWrite -gt (Get-Date).ToUniversalTime().AddHours(-24) - } - $key.Dispose() -} catch { Write-Host "[WARN] Could not read registry key write time: $_" -ForegroundColor Yellow } - -if ($activeProc) { - Write-Host "[HOLD] FieldWorks is running from the other worktree (PID $($activeProc.ProcessId)): $($activeProc.ExecutablePath)" -ForegroundColor Yellow - Write-Host "RESULT=ASK_USER"; return -} -if ($recent) { - Write-Host "[HOLD] The dev registry was used within the last 24h (another worktree may be active)." -ForegroundColor Yellow - Write-Host "RESULT=ASK_USER"; return -} - -Set-DevDirs $thisDist -Write-Host "[OK] Other worktree not active and not used in 24h -- realigned the dev registry to this worktree." -ForegroundColor Green -Write-Host "RESULT=REALIGNED" +& (Join-Path $repoRoot 'Build/Repair-DeveloperRegistry.ps1') -WhatIf +Write-Host 'Dev code and data resolve from the running source tree.' +Write-Host 'RESULT=READ_ONLY' diff --git a/Build/Repair-DeveloperRegistry.Tests.ps1 b/Build/Repair-DeveloperRegistry.Tests.ps1 new file mode 100644 index 0000000000..3a1d2a8fa7 --- /dev/null +++ b/Build/Repair-DeveloperRegistry.Tests.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Verifies developer registry repair and launch preflight with isolated registry operations. +#> +$ErrorActionPreference = 'Stop' +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('FwRegistry_' + [guid]::NewGuid()) +$repairScript = Join-Path $PSScriptRoot 'Repair-DeveloperRegistry.ps1' +$preflightScript = Join-Path $PSScriptRoot ` + '../.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1' +$registryState = @{ Values = @{}; Removed = @(); Checks = 0 } + +function Assert-True([bool]$Condition, [string]$Message) { + $registryState.Checks++ + if (-not $Condition) { throw $Message } +} + +function Test-Path { + param([string]$LiteralPath, [string]$PathType) + if ($LiteralPath.StartsWith('HKCU:')) { + return $registryState.Values.ContainsKey($LiteralPath) + } + Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath -PathType $PathType +} + +function Get-ItemProperty { + param([string]$LiteralPath) + return [pscustomobject]$registryState.Values[$LiteralPath] +} + +function Remove-ItemProperty { + param([string]$LiteralPath, [string]$Name) + $registryState.Removed += "$LiteralPath\$Name" + $registryState.Values[$LiteralPath].Remove($Name) +} + +function Set-ItemProperty { throw 'Launch preflight must not write registry values' } +function Set-DevDirs { throw 'Launch preflight must not realign registry values' } + +try { + $sourceRoot = Join-Path $testRoot 'source tree' + $distFiles = Join-Path $sourceRoot 'DistFiles' + $icuPath = Join-Path $distFiles 'Icu70/icudt70l' + $installPath = Join-Path $testRoot 'installed/DistFiles' + New-Item -ItemType Directory -Path $icuPath, $installPath -Force | Out-Null + Set-Content -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') -Value '' + $companyPath = 'HKCU:\SOFTWARE\SIL' + $fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' + $registryState.Values = @{ + $fieldWorksPath = @{ + RootCodeDir = $distFiles + '\' + RootDataDir = $distFiles + ProjectsDir = Join-Path $distFiles 'Projects' + } + $companyPath = @{ + Icu70DataDir = $icuPath + Icu99DataDir = $installPath + OtherDataDir = $distFiles + } + } + + & $repairScript -WhatIf + Assert-True ($registryState.Removed.Count -eq 0) 'Preview must not remove any values' + Assert-True ($registryState.Values[$fieldWorksPath].Count -eq 3) 'Preview must retain code/data' + & $preflightScript + & $preflightScript -Force + Assert-True ($registryState.Removed.Count -eq 0) 'Preflight must be read-only, including -Force' + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 3) 'Only source code/data/ICU overrides are removed' + Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootCodeDir')) ` + 'Source code override must be removed' + Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootDataDir')) ` + 'Source data override must be removed' + Assert-True (-not $registryState.Values[$companyPath].ContainsKey('Icu70DataDir')) ` + 'Source ICU override must be removed' + Assert-True ($registryState.Values[$fieldWorksPath].ContainsKey('ProjectsDir')) ` + 'Shared projects override must be preserved' + Assert-True ($registryState.Values[$companyPath].Icu99DataDir -eq $installPath) ` + 'Installed ICU override must be preserved' + Assert-True ($registryState.Values[$companyPath].ContainsKey('OtherDataDir')) ` + 'Unrelated values must be preserved' + + $registryState.Values[$fieldWorksPath].RootCodeDir = $installPath + $registryState.Values[$fieldWorksPath].RootDataDir = 'relative/path' + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 3) 'Installed and relative overrides must be preserved' + $registryState.Values[$fieldWorksPath].RootDataDir = 42 + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 3) 'Non-string overrides must be preserved' + $registryState.Values[$fieldWorksPath].RootDataDir = 'C:relative' + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 3) 'Drive-relative overrides must be preserved' + Remove-Item -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') + $registryState.Values[$fieldWorksPath].RootCodeDir = $distFiles + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 3) 'A path without the source marker must be preserved' + $registryState.Values = @{} + & $repairScript -Confirm:$false + & $preflightScript + Assert-True ($registryState.Removed.Count -eq 3) 'Missing registry keys must be harmless' + Write-Host "Developer registry tests: $($registryState.Checks) checks passed." +} +finally { + if ([System.IO.Path]::GetFullPath($testRoot).StartsWith( + [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()), + [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/Build/Repair-DeveloperRegistry.ps1 b/Build/Repair-DeveloperRegistry.ps1 new file mode 100644 index 0000000000..1f4f559b06 --- /dev/null +++ b/Build/Repair-DeveloperRegistry.ps1 @@ -0,0 +1,57 @@ +<# +.SYNOPSIS + Removes per-user code, data, and ICU overrides that point into a FieldWorks source tree. +.DESCRIPTION + Use -WhatIf to preview. ProjectsDir and paths without an ancestor containing + FieldWorks.sln are preserved. Close installed FieldWorks before applying a repair. +#> +[CmdletBinding(SupportsShouldProcess)] +param() + +$ErrorActionPreference = 'Stop' + +function Test-FieldWorksSourcePath { + param($Value) + if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value)) { + return $false + } + try { + if (-not [System.IO.Path]::IsPathRooted($Value) -or + [System.IO.Path]::GetPathRoot($Value).Length -lt 3) { + return $false + } + $directory = [System.IO.Path]::GetFullPath($Value) + while ($directory) { + if (Test-Path -LiteralPath (Join-Path $directory 'FieldWorks.sln') -PathType Leaf) { + return $true + } + $directory = [System.IO.Path]::GetDirectoryName($directory.TrimEnd('\', '/')) + } + } catch { + Write-Warning "Cannot identify source tree for '$Value': $_" + } + return $false +} + +$companyPath = 'HKCU:\SOFTWARE\SIL' +$fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' +$locations = @{ + $fieldWorksPath = @('RootCodeDir', 'RootDataDir') + $companyPath = @() +} +foreach ($registryPath in $locations.Keys) { + if (-not (Test-Path -LiteralPath $registryPath)) { continue } + $properties = Get-ItemProperty -LiteralPath $registryPath + $names = $locations[$registryPath] + if ($registryPath -eq $companyPath) { + $names = @($properties.PSObject.Properties.Name | Where-Object { $_ -match '^Icu\d+DataDir$' }) + } + foreach ($name in $names) { + $value = $properties.$name + if (Test-FieldWorksSourcePath $value) { + if ($PSCmdlet.ShouldProcess("$registryPath\$name = $value", 'Remove developer override')) { + Remove-ItemProperty -LiteralPath $registryPath -Name $name + } + } + } +} diff --git a/Build/mkall.targets b/Build/mkall.targets index 6e29db4632..6c5a159ee3 100644 --- a/Build/mkall.targets +++ b/Build/mkall.targets @@ -270,32 +270,14 @@ - - - - - - /// Gets a subdirectory of FieldWorks either by reading the - /// or by getting . - /// Will not return null. + /// Resolves a registry directory. Per-user source-tree code/data paths fall back + /// to machine settings, then the default directory. /// - /// The name of the registry value to read from the FW root - /// key in HKLM. - /// The default directory to use if there is no value in the - /// registry. - /// - /// The desired subdirectory of FieldWorks (without trailing directory separator). - /// - /// ------------------------------------------------------------------------------------ - private static string GetDirectory(string registryValue, string defaultDir) + internal static string GetDirectory(string registryValue, string defaultDir) { using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) { - if (userKey == null || userKey.GetValue(registryValue) == null) + var userValue = userKey?.GetValue(registryValue); + if (userValue == null || + ((registryValue == "RootCodeDir" || registryValue == ksRootDataDir) + && IsSourceTreePath(userValue as string))) return GetDirectoryLocalMachine(registryValue, defaultDir); return GetDirectory(userKey, registryValue, defaultDir); } } + private static bool IsSourceTreePath(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + try + { + if (!Path.IsPathRooted(path) || + (Platform.IsWindows && Path.GetPathRoot(path).Length < 3)) + return false; + for (var directory = Path.GetFullPath(path); !string.IsNullOrEmpty(directory); + directory = Path.GetDirectoryName(directory.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))) + { + if (File.Exists(Path.Combine(directory, ksSolutionFilename))) + return true; + } + } + catch (ArgumentException) { } + catch (NotSupportedException) { } + catch (PathTooLongException) { } + return false; + } + /// /// Get a directory for a particular key ignoring current user settings. /// diff --git a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs index 33b0210dfb..9997e3e598 100644 --- a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs +++ b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs @@ -220,6 +220,96 @@ private static string CreateFakeSourceTree(bool withSolutionFile) return root; } + [TestCase("RootCodeDir", true, true)] + [TestCase("RootDataDir", true, true)] + [TestCase("RootCodeDir", false, true)] + [TestCase("RootDataDir", false, true)] + [TestCase("RootCodeDir", true, false)] + [TestCase("RootDataDir", true, false)] + public void InstalledDirectory_SourceTreeUserOverrideFallsBack( + string valueName, bool withMachineOverride, bool withDistFiles) + { + var treeRoot = CreateFakeSourceTree(withSolutionFile: true); + try + { + if (!withDistFiles) + Directory.Delete(Path.Combine(treeRoot, "DistFiles")); + var devPath = Path.Combine(treeRoot, "DistFiles") + Path.DirectorySeparatorChar; + AssertRegistryDirectory(valueName, devPath, + withMachineOverride ? "MachineInstall" : null, "DefaultInstall", + withMachineOverride ? "MachineInstall" : "DefaultInstall"); + } + finally + { + Directory.Delete(treeRoot, true); + } + } + + [TestCase("RootCodeDir")] + [TestCase("RootDataDir")] + [TestCase("ProjectsDir")] + public void RegistryDirectory_NonSourceUserOverrideWins(string valueName) + { + var installRoot = CreateFakeSourceTree(withSolutionFile: false); + try + { + var userPath = Path.Combine(installRoot, "DistFiles"); + AssertRegistryDirectory(valueName, userPath, "MachineInstall", "DefaultInstall", userPath); + } + finally + { + Directory.Delete(installRoot, true); + } + } + + [Test] + public void RegistryDirectory_SourceTreeProjectsOverrideWins() + { + var treeRoot = CreateFakeSourceTree(withSolutionFile: true); + try + { + var projects = Path.Combine(treeRoot, "DistFiles", "Projects"); + AssertRegistryDirectory("ProjectsDir", projects, "MachineProjects", "DefaultProjects", projects); + } + finally + { + Directory.Delete(treeRoot, true); + } + } + + private static void AssertRegistryDirectory(string name, string userValue, + string machineValue, string defaultDirectory, string expected) + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + using (var machineKey = FwRegistryHelper.FieldWorksRegistryKeyLocalMachine) + { + var originalUser = userKey.GetValue(name); + var originalMachine = machineKey.GetValue(name); + try + { + userKey.SetValue(name, userValue); + if (machineValue == null) + machineKey.DeleteValue(name, false); + else + machineKey.SetValue(name, machineValue); + Assert.That(FwDirectoryFinder.GetDirectory(name, defaultDirectory), + Is.EqualTo(expected)); + Assert.That(userKey.GetValue(name), Is.EqualTo(userValue)); + } + finally + { + if (originalUser == null) + userKey.DeleteValue(name, false); + else + userKey.SetValue(name, originalUser); + if (originalMachine == null) + machineKey.DeleteValue(name, false); + else + machineKey.SetValue(name, originalMachine); + } + } + } + /// /// Verify that the user project key falls back to the local machine. /// From 0c44992d1e51b5bd355e8261eb7e1dc002d42e4e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Tue, 13 Oct 2026 16:10:39 -0400 Subject: [PATCH 2/9] fix: keep test initialization out of shared paths (LT-22840) --- Src/Common/FwUtils/FwDirectoryFinder.cs | 9 ++-- .../InitializeFwRegistryHelperAttribute.cs | 41 +++++-------------- .../FwUtilsTests/FwDirectoryFinderTests.cs | 33 +++++++++++++++ 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/Src/Common/FwUtils/FwDirectoryFinder.cs b/Src/Common/FwUtils/FwDirectoryFinder.cs index 1ae01b4d49..8be36a5a24 100644 --- a/Src/Common/FwUtils/FwDirectoryFinder.cs +++ b/Src/Common/FwUtils/FwDirectoryFinder.cs @@ -18,6 +18,7 @@ using SIL.LCModel; using SIL.LCModel.Utils; using SIL.PlatformUtilities; +using SIL.Reporting; namespace SIL.FieldWorks.Common.FwUtils { @@ -275,9 +276,11 @@ private static bool IsSourceTreePath(string path) return true; } } - catch (ArgumentException) { } - catch (NotSupportedException) { } - catch (PathTooLongException) { } + catch (Exception error) when (error is ArgumentException || + error is NotSupportedException || error is PathTooLongException) + { + Logger.WriteError("Could not inspect the FieldWorks registry directory", error); + } return false; } diff --git a/Src/Common/FwUtils/FwUtilsTests/Attributes/InitializeFwRegistryHelperAttribute.cs b/Src/Common/FwUtils/FwUtilsTests/Attributes/InitializeFwRegistryHelperAttribute.cs index 1b62b561e9..466364ce09 100644 --- a/Src/Common/FwUtils/FwUtilsTests/Attributes/InitializeFwRegistryHelperAttribute.cs +++ b/Src/Common/FwUtils/FwUtilsTests/Attributes/InitializeFwRegistryHelperAttribute.cs @@ -4,9 +4,9 @@ using System; using System.IO; -using System.Reflection; using NUnit.Framework; using NUnit.Framework.Interfaces; +using SIL.Reporting; namespace SIL.FieldWorks.Common.FwUtils.Attributes { @@ -31,43 +31,22 @@ public override void BeforeTest(ITest test) { base.BeforeTest(test); FwRegistryHelper.Initialize(); - TrySetTestRootDirs(); + TryCreateTestRepository(); } - private static void TrySetTestRootDirs() + private static void TryCreateTestRepository() { try { - // When running tests from Output/, prefer DistFiles in the repo/worktree - // over potentially-stale registry paths from a machine install. - var codeBase = Assembly.GetExecutingAssembly().CodeBase; - var uriBase = new Uri(codeBase); - var exeOrDllDir = Path.GetDirectoryName( - Uri.UnescapeDataString(uriBase.AbsolutePath) - ); - if (string.IsNullOrEmpty(exeOrDllDir)) - return; - - var distFiles = Path.GetFullPath( - Path.Combine(exeOrDllDir, "..", "..", "DistFiles") - ); - if (!Directory.Exists(distFiles)) - return; - - using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) - { - if (userKey != null) - { - userKey.SetValue("RootCodeDir", distFiles); - userKey.SetValue("RootDataDir", distFiles); - } - } - - Directory.CreateDirectory(Path.Combine(distFiles, "SIL", "Repository")); + // Directory lookup already anchors tests to their source tree. Shared registry + // paths must remain available to installed FieldWorks. + var distFiles = FwDirectoryFinder.FindDevDistFiles(FwDirectoryFinder.ExeOrDllDirectory); + if (distFiles != null) + Directory.CreateDirectory(Path.Combine(distFiles, "SIL", "Repository")); } - catch + catch (Exception error) { - // Best-effort: tests should still run if we can't update registry or create folders. + Logger.WriteError("Could not create the test repository directory", error); } } } diff --git a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs index 9997e3e598..11eb64e49b 100644 --- a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs +++ b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs @@ -220,6 +220,39 @@ private static string CreateFakeSourceTree(bool withSolutionFile) return root; } + [TestCase("RootCodeDir", false)] + [TestCase("RootDataDir", false)] + [TestCase("RootCodeDir", true)] + [TestCase("RootDataDir", true)] + [TestCase("ProjectsDir", true)] + public void TestRegistryInitialization_PreservesDirectoryOverrides( + string valueName, bool withUserOverride) + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + { + var originalValue = userKey.GetValue(valueName); + try + { + if (withUserOverride) + userKey.SetValue(valueName, "InstalledDirectory"); + else + userKey.DeleteValue(valueName, false); + + new Attributes.InitializeFwRegistryHelperAttribute().BeforeTest(null); + + Assert.That(userKey.GetValue(valueName), + Is.EqualTo(withUserOverride ? "InstalledDirectory" : null)); + } + finally + { + if (originalValue == null) + userKey.DeleteValue(valueName, false); + else + userKey.SetValue(valueName, originalValue); + } + } + } + [TestCase("RootCodeDir", true, true)] [TestCase("RootDataDir", true, true)] [TestCase("RootCodeDir", false, true)] From d593cc77d92ca65d6ceeff98a52e4a972c9d3b86 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:06:45 -0400 Subject: [PATCH 3/9] fix: stop dev builds and runs writing per-user paths (LT-22840) Delete the setRegistryValues and setKeysInHKCU targets; nothing in the build calls them. They wrote ProjectsDir, SilEncConverters40, EncodingConverterRepository and the silfw:// handler into HKCU, which sent an installed FieldWorks to the dev tree. Dev code and data already resolve from the running tree, so no dev run needs those keys. ProjectsDirectory no longer persists when running from a source tree: the choice lasts for the process. The reader and Repair-DeveloperRegistry.ps1 now also drop a source-tree ProjectsDir. Tests cover both run orders, the no-persist rule and a static check that the build scripts write no registry keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/skills/fieldworks-winapp/SKILL.md | 2 +- .../navigation/launch-or-attach.md | 2 +- Build/Repair-DeveloperRegistry.Tests.ps1 | 16 +- Build/Repair-DeveloperRegistry.ps1 | 9 +- Build/mkall.targets | 43 ----- Src/Common/FwUtils/FwDirectoryFinder.cs | 49 ++++- .../FwUtilsTests/FwDirectoryFinderTests.cs | 171 +++++++++++++++++- 7 files changed, 227 insertions(+), 65 deletions(-) diff --git a/.claude/skills/fieldworks-winapp/SKILL.md b/.claude/skills/fieldworks-winapp/SKILL.md index becc0fc3a2..880295d386 100644 --- a/.claude/skills/fieldworks-winapp/SKILL.md +++ b/.claude/skills/fieldworks-winapp/SKILL.md @@ -121,7 +121,7 @@ verifies that launch will succeed. - `scripts/Resolve-FieldWorksDevRegistry.ps1`: read-only preview of code/data/ICU overrides pointing into a source tree. Dev builds use their own `DistFiles`; installed releases can be redirected by those shared overrides. `Build/Repair-DeveloperRegistry.ps1 -WhatIf` - previews an explicit repair; applying it preserves `ProjectsDir` and non-source paths. + previews an explicit repair; applying it preserves paths outside source trees. - `references/headless-rendering.md`: why FieldWorks needs a display-bound desktop; what works/doesn't for invisible capture (winforms-mcp HEADLESS does NOT render FieldWorks; a Virtual Display Driver was tried and abandoned — see the doc for why; use visible capture, or RDP for true invisibility). Starts with the diff --git a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md index fc0dc06968..cb7ba6c457 100644 --- a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md +++ b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md @@ -27,7 +27,7 @@ returns empty and `winforms_take_screenshot` is blank even though the process is source tree. It is read-only, including with `-Force`. Dev builds use their own `DistFiles`; these shared overrides can redirect an installed release into incompatible worktree files. For an explicit repair, preview `Build/Repair-DeveloperRegistry.ps1 -WhatIf`, then run it - without `-WhatIf` after closing installed FieldWorks. It preserves `ProjectsDir` and + without `-WhatIf` after closing installed FieldWorks. It preserves overrides outside identifiable source trees. It cannot identify a checkout whose solution file has been deleted or moved. diff --git a/Build/Repair-DeveloperRegistry.Tests.ps1 b/Build/Repair-DeveloperRegistry.Tests.ps1 index 3a1d2a8fa7..b02ddff7f2 100644 --- a/Build/Repair-DeveloperRegistry.Tests.ps1 +++ b/Build/Repair-DeveloperRegistry.Tests.ps1 @@ -65,15 +65,15 @@ try { & $preflightScript -Force Assert-True ($registryState.Removed.Count -eq 0) 'Preflight must be read-only, including -Force' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 3) 'Only source code/data/ICU overrides are removed' + Assert-True ($registryState.Removed.Count -eq 4) 'Only source code/data/projects/ICU overrides are removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootCodeDir')) ` 'Source code override must be removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootDataDir')) ` 'Source data override must be removed' Assert-True (-not $registryState.Values[$companyPath].ContainsKey('Icu70DataDir')) ` 'Source ICU override must be removed' - Assert-True ($registryState.Values[$fieldWorksPath].ContainsKey('ProjectsDir')) ` - 'Shared projects override must be preserved' + Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('ProjectsDir')) ` + 'Source projects override must be removed' Assert-True ($registryState.Values[$companyPath].Icu99DataDir -eq $installPath) ` 'Installed ICU override must be preserved' Assert-True ($registryState.Values[$companyPath].ContainsKey('OtherDataDir')) ` @@ -82,21 +82,21 @@ try { $registryState.Values[$fieldWorksPath].RootCodeDir = $installPath $registryState.Values[$fieldWorksPath].RootDataDir = 'relative/path' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 3) 'Installed and relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 4) 'Installed and relative overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 42 & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 3) 'Non-string overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 4) 'Non-string overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 'C:relative' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 3) 'Drive-relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 4) 'Drive-relative overrides must be preserved' Remove-Item -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') $registryState.Values[$fieldWorksPath].RootCodeDir = $distFiles & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 3) 'A path without the source marker must be preserved' + Assert-True ($registryState.Removed.Count -eq 4) 'A path without the source marker must be preserved' $registryState.Values = @{} & $repairScript -Confirm:$false & $preflightScript - Assert-True ($registryState.Removed.Count -eq 3) 'Missing registry keys must be harmless' + Assert-True ($registryState.Removed.Count -eq 4) 'Missing registry keys must be harmless' Write-Host "Developer registry tests: $($registryState.Checks) checks passed." } finally { diff --git a/Build/Repair-DeveloperRegistry.ps1 b/Build/Repair-DeveloperRegistry.ps1 index 1f4f559b06..fa120cbae3 100644 --- a/Build/Repair-DeveloperRegistry.ps1 +++ b/Build/Repair-DeveloperRegistry.ps1 @@ -1,9 +1,10 @@ <# .SYNOPSIS - Removes per-user code, data, and ICU overrides that point into a FieldWorks source tree. + Removes per-user code, data, projects, and ICU overrides that point into a FieldWorks source + tree. .DESCRIPTION - Use -WhatIf to preview. ProjectsDir and paths without an ancestor containing - FieldWorks.sln are preserved. Close installed FieldWorks before applying a repair. + Use -WhatIf to preview. Paths without an ancestor containing FieldWorks.sln + are preserved. Close installed FieldWorks before applying a repair. #> [CmdletBinding(SupportsShouldProcess)] param() @@ -36,7 +37,7 @@ function Test-FieldWorksSourcePath { $companyPath = 'HKCU:\SOFTWARE\SIL' $fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' $locations = @{ - $fieldWorksPath = @('RootCodeDir', 'RootDataDir') + $fieldWorksPath = @('RootCodeDir', 'RootDataDir', 'ProjectsDir') $companyPath = @() } foreach ($registryPath in $locations.Keys) { diff --git a/Build/mkall.targets b/Build/mkall.targets index 6c5a159ee3..a4ec84b23a 100644 --- a/Build/mkall.targets +++ b/Build/mkall.targets @@ -14,7 +14,6 @@ Additional Responsibilities: - Code generation (CellarConstants) - Clean/utility targets (CleanAll, CleanDownloads) - - Registry setup for dev/test environments (setRegistryValues) Imported files: - PackageRestore.targets: RestorePackages, downloadDlls, DefineNugetPackages, CopyDlls @@ -270,46 +269,4 @@ - - - - - - - - - - - - - diff --git a/Src/Common/FwUtils/FwDirectoryFinder.cs b/Src/Common/FwUtils/FwDirectoryFinder.cs index 8be36a5a24..bad6b4d45b 100644 --- a/Src/Common/FwUtils/FwDirectoryFinder.cs +++ b/Src/Common/FwUtils/FwDirectoryFinder.cs @@ -243,8 +243,8 @@ public static string GetCodeFile(string filename) } /// - /// Resolves a registry directory. Per-user source-tree code/data paths fall back - /// to machine settings, then the default directory. + /// Resolves a registry directory. Per-user source-tree code, data and projects paths + /// fall back to machine settings, then the default directory. /// internal static string GetDirectory(string registryValue, string defaultDir) { @@ -252,14 +252,19 @@ internal static string GetDirectory(string registryValue, string defaultDir) { var userValue = userKey?.GetValue(registryValue); if (userValue == null || - ((registryValue == "RootCodeDir" || registryValue == ksRootDataDir) + ((registryValue == "RootCodeDir" || registryValue == ksRootDataDir + || registryValue == ksProjectsDir) && IsSourceTreePath(userValue as string))) return GetDirectoryLocalMachine(registryValue, defaultDir); return GetDirectory(userKey, registryValue, defaultDir); } } - private static bool IsSourceTreePath(string path) + /// + /// True if lies inside a source tree, that is under a directory + /// holding FieldWorks.sln. Installed FieldWorks must not follow such a path. + /// + public static bool IsSourceTreePath(string path) { if (string.IsNullOrEmpty(path)) return false; @@ -391,6 +396,27 @@ public static string FindDevDistFiles(string startDirectory) return null; } + /// Test seam: forces when not + /// null. + internal static bool? RunningFromSourceTreeOverride { get; set; } + + private static string s_devProjectsDirectory; + + /// Forgets the projects folder a source-tree run chose in memory. + internal static void ResetDevProjectsDirectory() + { + s_devProjectsDirectory = null; + } + + /// True when this assembly runs from a source tree, not an install. + internal static bool RunningFromSourceTree + { + get + { + return RunningFromSourceTreeOverride ?? GetDevDistFilesPath() != null; + } + } + private static string GetDevDistFilesPath() { return FindDevDistFiles(AssemblyDirectoryFromCodeBase( @@ -574,9 +600,22 @@ public static string TemplateDirectory /// ------------------------------------------------------------------------------------ public static string ProjectsDirectory { - get { return GetDirectory(ksProjectsDir, Path.Combine(DataDirectory, ksProjects)); } + get + { + if (s_devProjectsDirectory != null) + return s_devProjectsDirectory; + return GetDirectory(ksProjectsDir, Path.Combine(DataDirectory, ksProjects)); + } set { + if (RunningFromSourceTree) + { + // A source-tree build must not leave a per-user ProjectsDir that an + // installed FieldWorks would then follow (LT-22840); it keeps the + // choice for this process only. + s_devProjectsDirectory = value; + return; + } if (ProjectsDirectory == value) return; // no change. diff --git a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs index 11eb64e49b..c34c4fd169 100644 --- a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs +++ b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NUnit.Framework; @@ -25,9 +26,22 @@ public class FwDirectoryFinderTests [OneTimeTearDown] public void TearDown() { + FwDirectoryFinder.RunningFromSourceTreeOverride = null; + FwDirectoryFinder.ResetDevProjectsDirectory(); FwRegistryHelper.Manager.Reset(); } + /// + /// These tests run inside a source tree, so each starts as an installed run unless it + /// says otherwise. + /// + [SetUp] + public void SetUp() + { + FwDirectoryFinder.RunningFromSourceTreeOverride = false; + FwDirectoryFinder.ResetDevProjectsDirectory(); + } + /// /// Fixture setup /// @@ -295,21 +309,172 @@ public void RegistryDirectory_NonSourceUserOverrideWins(string valueName) } } - [Test] - public void RegistryDirectory_SourceTreeProjectsOverrideWins() + [TestCase(true)] + [TestCase(false)] + public void RegistryDirectory_SourceTreeProjectsOverrideFallsBack(bool withMachineOverride) { var treeRoot = CreateFakeSourceTree(withSolutionFile: true); try { var projects = Path.Combine(treeRoot, "DistFiles", "Projects"); - AssertRegistryDirectory("ProjectsDir", projects, "MachineProjects", "DefaultProjects", projects); + AssertRegistryDirectory("ProjectsDir", projects, + withMachineOverride ? "MachineProjects" : null, "DefaultProjects", + withMachineOverride ? "MachineProjects" : "DefaultProjects"); + } + finally + { + Directory.Delete(treeRoot, true); + } + } + + private static Dictionary SnapshotUserValues() + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + return userKey.GetValueNames().ToDictionary(n => n, n => userKey.GetValue(n)); + } + + /// + /// A source-tree run keeps its projects folder for the process only, so an installed + /// FieldWorks never inherits it (LT-22840). + /// + [Test] + public void ProjectsDirectory_SetFromSourceTreeRun_DoesNotPersist() + { + var before = SnapshotUserValues(); + FwDirectoryFinder.RunningFromSourceTreeOverride = true; + + FwDirectoryFinder.ProjectsDirectory = Path.Combine(Path.GetTempPath(), "DevProjects"); + + Assert.That(FwDirectoryFinder.ProjectsDirectory, + Is.EqualTo(Path.Combine(Path.GetTempPath(), "DevProjects")), + "the choice still applies to the running process"); + Assert.That(SnapshotUserValues(), Is.EquivalentTo(before), + "nothing may be written to the per-user key"); + FwDirectoryFinder.RunningFromSourceTreeOverride = false; + FwDirectoryFinder.ResetDevProjectsDirectory(); + Assert.That(FwDirectoryFinder.ProjectsDirectory, + Is.Not.EqualTo(Path.Combine(Path.GetTempPath(), "DevProjects"))); + } + + /// + /// Direction one: a dev run happens last, then an installed run. The dev run leaves the + /// per-user key as it found it, and a stale source-tree leftover from an older dev build + /// is not followed by the installed run. + /// + [Test] + public void Walk_DevRunThenInstalledRun_InstalledIgnoresDevPaths() + { + var treeRoot = CreateFakeSourceTree(withSolutionFile: true); + var names = new[] { "RootCodeDir", "RootDataDir", "ProjectsDir" }; + try + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + using (var machineKey = FwRegistryHelper.FieldWorksRegistryKeyLocalMachine) + { + foreach (var name in names) + { + userKey.DeleteValue(name, false); + machineKey.SetValue(name, "Installed" + name); + } + } + var before = SnapshotUserValues(); + + FwDirectoryFinder.RunningFromSourceTreeOverride = true; + FwDirectoryFinder.ProjectsDirectory = Path.Combine(treeRoot, "DistFiles", "Projects"); + var devDir = FwDirectoryFinder.CodeDirectory; + Assert.That(devDir, Is.SamePath(FwDirectoryFinder.FindDevDistFiles(UtilsAssemblyDir))); + Assert.That(SnapshotUserValues(), Is.EquivalentTo(before), "dev run wrote HKCU"); + + // An older dev build left tree paths behind: the installed run still resolves + // its own directories. + FwDirectoryFinder.RunningFromSourceTreeOverride = false; + FwDirectoryFinder.ResetDevProjectsDirectory(); + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + { + foreach (var name in names) + userKey.SetValue(name, Path.Combine(treeRoot, "DistFiles")); + } + foreach (var name in names) + Assert.That(FwDirectoryFinder.GetDirectory(name, "Default"), + Is.EqualTo("Installed" + name), name); } finally { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + using (var machineKey = FwRegistryHelper.FieldWorksRegistryKeyLocalMachine) + { + foreach (var name in names) + { + userKey.DeleteValue(name, false); + machineKey.DeleteValue(name, false); + } + } Directory.Delete(treeRoot, true); } } + /// + /// Direction two: an installed run happens last, then a dev run. The per-user key holds + /// the installed settings; the dev run resolves code and data from its own tree and + /// leaves those settings untouched. + /// + [Test] + public void Walk_InstalledRunThenDevRun_DevUsesItsTreeAndLeavesHkcuAlone() + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + { + userKey.SetValue("RootCodeDir", Path.Combine(Path.GetTempPath(), "InstalledCode")); + userKey.SetValue("RootDataDir", Path.Combine(Path.GetTempPath(), "InstalledData")); + userKey.SetValue("ProjectsDir", Path.Combine(Path.GetTempPath(), "InstalledProjects")); + } + var before = SnapshotUserValues(); + try + { + FwDirectoryFinder.RunningFromSourceTreeOverride = true; + var distFiles = FwDirectoryFinder.FindDevDistFiles(UtilsAssemblyDir); + Assert.That(FwDirectoryFinder.CodeDirectory, Is.SamePath(distFiles)); + Assert.That(FwDirectoryFinder.DataDirectory, Is.SamePath(distFiles)); + FwDirectoryFinder.ProjectsDirectory = Path.Combine(distFiles, "Projects"); + Assert.That(SnapshotUserValues(), Is.EquivalentTo(before), "dev run wrote HKCU"); + FwDirectoryFinder.RunningFromSourceTreeOverride = false; + FwDirectoryFinder.ResetDevProjectsDirectory(); + Assert.That(FwDirectoryFinder.ProjectsDirectory, + Is.EqualTo(Path.Combine(Path.GetTempPath(), "InstalledProjects"))); + } + finally + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + { + foreach (var name in before.Keys) + userKey.DeleteValue(name, false); + } + } + } + + /// + /// The build scripts must not write per-user registry values: those redirect an + /// installed FieldWorks to a source tree. + /// + [Test] + public void BuildScripts_WriteNoRegistryKeys() + { + var distFiles = FwDirectoryFinder.FindDevDistFiles(UtilsAssemblyDir); + Assert.That(distFiles, Is.Not.Null); + var buildDir = Path.Combine(Path.GetDirectoryName(distFiles), "Build"); + var files = Directory.GetFiles(buildDir, "*.targets").Concat( + Directory.GetFiles(buildDir, "*.proj")).ToList(); + Assert.That(files, Is.Not.Empty); + foreach (var file in files) + { + var text = File.ReadAllText(file); + Assert.That(text, Does.Not.Contain(" Date: Mon, 19 Oct 2026 10:06:45 -0400 Subject: [PATCH 4/9] fix: let the running tree choose the ICU data dir (LT-22840) InitializeIcu now prefers the running source tree's ICU data, and ignores a registry Icu*DataDir that is missing or inside a source tree. A stale or foreign key can no longer redirect a dev build, or an installed one. Co-Authored-By: Claude Opus 5.5 (1M context) --- Src/Common/FwUtils/FwUtils.cs | 45 +++++----- .../FwUtilsTests/IcuDataDirChoiceTests.cs | 83 +++++++++++++++++++ 2 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 Src/Common/FwUtils/FwUtilsTests/IcuDataDirChoiceTests.cs diff --git a/Src/Common/FwUtils/FwUtils.cs b/Src/Common/FwUtils/FwUtils.cs index 454d44058c..4d7c8ef332 100644 --- a/Src/Common/FwUtils/FwUtils.cs +++ b/Src/Common/FwUtils/FwUtils.cs @@ -176,28 +176,15 @@ public static void InitializeIcu() using (var userKey = RegistryHelper.CompanyKey) using (var machineKey = RegistryHelper.CompanyKeyLocalMachine) { - string dir = null; - if (userKey != null && userKey.GetValue(icuDirValueName) != null) - { - dir = userKey.GetValue(icuDirValueName, dir) as string; - } - else if (machineKey != null && machineKey.GetValue(icuDirValueName) != null) - { - - dir = machineKey.GetValue(icuDirValueName, dir) as string; - } + // The running source tree wins over any registry value, so that a stale or + // foreign Icu key cannot redirect a dev build (LT-22840). + var dir = ChooseIcuDataDir(TryGetDevIcuDataDir(), + userKey?.GetValue(icuDirValueName) as string, + machineKey?.GetValue(icuDirValueName) as string); if (!string.IsNullOrEmpty(dir)) Environment.SetEnvironmentVariable("ICU_DATA", dir); } } - // If ICU_DATA still isn't set (common on clean dev/test machines), fall back to - // the repo/worktree DistFiles ICU payload so tests can run without a machine install. - if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ICU_DATA"))) - { - var devIcuDir = TryGetDevIcuDataDir(); - if (!string.IsNullOrEmpty(devIcuDir)) - Environment.SetEnvironmentVariable("ICU_DATA", devIcuDir); - } // ICU_DATA should point to the directory that contains nfc_fw.nrm and nfkc_fw.nrm // (i.e. icudt54l). // Must call Wrapper.Init() before CustomIcu.InitIcuDataDir() to avoid most @@ -212,6 +199,25 @@ public static void InitializeIcu() CustomIcu.InitIcuDataDir(); } + /// + /// Picks the ICU data directory: the running tree's, else the per-user registry value, + /// else the machine one. A registry value that does not exist or lies inside a source + /// tree is ignored, because an installed FieldWorks must not follow a dev build's key. + /// + internal static string ChooseIcuDataDir(string devDir, string userValue, + string machineValue) + { + if (!string.IsNullOrEmpty(devDir)) + return devDir; + foreach (var value in new[] { userValue, machineValue }) + { + if (!string.IsNullOrEmpty(value) && Directory.Exists(value) + && !FwDirectoryFinder.IsSourceTreePath(value)) + return value; + } + return null; + } + private static string TryGetDevIcuDataDir() { try @@ -224,7 +230,8 @@ private static string TryGetDevIcuDataDir() // Not FwDirectoryFinder.FindDevDistFiles: on the InitIcuDataDir bootstrap path // that dependency is an initialisation-order question. See LT-22768. var distFiles = Path.GetFullPath(Path.Combine(assemblyDir, "..", "..", "DistFiles")); - if (!Directory.Exists(distFiles)) + if (!Directory.Exists(distFiles) || + !File.Exists(Path.Combine(distFiles, "..", "FieldWorks.sln"))) return null; var candidate = Path.Combine(distFiles, $"Icu{ver}", $"icudt{ver}l"); diff --git a/Src/Common/FwUtils/FwUtilsTests/IcuDataDirChoiceTests.cs b/Src/Common/FwUtils/FwUtilsTests/IcuDataDirChoiceTests.cs new file mode 100644 index 0000000000..62e9f91f1e --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/IcuDataDirChoiceTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 SIL Global +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.IO; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// Tests which ICU data directory FwUtils.InitializeIcu chooses (LT-22840). + /// + [TestFixture] + public class IcuDataDirChoiceTests + { + private string m_root; + private string m_sourceTreeIcu; + private string m_installedIcu; + private string m_otherInstalledIcu; + + [SetUp] + public void SetUp() + { + m_root = Path.Combine(Path.GetTempPath(), "IcuDataDirChoiceTests", + Guid.NewGuid().ToString("N")); + var tree = Directory.CreateDirectory(Path.Combine(m_root, "tree")).FullName; + File.WriteAllText(Path.Combine(tree, "FieldWorks.sln"), string.Empty); + m_sourceTreeIcu = Directory.CreateDirectory( + Path.Combine(tree, "DistFiles", "Icu70", "icudt70l")).FullName; + m_installedIcu = Directory.CreateDirectory( + Path.Combine(m_root, "installed", "Icu70", "icudt70l")).FullName; + m_otherInstalledIcu = Directory.CreateDirectory( + Path.Combine(m_root, "machine", "Icu70", "icudt70l")).FullName; + } + + [TearDown] + public void TearDown() + { + Directory.Delete(m_root, true); + } + + [Test] + public void RunningTreeWinsOverAnyRegistryValue() + { + Assert.That(FwUtils.ChooseIcuDataDir(m_sourceTreeIcu, m_installedIcu, m_otherInstalledIcu), + Is.EqualTo(m_sourceTreeIcu)); + } + + [Test] + public void SourceTreeUserKey_IsIgnored_MachineValueUsed() + { + Assert.That(FwUtils.ChooseIcuDataDir(null, m_sourceTreeIcu, m_otherInstalledIcu), + Is.EqualTo(m_otherInstalledIcu)); + } + + [Test] + public void SourceTreeUserKey_WithNoMachineValue_GivesNull() + { + Assert.That(FwUtils.ChooseIcuDataDir(null, m_sourceTreeIcu, null), Is.Null); + } + + [Test] + public void MissingUserKeyDirectory_IsIgnored() + { + Assert.That(FwUtils.ChooseIcuDataDir(null, Path.Combine(m_root, "gone"), m_otherInstalledIcu), + Is.EqualTo(m_otherInstalledIcu)); + } + + [Test] + public void InstalledUserKey_BeatsMachineValue() + { + Assert.That(FwUtils.ChooseIcuDataDir(null, m_installedIcu, m_otherInstalledIcu), + Is.EqualTo(m_installedIcu)); + } + + [Test] + public void NoValues_GivesNull() + { + Assert.That(FwUtils.ChooseIcuDataDir(null, null, null), Is.Null); + } + } +} From 1bf85af2dcbb3d4ac3a9ce60de525dbbbf63224e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:06:45 -0400 Subject: [PATCH 5/9] fix: skip source-tree HKCU paths in Paratext plugin and Convert ParatextLexiconPluginDirectoryFinder falls back to the machine value when the per-user code, data or projects path is inside a source tree. Convert.cs does the same for RootCodeDir, with a local check so it stays independent of the FieldWorks dlls. Co-Authored-By: Claude Opus 5.5 (1M context) --- Lib/src/Converter/Convertlib/Convert.cs | 26 +++++- ...ratextLexiconPluginDirectoryFinderTests.cs | 81 +++++++++++++++++++ .../ParatextLexiconPluginDirectoryFinder.cs | 27 +++++-- 3 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs diff --git a/Lib/src/Converter/Convertlib/Convert.cs b/Lib/src/Converter/Convertlib/Convert.cs index 5027731c65..e7155a7918 100644 --- a/Lib/src/Converter/Convertlib/Convert.cs +++ b/Lib/src/Converter/Convertlib/Convert.cs @@ -524,10 +524,34 @@ private static string GetCodeDirFromRegistryKey(RegistryKey key) // Avoiding DirectoryFinder so this isn't dependent on lots of FW dlls. using (var regKey = key.OpenSubKey(@"Software\SIL\FieldWorks\8")) { - return (regKey == null) ? null : regKey.GetValue("RootCodeDir") as string; + var value = (regKey == null) ? null : regKey.GetValue("RootCodeDir") as string; + return IsInSourceTree(value) ? null : value; } } + /// + /// A RootCodeDir inside a source tree (under a FieldWorks.sln) is a dev build's + /// leftover, so it is not used. Inlined to avoid depending on FW dlls. + /// + private static bool IsInSourceTree(string path) + { + if (string.IsNullOrEmpty(path) || !Path.IsPathRooted(path)) + return false; + try + { + for (var dir = Path.GetFullPath(path); !string.IsNullOrEmpty(dir); + dir = Path.GetDirectoryName(dir)) + { + if (File.Exists(Path.Combine(dir, "FieldWorks.sln"))) + return true; + } + } + catch (ArgumentException) + { + } + return false; + } + private string GetModelName() //Locate the Model File { string rootDir = RootCodeDir; diff --git a/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs new file mode 100644 index 0000000000..d8f918d2cf --- /dev/null +++ b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) 2026 SIL Global +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.IO; +using Microsoft.Win32; +using NUnit.Framework; + +namespace SIL.FieldWorks.ParatextLexiconPlugin +{ + /// + /// Tests that the plugin does not follow per-user paths a source-tree build left behind + /// (LT-22840). Uses throw-away keys under a test hive, never the FieldWorks keys. + /// + [TestFixture] + public class ParatextLexiconPluginDirectoryFinderTests + { + private string m_root; + private string m_hive; + private RegistryKey m_userKey; + private RegistryKey m_machineKey; + + [SetUp] + public void SetUp() + { + m_root = Path.Combine(Path.GetTempPath(), "ParatextFinderTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(m_root, "tree", "DistFiles")); + File.WriteAllText(Path.Combine(m_root, "tree", "FieldWorks.sln"), string.Empty); + Directory.CreateDirectory(Path.Combine(m_root, "installed")); + m_hive = @"Software\SIL\FwParatextFinderTests\" + Guid.NewGuid().ToString("N"); + m_userKey = Registry.CurrentUser.CreateSubKey(m_hive + @"\user"); + m_machineKey = Registry.CurrentUser.CreateSubKey(m_hive + @"\machine"); + m_machineKey.SetValue("RootCodeDir", "MachineCode"); + m_machineKey.SetValue("RootDataDir", "MachineData"); + m_machineKey.SetValue("ProjectsDir", "MachineProjects"); + } + + [TearDown] + public void TearDown() + { + m_userKey.Dispose(); + m_machineKey.Dispose(); + Registry.CurrentUser.DeleteSubKeyTree(m_hive, false); + Directory.Delete(m_root, true); + } + + [TestCase("RootCodeDir")] + [TestCase("RootDataDir")] + [TestCase("ProjectsDir")] + public void SourceTreeUserValue_FallsBackToMachineValue(string name) + { + m_userKey.SetValue(name, Path.Combine(m_root, "tree", "DistFiles")); + Assert.That(ParatextLexiconPluginDirectoryFinder.GetDirectory( + m_userKey, m_machineKey, name, "Default"), Is.EqualTo(ExpectedMachineValue(name))); + } + + private static string ExpectedMachineValue(string name) + { + return name == "RootCodeDir" ? "MachineCode" : name == "RootDataDir" ? "MachineData" : "MachineProjects"; + } + + [TestCase("RootCodeDir")] + [TestCase("RootDataDir")] + [TestCase("ProjectsDir")] + public void InstalledUserValue_Wins(string name) + { + var installed = Path.Combine(m_root, "installed"); + m_userKey.SetValue(name, installed); + Assert.That(ParatextLexiconPluginDirectoryFinder.GetDirectory( + m_userKey, m_machineKey, name, "Default"), Is.EqualTo(installed)); + } + + [Test] + public void NoUserValue_UsesMachineValue() + { + Assert.That(ParatextLexiconPluginDirectoryFinder.GetDirectory( + m_userKey, m_machineKey, "RootCodeDir", "Default"), Is.EqualTo("MachineCode")); + } + } +} diff --git a/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs b/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs index b7e0ccf792..e68343985b 100644 --- a/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs +++ b/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs @@ -6,6 +6,7 @@ using System.IO; using System.Reflection; using Microsoft.Win32; +using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Utils; using SIL.PlatformUtilities; @@ -64,14 +65,28 @@ private static string GetDirectory(string registryValue, string defaultDir) using (RegistryKey userKey = ParatextLexiconPluginRegistryHelper.FieldWorksRegistryKey) using (RegistryKey machineKey = ParatextLexiconPluginRegistryHelper.FieldWorksRegistryKeyLocalMachine) { - var registryKey = userKey; - if (userKey?.GetValue(registryValue) == null) - { - registryKey = machineKey; - } + return GetDirectory(userKey, machineKey, registryValue, defaultDir); + } + } - return GetDirectory(registryKey, registryValue, defaultDir); + /// + /// Resolves a directory from the per-user key, else the machine key, else the default. + /// A per-user path inside a source tree is a dev build's leftover, so it is skipped. + /// + internal static string GetDirectory(RegistryKey userKey, RegistryKey machineKey, + string registryValue, string defaultDir) + { + var registryKey = userKey; + var userValue = userKey?.GetValue(registryValue); + if (userValue == null || + ((registryValue == RootCodeDir || registryValue == RootDataDir || + registryValue == ProjectsDir) && + FwDirectoryFinder.IsSourceTreePath(userValue as string))) + { + registryKey = machineKey; } + + return GetDirectory(registryKey, registryValue, defaultDir); } private static string GetDirectory(RegistryKey registryKey, string registryValue, string defaultDir) From 5fdd373f105b59b8530128fbec9604374d417393 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:11:10 -0400 Subject: [PATCH 6/9] fix: repair script also clears silfw and encoding converter keys Repair-DeveloperRegistry.ps1 now removes SilEncConverters40 values, the EncodingConverterRepository value and the silfw:// handler when they point into a source tree. The handler goes as a whole key and only through ShouldProcess. Installed values are kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- Build/Repair-DeveloperRegistry.Tests.ps1 | 55 +++++++++++++++++++++--- Build/Repair-DeveloperRegistry.ps1 | 18 ++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Build/Repair-DeveloperRegistry.Tests.ps1 b/Build/Repair-DeveloperRegistry.Tests.ps1 index b02ddff7f2..8e711d2144 100644 --- a/Build/Repair-DeveloperRegistry.Tests.ps1 +++ b/Build/Repair-DeveloperRegistry.Tests.ps1 @@ -33,6 +33,18 @@ function Remove-ItemProperty { $registryState.Values[$LiteralPath].Remove($Name) } +function Remove-Item { + param([string]$LiteralPath, [switch]$Recurse, [switch]$Force) + if ($LiteralPath.StartsWith('HKCU:')) { + $registryState.Removed += $LiteralPath + foreach ($key in @($registryState.Values.Keys)) { + if ($key -eq $LiteralPath -or $key.StartsWith($LiteralPath + '\')) { $registryState.Values.Remove($key) } + } + return + } + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $LiteralPath -Recurse:$Recurse -Force:$Force +} + function Set-ItemProperty { throw 'Launch preflight must not write registry values' } function Set-DevDirs { throw 'Launch preflight must not realign registry values' } @@ -45,7 +57,17 @@ try { Set-Content -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') -Value '' $companyPath = 'HKCU:\SOFTWARE\SIL' $fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' + $encPath = Join-Path $companyPath 'SilEncConverters40' + $repoPath = Join-Path $companyPath 'EncodingConverterRepository' + $silfwPath = 'HKCU:\SOFTWARE\Classes\silfw' + $silfwCommandPath = Join-Path $silfwPath 'shell\open\command' $registryState.Values = @{ + $encPath = @{ + RootDir = Join-Path $sourceRoot 'Output\Release' + DeveloperPluginDir = Join-Path $sourceRoot 'Output\Release\EC\Plugins' + } + $repoPath = @{ Registry = Join-Path $distFiles 'SIL\Repository\mappingRegistry.xml' } + $silfwCommandPath = @{ '(default)' = '"' + (Join-Path $sourceRoot 'Output\Release\FieldWorks.exe') + '" %1' } $fieldWorksPath = @{ RootCodeDir = $distFiles + '\' RootDataDir = $distFiles @@ -65,7 +87,7 @@ try { & $preflightScript -Force Assert-True ($registryState.Removed.Count -eq 0) 'Preflight must be read-only, including -Force' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 4) 'Only source code/data/projects/ICU overrides are removed' + Assert-True ($registryState.Removed.Count -eq 8) 'Only source code/data/projects/ICU overrides are removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootCodeDir')) ` 'Source code override must be removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootDataDir')) ` @@ -79,24 +101,45 @@ try { Assert-True ($registryState.Values[$companyPath].ContainsKey('OtherDataDir')) ` 'Unrelated values must be preserved' + Assert-True (-not $registryState.Values.ContainsKey($silfwCommandPath)) ` + 'Source silfw handler must be removed' + Assert-True (-not $registryState.Values[$encPath].ContainsKey('RootDir')) ` + 'Source encoding converter root must be removed' + Assert-True (-not $registryState.Values[$repoPath].ContainsKey('Registry')) ` + 'Source encoding repository must be removed' + + # Installed (non-source) values are kept. + $registryState.Values[$encPath] = @{ RootDir = $installPath } + $registryState.Values[$repoPath] = @{ Registry = Join-Path $installPath 'mappingRegistry.xml' } + $registryState.Values[$silfwCommandPath] = @{ '(default)' = '"' + (Join-Path $installPath 'FieldWorks.exe') + '" %1' } + & $repairScript -Confirm:$false + Assert-True ($registryState.Removed.Count -eq 8) 'Installed silfw/encoding values must be kept' + Assert-True ($registryState.Values.ContainsKey($silfwCommandPath)) 'Installed silfw handler kept' + & $repairScript -WhatIf | Out-Null + Assert-True ($registryState.Removed.Count -eq 8) 'Preview must not remove silfw' + $registryState.Values[$silfwCommandPath] = @{ '(default)' = '"' + (Join-Path $sourceRoot 'Output\Release\FieldWorks.exe') + '" %1' } + & $repairScript -WhatIf | Out-Null + Assert-True ($registryState.Values.ContainsKey($silfwCommandPath)) 'Preview must keep a source silfw handler' + $registryState.Values.Remove($silfwCommandPath) + $registryState.Values[$fieldWorksPath].RootCodeDir = $installPath $registryState.Values[$fieldWorksPath].RootDataDir = 'relative/path' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 4) 'Installed and relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 8) 'Installed and relative overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 42 & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 4) 'Non-string overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 8) 'Non-string overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 'C:relative' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 4) 'Drive-relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 8) 'Drive-relative overrides must be preserved' Remove-Item -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') $registryState.Values[$fieldWorksPath].RootCodeDir = $distFiles & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 4) 'A path without the source marker must be preserved' + Assert-True ($registryState.Removed.Count -eq 8) 'A path without the source marker must be preserved' $registryState.Values = @{} & $repairScript -Confirm:$false & $preflightScript - Assert-True ($registryState.Removed.Count -eq 4) 'Missing registry keys must be harmless' + Assert-True ($registryState.Removed.Count -eq 8) 'Missing registry keys must be harmless' Write-Host "Developer registry tests: $($registryState.Checks) checks passed." } finally { diff --git a/Build/Repair-DeveloperRegistry.ps1 b/Build/Repair-DeveloperRegistry.ps1 index fa120cbae3..10cc38b089 100644 --- a/Build/Repair-DeveloperRegistry.ps1 +++ b/Build/Repair-DeveloperRegistry.ps1 @@ -39,6 +39,8 @@ $fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' $locations = @{ $fieldWorksPath = @('RootCodeDir', 'RootDataDir', 'ProjectsDir') $companyPath = @() + (Join-Path $companyPath 'SilEncConverters40') = @('RootDir', 'DeveloperPluginDir') + (Join-Path $companyPath 'EncodingConverterRepository') = @('Registry') } foreach ($registryPath in $locations.Keys) { if (-not (Test-Path -LiteralPath $registryPath)) { continue } @@ -56,3 +58,19 @@ foreach ($registryPath in $locations.Keys) { } } } + +# The silfw:// handler a dev build registered launches that build's exe, so it goes as a whole +# key. +$silfwPath = 'HKCU:\SOFTWARE\Classes\silfw' +$silfwCommandPath = Join-Path $silfwPath 'shell\open\command' +if (Test-Path -LiteralPath $silfwCommandPath) { + $command = (Get-ItemProperty -LiteralPath $silfwCommandPath).'(default)' + if ($command -is [string] -and $command -match '^\s*"?([^"]+?\.exe)"?(\s|$)') { + $exePath = $Matches[1] + if (Test-FieldWorksSourcePath $exePath) { + if ($PSCmdlet.ShouldProcess("$silfwPath (command = $command)", 'Remove developer silfw:// handler')) { + Remove-Item -LiteralPath $silfwPath -Recurse -Force + } + } + } +} From 60f76662d4a1bb065805ab2167159219971f2c8b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:11:11 -0400 Subject: [PATCH 7/9] docs: note the in-memory ProjectsDirectory and widen a path catch ProjectsDirectory documents that a source-tree run keeps the value in memory and that null does not delete a real HKCU value. Convert.cs catches the path and security exceptions its source-tree check can raise. Co-Authored-By: Claude Opus 5.5 (1M context) --- Lib/src/Converter/Convertlib/Convert.cs | 4 +++- Src/Common/FwUtils/FwDirectoryFinder.cs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Lib/src/Converter/Convertlib/Convert.cs b/Lib/src/Converter/Convertlib/Convert.cs index e7155a7918..1bbdc14b19 100644 --- a/Lib/src/Converter/Convertlib/Convert.cs +++ b/Lib/src/Converter/Convertlib/Convert.cs @@ -546,7 +546,9 @@ private static bool IsInSourceTree(string path) return true; } } - catch (ArgumentException) + catch (Exception e) when (e is ArgumentException || e is NotSupportedException || + e is PathTooLongException || e is System.Security.SecurityException || + e is UnauthorizedAccessException || e is IOException) { } return false; diff --git a/Src/Common/FwUtils/FwDirectoryFinder.cs b/Src/Common/FwUtils/FwDirectoryFinder.cs index bad6b4d45b..fac1de03ff 100644 --- a/Src/Common/FwUtils/FwDirectoryFinder.cs +++ b/Src/Common/FwUtils/FwDirectoryFinder.cs @@ -595,6 +595,10 @@ public static string TemplateDirectory /// Gets or sets the dir where projects are stored. Setting to null will delete the HKCU /// key, so that the HKLM key (system default) will be used for this user. /// + /// + /// A source-tree run keeps the value in memory only: it never writes HKCU, and setting + /// null does not delete a real HKCU ProjectsDir (LT-22840). + /// /// If user does not have permission to write to HKLM /// /// ------------------------------------------------------------------------------------ From 07c34d08bf148eea205f7b0dc91f3d1fe5902edb Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:49:52 -0400 Subject: [PATCH 8/9] fix: share ProjectsDir between dev and installed runs (LT-22840) Developers want the same projects in both builds, so ProjectsDir is no longer filtered by the source-tree rule in FwDirectoryFinder or the Paratext finder, and Repair-DeveloperRegistry.ps1 keeps it. A dev run still never writes it: the setter keeps the choice in memory. Root*, Icu* and the converter keys stay filtered. Tests now expect a source-tree ProjectsDir to be honoured by dev and installed runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- Build/Repair-DeveloperRegistry.Tests.ps1 | 20 ++++++------- Build/Repair-DeveloperRegistry.ps1 | 7 +++-- Src/Common/FwUtils/FwDirectoryFinder.cs | 10 +++---- .../FwUtilsTests/FwDirectoryFinderTests.cs | 29 +++++++++++++------ ...ratextLexiconPluginDirectoryFinderTests.cs | 13 +++++++-- .../ParatextLexiconPluginDirectoryFinder.cs | 6 ++-- 6 files changed, 52 insertions(+), 33 deletions(-) diff --git a/Build/Repair-DeveloperRegistry.Tests.ps1 b/Build/Repair-DeveloperRegistry.Tests.ps1 index 8e711d2144..65ee5f7f5c 100644 --- a/Build/Repair-DeveloperRegistry.Tests.ps1 +++ b/Build/Repair-DeveloperRegistry.Tests.ps1 @@ -87,15 +87,15 @@ try { & $preflightScript -Force Assert-True ($registryState.Removed.Count -eq 0) 'Preflight must be read-only, including -Force' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'Only source code/data/projects/ICU overrides are removed' + Assert-True ($registryState.Removed.Count -eq 7) 'Only source code/data/ICU/converter/silfw overrides are removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootCodeDir')) ` 'Source code override must be removed' Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('RootDataDir')) ` 'Source data override must be removed' Assert-True (-not $registryState.Values[$companyPath].ContainsKey('Icu70DataDir')) ` 'Source ICU override must be removed' - Assert-True (-not $registryState.Values[$fieldWorksPath].ContainsKey('ProjectsDir')) ` - 'Source projects override must be removed' + Assert-True ($registryState.Values[$fieldWorksPath].ContainsKey('ProjectsDir')) ` + 'Shared projects override must be kept, even inside a source tree' Assert-True ($registryState.Values[$companyPath].Icu99DataDir -eq $installPath) ` 'Installed ICU override must be preserved' Assert-True ($registryState.Values[$companyPath].ContainsKey('OtherDataDir')) ` @@ -113,10 +113,10 @@ try { $registryState.Values[$repoPath] = @{ Registry = Join-Path $installPath 'mappingRegistry.xml' } $registryState.Values[$silfwCommandPath] = @{ '(default)' = '"' + (Join-Path $installPath 'FieldWorks.exe') + '" %1' } & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'Installed silfw/encoding values must be kept' + Assert-True ($registryState.Removed.Count -eq 7) 'Installed silfw/encoding values must be kept' Assert-True ($registryState.Values.ContainsKey($silfwCommandPath)) 'Installed silfw handler kept' & $repairScript -WhatIf | Out-Null - Assert-True ($registryState.Removed.Count -eq 8) 'Preview must not remove silfw' + Assert-True ($registryState.Removed.Count -eq 7) 'Preview must not remove silfw' $registryState.Values[$silfwCommandPath] = @{ '(default)' = '"' + (Join-Path $sourceRoot 'Output\Release\FieldWorks.exe') + '" %1' } & $repairScript -WhatIf | Out-Null Assert-True ($registryState.Values.ContainsKey($silfwCommandPath)) 'Preview must keep a source silfw handler' @@ -125,21 +125,21 @@ try { $registryState.Values[$fieldWorksPath].RootCodeDir = $installPath $registryState.Values[$fieldWorksPath].RootDataDir = 'relative/path' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'Installed and relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 7) 'Installed and relative overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 42 & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'Non-string overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 7) 'Non-string overrides must be preserved' $registryState.Values[$fieldWorksPath].RootDataDir = 'C:relative' & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'Drive-relative overrides must be preserved' + Assert-True ($registryState.Removed.Count -eq 7) 'Drive-relative overrides must be preserved' Remove-Item -LiteralPath (Join-Path $sourceRoot 'FieldWorks.sln') $registryState.Values[$fieldWorksPath].RootCodeDir = $distFiles & $repairScript -Confirm:$false - Assert-True ($registryState.Removed.Count -eq 8) 'A path without the source marker must be preserved' + Assert-True ($registryState.Removed.Count -eq 7) 'A path without the source marker must be preserved' $registryState.Values = @{} & $repairScript -Confirm:$false & $preflightScript - Assert-True ($registryState.Removed.Count -eq 8) 'Missing registry keys must be harmless' + Assert-True ($registryState.Removed.Count -eq 7) 'Missing registry keys must be harmless' Write-Host "Developer registry tests: $($registryState.Checks) checks passed." } finally { diff --git a/Build/Repair-DeveloperRegistry.ps1 b/Build/Repair-DeveloperRegistry.ps1 index 10cc38b089..256b611040 100644 --- a/Build/Repair-DeveloperRegistry.ps1 +++ b/Build/Repair-DeveloperRegistry.ps1 @@ -3,8 +3,9 @@ Removes per-user code, data, projects, and ICU overrides that point into a FieldWorks source tree. .DESCRIPTION - Use -WhatIf to preview. Paths without an ancestor containing FieldWorks.sln - are preserved. Close installed FieldWorks before applying a repair. + Use -WhatIf to preview. ProjectsDir, which dev and installed runs share, and paths without an + ancestor containing FieldWorks.sln are preserved. Close installed FieldWorks before applying a + repair. #> [CmdletBinding(SupportsShouldProcess)] param() @@ -37,7 +38,7 @@ function Test-FieldWorksSourcePath { $companyPath = 'HKCU:\SOFTWARE\SIL' $fieldWorksPath = Join-Path $companyPath 'FieldWorks\9' $locations = @{ - $fieldWorksPath = @('RootCodeDir', 'RootDataDir', 'ProjectsDir') + $fieldWorksPath = @('RootCodeDir', 'RootDataDir') $companyPath = @() (Join-Path $companyPath 'SilEncConverters40') = @('RootDir', 'DeveloperPluginDir') (Join-Path $companyPath 'EncodingConverterRepository') = @('Registry') diff --git a/Src/Common/FwUtils/FwDirectoryFinder.cs b/Src/Common/FwUtils/FwDirectoryFinder.cs index fac1de03ff..0eb298b75a 100644 --- a/Src/Common/FwUtils/FwDirectoryFinder.cs +++ b/Src/Common/FwUtils/FwDirectoryFinder.cs @@ -243,7 +243,7 @@ public static string GetCodeFile(string filename) } /// - /// Resolves a registry directory. Per-user source-tree code, data and projects paths + /// Resolves a registry directory. Per-user source-tree code and data paths /// fall back to machine settings, then the default directory. /// internal static string GetDirectory(string registryValue, string defaultDir) @@ -252,8 +252,7 @@ internal static string GetDirectory(string registryValue, string defaultDir) { var userValue = userKey?.GetValue(registryValue); if (userValue == null || - ((registryValue == "RootCodeDir" || registryValue == ksRootDataDir - || registryValue == ksProjectsDir) + ((registryValue == "RootCodeDir" || registryValue == ksRootDataDir) && IsSourceTreePath(userValue as string))) return GetDirectoryLocalMachine(registryValue, defaultDir); return GetDirectory(userKey, registryValue, defaultDir); @@ -614,9 +613,8 @@ public static string ProjectsDirectory { if (RunningFromSourceTree) { - // A source-tree build must not leave a per-user ProjectsDir that an - // installed FieldWorks would then follow (LT-22840); it keeps the - // choice for this process only. + // A source-tree run never writes the per-user ProjectsDir, which dev and + // installed runs share (LT-22840); it keeps the choice for this process. s_devProjectsDirectory = value; return; } diff --git a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs index c34c4fd169..ac2c2bb0bf 100644 --- a/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs +++ b/Src/Common/FwUtils/FwUtilsTests/FwDirectoryFinderTests.cs @@ -309,17 +309,21 @@ public void RegistryDirectory_NonSourceUserOverrideWins(string valueName) } } - [TestCase(true)] - [TestCase(false)] - public void RegistryDirectory_SourceTreeProjectsOverrideFallsBack(bool withMachineOverride) - { + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void RegistryDirectory_SourceTreeProjectsOverrideIsHonoured( + bool withMachineOverride, bool devRun) + { + // Dev and installed runs share the projects folder, even one inside a source tree. + FwDirectoryFinder.RunningFromSourceTreeOverride = devRun; var treeRoot = CreateFakeSourceTree(withSolutionFile: true); try { var projects = Path.Combine(treeRoot, "DistFiles", "Projects"); AssertRegistryDirectory("ProjectsDir", projects, - withMachineOverride ? "MachineProjects" : null, "DefaultProjects", - withMachineOverride ? "MachineProjects" : "DefaultProjects"); + withMachineOverride ? "MachineProjects" : null, "DefaultProjects", projects); } finally { @@ -358,14 +362,14 @@ public void ProjectsDirectory_SetFromSourceTreeRun_DoesNotPersist() /// /// Direction one: a dev run happens last, then an installed run. The dev run leaves the - /// per-user key as it found it, and a stale source-tree leftover from an older dev build - /// is not followed by the installed run. + /// per-user key as it found it, and a stale source-tree code or data path from an older + /// dev build is not followed by the installed run. The shared projects folder is. /// [Test] public void Walk_DevRunThenInstalledRun_InstalledIgnoresDevPaths() { var treeRoot = CreateFakeSourceTree(withSolutionFile: true); - var names = new[] { "RootCodeDir", "RootDataDir", "ProjectsDir" }; + var names = new[] { "RootCodeDir", "RootDataDir" }; try { using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) @@ -397,6 +401,11 @@ public void Walk_DevRunThenInstalledRun_InstalledIgnoresDevPaths() foreach (var name in names) Assert.That(FwDirectoryFinder.GetDirectory(name, "Default"), Is.EqualTo("Installed" + name), name); + var projects = Path.Combine(treeRoot, "DistFiles", "Projects"); + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + userKey.SetValue("ProjectsDir", projects); + Assert.That(FwDirectoryFinder.GetDirectory("ProjectsDir", "Default"), + Is.EqualTo(projects), "the shared projects folder stays visible"); } finally { @@ -408,6 +417,8 @@ public void Walk_DevRunThenInstalledRun_InstalledIgnoresDevPaths() userKey.DeleteValue(name, false); machineKey.DeleteValue(name, false); } + userKey.DeleteValue("ProjectsDir", false); + machineKey.DeleteValue("ProjectsDir", false); } Directory.Delete(treeRoot, true); } diff --git a/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs index d8f918d2cf..822308b059 100644 --- a/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs +++ b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs @@ -10,7 +10,8 @@ namespace SIL.FieldWorks.ParatextLexiconPlugin { /// - /// Tests that the plugin does not follow per-user paths a source-tree build left behind + /// Tests that the plugin does not follow per-user code or data paths a source-tree build left + /// behind, and still shares the projects folder /// (LT-22840). Uses throw-away keys under a test hive, never the FieldWorks keys. /// [TestFixture] @@ -47,7 +48,6 @@ public void TearDown() [TestCase("RootCodeDir")] [TestCase("RootDataDir")] - [TestCase("ProjectsDir")] public void SourceTreeUserValue_FallsBackToMachineValue(string name) { m_userKey.SetValue(name, Path.Combine(m_root, "tree", "DistFiles")); @@ -60,6 +60,15 @@ private static string ExpectedMachineValue(string name) return name == "RootCodeDir" ? "MachineCode" : name == "RootDataDir" ? "MachineData" : "MachineProjects"; } + [Test] + public void SourceTreeProjectsDir_IsHonoured() + { + var projects = Path.Combine(m_root, "tree", "DistFiles", "Projects"); + m_userKey.SetValue("ProjectsDir", projects); + Assert.That(ParatextLexiconPluginDirectoryFinder.GetDirectory( + m_userKey, m_machineKey, "ProjectsDir", "Default"), Is.EqualTo(projects)); + } + [TestCase("RootCodeDir")] [TestCase("RootDataDir")] [TestCase("ProjectsDir")] diff --git a/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs b/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs index e68343985b..294f0f1605 100644 --- a/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs +++ b/Src/FwParatextLexiconPlugin/ParatextLexiconPluginDirectoryFinder.cs @@ -71,7 +71,8 @@ private static string GetDirectory(string registryValue, string defaultDir) /// /// Resolves a directory from the per-user key, else the machine key, else the default. - /// A per-user path inside a source tree is a dev build's leftover, so it is skipped. + /// A per-user code or data path inside a source tree is a dev build's leftover, so it is + /// skipped. /// internal static string GetDirectory(RegistryKey userKey, RegistryKey machineKey, string registryValue, string defaultDir) @@ -79,8 +80,7 @@ internal static string GetDirectory(RegistryKey userKey, RegistryKey machineKey, var registryKey = userKey; var userValue = userKey?.GetValue(registryValue); if (userValue == null || - ((registryValue == RootCodeDir || registryValue == RootDataDir || - registryValue == ProjectsDir) && + ((registryValue == RootCodeDir || registryValue == RootDataDir) && FwDirectoryFinder.IsSourceTreePath(userValue as string))) { registryKey = machineKey; From 19d5858e30eefeae3e7dedbbee30ed987ba06de0 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 19 Oct 2026 10:49:53 -0400 Subject: [PATCH 9/9] docs: say ProjectsDir stays shared and tidy the skill edits Restore the em dash and drop the stray blank line in launch-or-attach.md, note that the repair keeps ProjectsDir, and indent Resolve-FieldWorksDevRegistry.ps1 with tabs like other scripts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fieldworks-winapp/navigation/launch-or-attach.md | 4 ++-- .../scripts/Resolve-FieldWorksDevRegistry.ps1 | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md index cb7ba6c457..2230e8d9bf 100644 --- a/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md +++ b/.claude/skills/fieldworks-winapp/navigation/launch-or-attach.md @@ -98,10 +98,10 @@ See the script headers and `../references/mcp-setup.md`. relaunch (Step 0 above). If it is STILL blank in Legacy mode, check whether the process memory is *flat* (via `tasklist`): flat ≈ loaded-but-stuck (a modal that is not enumerated, or the main window failed to build), climbing ≈ still loading - (a Debug build + Sena 3 migration can take minutes -- keep waiting). A current dev build + (a Debug build + Sena 3 migration can take minutes — keep waiting). A current dev build resolves code and data from its own source tree. For installed releases, run the read-only registry preflight to check for worktree overrides; use the explicit repair described above. - + `ProjectsDir` stays shared with the installed app on purpose; the repair keeps it. ## Exit diff --git a/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 b/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 index 1794e681ce..dd42cde1dd 100644 --- a/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 +++ b/.claude/skills/fieldworks-winapp/scripts/Resolve-FieldWorksDevRegistry.ps1 @@ -1,13 +1,13 @@ <# .SYNOPSIS - Previews source-tree registry overrides that can redirect an installed FieldWorks. + Previews source-tree registry overrides that can redirect an installed FieldWorks. .DESCRIPTION - Dev builds resolve code and data from their own source tree. This preflight is - read-only; use Build/Repair-DeveloperRegistry.ps1 explicitly to remove overrides. + Dev builds resolve code and data from their own source tree. This preflight is + read-only; use Build/Repair-DeveloperRegistry.ps1 explicitly to remove overrides. .PARAMETER Force - Accepted for existing callers. The preflight remains read-only. + Accepted for existing callers. The preflight remains read-only. .OUTPUTS - RESULT=READ_ONLY after the preview completes. + RESULT=READ_ONLY after the preview completes. #> [CmdletBinding()] param([switch]$Force)