From d04c9b20585e4f24b71f8d67b57d55db5497ddca Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 10 Jun 2026 14:30:10 -0500 Subject: [PATCH] fixed path bug --- CLAUDE.md | 2 +- authoring-ui.Tests/MainViewModelTests.cs | 25 +++++++++++++--- authoring-ui/MainViewModel.cs | 6 ++++ authoring-ui/MainWindow.cs | 36 +++++++++++++++++++++--- src/win.rs | 34 ++++++++++++++++++++++ 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1ed438..b1d1608 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ the example manifest (`examples/Covenant-SetupSampleApp-install.toml`). - `InstallManifest` — Declarative TOML contract: directories, files, registry, shortcuts, scripts, purge spec - `Journal` / `JournalAction` — Serialized record of every mutation for deterministic rollback - `MutationTracker` trait — Extensibility point (MVP uses `DeclaredTracker`; future: `ObservedTracker` for ETW-based capture) -- `PathResolver` — Resolves `{ProgramFilesX64}`, `{LocalAppData}`, `{Desktop}` tokens via `SHGetKnownFolderPath` +- `PathResolver` — Resolves `{ProgramFilesX64}`, `{ProgramFilesX86}`, `{LocalAppData}`, `{Desktop}` tokens via `SHGetKnownFolderPath` - `Logger` — Dual-mode output: structured JSON (`--json` flag) for IPC or human-readable text **Elevation:** Manifest/journal is scanned for `HKLM` registry or ProgramFiles paths to determine if admin is needed. Auto-relaunches via `ShellExecuteW` with `runas` when `--elevate` flag is set. Exit code 33 signals elevation required. diff --git a/authoring-ui.Tests/MainViewModelTests.cs b/authoring-ui.Tests/MainViewModelTests.cs index a94ba18..08a4a98 100644 --- a/authoring-ui.Tests/MainViewModelTests.cs +++ b/authoring-ui.Tests/MainViewModelTests.cs @@ -16,8 +16,8 @@ public class MainViewModelTests Assert.Empty(viewModel.PurgePaths); Assert.Equal("Covenant-SetupSampleApp-install.toml", viewModel.ExpectedManifestFileName); Assert.Contains("app_name = 'Covenant-Setup Sample App'", viewModel.TomlPreview); - Assert.Contains(@"{LocalAppData}\CovenantSetupSample", viewModel.TomlPreview); - Assert.Contains(@"{Desktop}\CovenantSetupSample.lnk", viewModel.TomlPreview); + Assert.Contains(@"{ProgramFilesX64}\CovenantSetupSample", viewModel.TomlPreview); + Assert.Contains(@"{Desktop}\sample_app.lnk", viewModel.TomlPreview); Assert.DoesNotContain(@"{Desktop}\Covenant-Setup Sample App.lnk", viewModel.TomlPreview, StringComparison.Ordinal); Assert.Contains(@"payload\sample_app.cmd", viewModel.TomlPreview); } @@ -168,9 +168,9 @@ public class MainViewModelTests var defaultDocument = viewModel.BuildDocument(); var defaultShortcut = Assert.Single(defaultDocument.Shortcuts); - Assert.Equal(@"{Desktop}\RenamedApp.lnk", defaultShortcut.Path); + Assert.Equal(@"{Desktop}\renamed.lnk", defaultShortcut.Path); Assert.Equal(@"{ProgramFilesX64}\RenamedApp\bin\renamed.exe", defaultShortcut.Target); - Assert.Equal(@"{ProgramFilesX64}\RenamedApp", defaultShortcut.WorkingDirectory); + Assert.Equal(@"{ProgramFilesX64}\RenamedApp\bin", defaultShortcut.WorkingDirectory); Assert.Equal("Launch Renamed App", defaultShortcut.Description); viewModel.ShortcutDescription = "Start Renamed App"; @@ -219,4 +219,21 @@ public class MainViewModelTests Assert.True(viewModel.HasValidationErrors); Assert.False(viewModel.CanPackage); } + + [Fact] + public void Placeholders_update_with_install_root_token() + { + var viewModel = new MainViewModel(() => null) + { + InstallRootToken = "{ProgramFilesX64}" + }; + + Assert.Equal(@"{ProgramFilesX64}\Vendor\App", viewModel.DirectoryPlaceholder); + Assert.Equal(@"{ProgramFilesX64}\Vendor\App\bin\app.exe", viewModel.FileDestinationPlaceholder); + + viewModel.InstallRootToken = "{LocalAppData}"; + + Assert.Equal(@"{LocalAppData}\Vendor\App", viewModel.DirectoryPlaceholder); + Assert.Equal(@"{LocalAppData}\Vendor\App\bin\app.exe", viewModel.FileDestinationPlaceholder); + } } diff --git a/authoring-ui/MainViewModel.cs b/authoring-ui/MainViewModel.cs index 7cd1cd7..9805d24 100644 --- a/authoring-ui/MainViewModel.cs +++ b/authoring-ui/MainViewModel.cs @@ -58,6 +58,10 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged set => SetProperty(ref _installRootToken, value); } + public string DirectoryPlaceholder => $"{InstallRootToken}\\Vendor\\App"; + + public string FileDestinationPlaceholder => $"{InstallRootToken}\\Vendor\\App\\bin\\app.exe"; + public string ApplicationFolder { get => _applicationFolder; @@ -414,6 +418,8 @@ internal sealed partial class MainViewModel : INotifyPropertyChanged OnPropertyChanged(nameof(ShortcutWorkingDirectory)); OnPropertyChanged(nameof(PurgePathsPreview)); OnPropertyChanged(nameof(PurgeRegistryBranchesPreview)); + OnPropertyChanged(nameof(DirectoryPlaceholder)); + OnPropertyChanged(nameof(FileDestinationPlaceholder)); } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) diff --git a/authoring-ui/MainWindow.cs b/authoring-ui/MainWindow.cs index cfbeaad..ade15a2 100644 --- a/authoring-ui/MainWindow.cs +++ b/authoring-ui/MainWindow.cs @@ -313,7 +313,14 @@ internal sealed class MainWindow : Window private FrameworkElement BuildDirectoriesSection() { - var pathBox = new TextBox { PlaceholderText = @"{LocalAppData}\Vendor\App" }; + var pathBox = new TextBox(); + pathBox.SetBinding( + TextBox.PlaceholderTextProperty, + new Binding + { + Path = new PropertyPath(nameof(MainViewModel.DirectoryPlaceholder)), + Mode = BindingMode.OneWay + }); var rows = RemovableRows(_viewModel.Directories); var addButton = new Button { Content = "Add Path", MinWidth = 88 }; addButton.Click += (_, _) => @@ -331,7 +338,14 @@ internal sealed class MainWindow : Window private FrameworkElement BuildFilesSection() { var sourceBox = new TextBox { PlaceholderText = @"payload\app.exe" }; - var destinationBox = new TextBox { PlaceholderText = @"{LocalAppData}\Vendor\App\bin\app.exe" }; + var destinationBox = new TextBox(); + destinationBox.SetBinding( + TextBox.PlaceholderTextProperty, + new Binding + { + Path = new PropertyPath(nameof(MainViewModel.FileDestinationPlaceholder)), + Mode = BindingMode.OneWay + }); var rows = RemovableRows(_viewModel.Files); var addButton = new Button { Content = "Add", MinWidth = 72 }; addButton.Click += async (_, _) => @@ -358,7 +372,14 @@ internal sealed class MainWindow : Window { var keyBox = new TextBox { PlaceholderText = @"HKCU\Software\VendorApp" }; var nameBox = new TextBox { PlaceholderText = "InstallRoot" }; - var valueBox = new TextBox { PlaceholderText = @"{LocalAppData}\Vendor\App" }; + var valueBox = new TextBox(); + valueBox.SetBinding( + TextBox.PlaceholderTextProperty, + new Binding + { + Path = new PropertyPath(nameof(MainViewModel.DirectoryPlaceholder)), + Mode = BindingMode.OneWay + }); var rows = RemovableRows(_viewModel.Registry); var addButton = new Button { Content = "Add", MinWidth = 72 }; addButton.Click += async (_, _) => @@ -403,7 +424,14 @@ internal sealed class MainWindow : Window Height = 76, PlaceholderText = "-ExecutionPolicy" }; - var workingDirectoryBox = new TextBox { PlaceholderText = @"{LocalAppData}\Vendor\App" }; + var workingDirectoryBox = new TextBox(); + workingDirectoryBox.SetBinding( + TextBox.PlaceholderTextProperty, + new Binding + { + Path = new PropertyPath(nameof(MainViewModel.DirectoryPlaceholder)), + Mode = BindingMode.OneWay + }); var rows = RemovableRows(_viewModel.Scripts); var addButton = new Button { Content = "Add", MinWidth = 72 }; addButton.Click += async (_, _) => diff --git a/src/win.rs b/src/win.rs index 2d9569e..56b1213 100644 --- a/src/win.rs +++ b/src/win.rs @@ -36,6 +36,7 @@ use windows::core::{Interface, PCWSTR, PWSTR, w}; pub struct PathResolver { pub program_files_x64: PathBuf, + pub program_files_x86: PathBuf, pub local_app_data: PathBuf, pub desktop: PathBuf, admin_roots: Vec, @@ -50,6 +51,7 @@ impl PathResolver { build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]); Ok(Self { program_files_x64, + program_files_x86, local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?, desktop: known_folder(&FOLDERID_Desktop, logger)?, admin_roots, @@ -63,6 +65,10 @@ impl PathResolver { "{ProgramFilesX64}", &self.program_files_x64.to_string_lossy(), ) + .replace( + "{ProgramFilesX86}", + &self.program_files_x86.to_string_lossy(), + ) .replace("{LocalAppData}", &self.local_app_data.to_string_lossy()) .replace("{Desktop}", &self.desktop.to_string_lossy()), ) @@ -80,6 +86,7 @@ impl PathResolver { let admin_roots = build_admin_roots(&roots.iter().collect::>()); Self { program_files_x64: PathBuf::new(), + program_files_x86: PathBuf::new(), local_app_data: PathBuf::new(), desktop: PathBuf::new(), admin_roots, @@ -631,6 +638,33 @@ mod tests { ]) } + #[test] + fn resolve_replaces_every_known_folder_token() { + let r = PathResolver { + program_files_x64: PathBuf::from("C:\\Program Files"), + program_files_x86: PathBuf::from("C:\\Program Files (x86)"), + local_app_data: PathBuf::from("C:\\Users\\alice\\AppData\\Local"), + desktop: PathBuf::from("C:\\Users\\alice\\Desktop"), + admin_roots: Vec::new(), + }; + assert_eq!( + r.resolve("{ProgramFilesX64}\\App\\bin"), + PathBuf::from("C:\\Program Files\\App\\bin") + ); + assert_eq!( + r.resolve("{ProgramFilesX86}\\Vendor\\app.exe"), + PathBuf::from("C:\\Program Files (x86)\\Vendor\\app.exe") + ); + assert_eq!( + r.resolve("{LocalAppData}\\Vendor"), + PathBuf::from("C:\\Users\\alice\\AppData\\Local\\Vendor") + ); + assert_eq!( + r.resolve("{Desktop}\\App.lnk"), + PathBuf::from("C:\\Users\\alice\\Desktop\\App.lnk") + ); + } + #[test] fn requires_admin_matches_subpaths() { let r = resolver();