diff --git a/.claude/skills/fieldworks-winapp/SKILL.md b/.claude/skills/fieldworks-winapp/SKILL.md index 641d7a13d8..880295d386 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 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 8ef6dd97a1..2230e8d9bf 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 + 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. + `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 760887e098..dd42cde1dd 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..65ee5f7f5c --- /dev/null +++ b/Build/Repair-DeveloperRegistry.Tests.ps1 @@ -0,0 +1,151 @@ +<# +.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 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' } + +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' + $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 + 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 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 ($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')) ` + '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 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 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' + $registryState.Values.Remove($silfwCommandPath) + + $registryState.Values[$fieldWorksPath].RootCodeDir = $installPath + $registryState.Values[$fieldWorksPath].RootDataDir = 'relative/path' + & $repairScript -Confirm:$false + 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 7) 'Non-string overrides must be preserved' + $registryState.Values[$fieldWorksPath].RootDataDir = 'C:relative' + & $repairScript -Confirm:$false + 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 7) 'A path without the source marker must be preserved' + $registryState.Values = @{} + & $repairScript -Confirm:$false + & $preflightScript + Assert-True ($registryState.Removed.Count -eq 7) '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..256b611040 --- /dev/null +++ b/Build/Repair-DeveloperRegistry.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + Removes per-user code, data, projects, and ICU overrides that point into a FieldWorks source + tree. +.DESCRIPTION + 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() + +$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 = @() + (Join-Path $companyPath 'SilEncConverters40') = @('RootDir', 'DeveloperPluginDir') + (Join-Path $companyPath 'EncodingConverterRepository') = @('Registry') +} +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 + } + } + } +} + +# 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 + } + } + } +} diff --git a/Build/mkall.targets b/Build/mkall.targets index 6e29db4632..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,64 +269,4 @@ - - - - - - - - - - - - - - - - - - diff --git a/Lib/src/Converter/Convertlib/Convert.cs b/Lib/src/Converter/Convertlib/Convert.cs index 5027731c65..1bbdc14b19 100644 --- a/Lib/src/Converter/Convertlib/Convert.cs +++ b/Lib/src/Converter/Convertlib/Convert.cs @@ -524,10 +524,36 @@ 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 (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; + } + private string GetModelName() //Locate the Model File { string rootDir = RootCodeDir; diff --git a/Src/Common/FwUtils/FwDirectoryFinder.cs b/Src/Common/FwUtils/FwDirectoryFinder.cs index 960018fcca..0eb298b75a 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 { @@ -241,30 +242,52 @@ public static string GetCodeFile(string filename) return Path.Combine(CodeDirectory, filename); } - /// ------------------------------------------------------------------------------------ /// - /// 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 and 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); } } + /// + /// 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; + 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 (Exception error) when (error is ArgumentException || + error is NotSupportedException || error is PathTooLongException) + { + Logger.WriteError("Could not inspect the FieldWorks registry directory", error); + } + return false; + } + /// /// Get a directory for a particular key ignoring current user settings. /// @@ -372,6 +395,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( @@ -550,14 +594,30 @@ 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 /// /// ------------------------------------------------------------------------------------ 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 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; + } if (ProjectsDirectory == value) return; // no change. 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/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 33b0210dfb..ac2c2bb0bf 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 /// @@ -220,6 +234,291 @@ 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)] + [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); + } + } + + [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", projects); + } + 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 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" }; + 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); + 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 + { + using (var userKey = FwRegistryHelper.FieldWorksRegistryKey) + using (var machineKey = FwRegistryHelper.FieldWorksRegistryKeyLocalMachine) + { + foreach (var name in names) + { + userKey.DeleteValue(name, false); + machineKey.DeleteValue(name, false); + } + userKey.DeleteValue("ProjectsDir", false); + machineKey.DeleteValue("ProjectsDir", 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(" /// Verify that the user project key falls back to the local machine. /// 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); + } + } +} diff --git a/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs new file mode 100644 index 0000000000..822308b059 --- /dev/null +++ b/Src/FwParatextLexiconPlugin/FwParatextLexiconPluginTests/ParatextLexiconPluginDirectoryFinderTests.cs @@ -0,0 +1,90 @@ +// 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 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] + 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")] + 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"; + } + + [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")] + 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..294f0f1605 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 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) + { + var registryKey = userKey; + var userValue = userKey?.GetValue(registryValue); + if (userValue == null || + ((registryValue == RootCodeDir || registryValue == RootDataDir) && + FwDirectoryFinder.IsSourceTreePath(userValue as string))) + { + registryKey = machineKey; } + + return GetDirectory(registryKey, registryValue, defaultDir); } private static string GetDirectory(RegistryKey registryKey, string registryValue, string defaultDir)