diff --git a/.github/workflows/LANCommander.Packager.yml b/.github/workflows/LANCommander.Packager.yml
new file mode 100644
index 00000000..570eaab3
--- /dev/null
+++ b/.github/workflows/LANCommander.Packager.yml
@@ -0,0 +1,100 @@
+name: LANCommander Packager Build
+
+on:
+ workflow_dispatch:
+ workflow_call:
+ inputs:
+ version_semver:
+ description: "Semantic Version"
+ required: true
+ type: string
+ version_tag:
+ description: 'Version Tag'
+ required: true
+ type: string
+ build_dotnet_version:
+ description: 'Build .NET Version'
+ required: false
+ type: string
+ default: '10.0.x'
+ build_configuration:
+ description: 'Build Configuration (Debug/Release)'
+ required: false
+ type: string
+ default: 'Release'
+
+permissions:
+ contents: write
+
+env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/package
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ inputs.build_dotnet_version }}
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Publish Packager
+ shell: pwsh
+ run: |
+ # Strip leading 'v' if present
+ $RawVersion = "${{ inputs.version_tag }}"
+ $Semver = $RawVersion -replace '^v', ''
+
+ # Numeric part only for Assembly/FileVersion
+ $Numeric = ($Semver -split '-')[0]
+ $AssemblyVersion = "$Numeric.0"
+
+ Write-Host "SEMVER=$Semver"
+ Write-Host "ASSEMBLY_VERSION=$AssemblyVersion"
+
+ dotnet publish "./LANCommander.Packager/LANCommander.Packager.csproj" `
+ -c "${{ inputs.build_configuration }}" `
+ --self-contained `
+ --runtime win-x86 `
+ -p:Version="$Semver" `
+ -p:AssemblyVersion="$AssemblyVersion" `
+ -p:FileVersion="$AssemblyVersion" `
+ -p:InformationalVersion="$Semver" `
+ -p:PublishSingleFile=true `
+ -p:IncludeNativeLibrariesForSelfExtract=true `
+ -p:IncludeAllContentForSelfExtract=true `
+ -p:EnableCompressionInSingleFile=true `
+ -p:DebugType=embedded
+
+ - name: Clean
+ shell: pwsh
+ run: |
+ $BasePath = "LANCommander.Packager/bin/${{ inputs.build_configuration }}/net10.0/win-x86/publish"
+ Remove-Item -Recurse -Force -ErrorAction Continue "$BasePath/*.pdb"
+
+ - name: Compress Build Output
+ shell: pwsh
+ run: |
+ $compress = @{
+ Path = "LANCommander.Packager/bin/${{ inputs.build_configuration }}/net10.0/win-x86/publish/*"
+ DestinationPath = "LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip"
+ CompressionLevel = "Fastest"
+ }
+ Compress-Archive @compress
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ path: LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip
+ name: LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip
diff --git a/.github/workflows/LANCommander.Release.yml b/.github/workflows/LANCommander.Release.yml
index d56084ee..a48c705b 100644
--- a/.github/workflows/LANCommander.Release.yml
+++ b/.github/workflows/LANCommander.Release.yml
@@ -165,6 +165,16 @@ jobs:
build_platform: Windows
build_configuration: Release
+ # Packager (Windows x86 only)
+ build_packager:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Packager.yml
+ with:
+ build_dotnet_version: '10.0.x'
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_configuration: Release
+
build_release:
runs-on: ubuntu-latest
needs:
@@ -179,6 +189,7 @@ jobs:
- build_launcher_avalonia_osx_arm64
- build_launcher_avalonia_osx_x64
- build_launcher_avalonia_win_x64
+ - build_packager
steps:
- name: Create Temp Directory
@@ -245,6 +256,12 @@ jobs:
name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
+ - name: Download Packager Windows x86
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Packager-Windows-x86-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
- name: Debug - List Artifact Files
run: |
echo "Contents of ./artifacts:"
@@ -267,6 +284,7 @@ jobs:
artifacts/LANCommander.Launcher-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Launcher-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Packager-Windows-x86-v${{ needs.prep.outputs.version_tag }}.zip
- name: Checkout Repo for Docker build
uses: actions/checkout@v4
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ba77b808..27070e87 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -92,6 +92,7 @@
+
diff --git a/LANCommander.Documentation/Overview.md b/LANCommander.Documentation/Overview.md
index f446de80..9968fb3c 100644
--- a/LANCommander.Documentation/Overview.md
+++ b/LANCommander.Documentation/Overview.md
@@ -25,5 +25,6 @@ This site serves as the main documentation platform for the project. As such, it
- [Getting Started](/GettingStarted)
- [Server](/Server/Overview)
- [Launcher](/Launcher/Overview)
+- [Packager](/Packager/Overview)
- [Scripting](/Scripting/Overview)
- [SDK Documentation](/SDK/Overview)
\ No newline at end of file
diff --git a/LANCommander.Documentation/Packager/Getting Started.md b/LANCommander.Documentation/Packager/Getting Started.md
new file mode 100644
index 00000000..a66ade30
--- /dev/null
+++ b/LANCommander.Documentation/Packager/Getting Started.md
@@ -0,0 +1,34 @@
+---
+sidebar_label: Getting Started
+sidebar_position: 2
+---
+
+# Getting Started
+
+## Requirements
+
+- **Windows 10 or later** (x86 or x64)
+- **Administrator privileges** - the Packager requires elevation to monitor installer processes via DLL injection
+
+The Packager is distributed as a single 32-bit executable (`LANCommander.Packager.exe`). No installation is required.
+
+## Download
+
+Download the latest release from the [GitHub Releases page](https://github.com/LANCommander/LANCommander/releases). The Packager artifact is named `LANCommander.Packager-Windows-x86-v{VERSION}.zip`.
+
+Extract the archive to a directory of your choice and run `LANCommander.Packager.exe`.
+
+## Command-Line Usage
+
+The Packager can optionally accept arguments to skip the initial file picker dialog:
+
+```
+LANCommander.Packager.exe [installer-path] [-o output-path]
+```
+
+| Argument | Description |
+|:--------:|:------------|
+| `installer-path` | Path to the installer executable to monitor |
+| `-o`, `--output` | Path for the output `.lcx` file |
+
+If no installer path is provided, a file picker dialog will appear on launch.
diff --git a/LANCommander.Documentation/Packager/LCX Format.md b/LANCommander.Documentation/Packager/LCX Format.md
new file mode 100644
index 00000000..abac23cc
--- /dev/null
+++ b/LANCommander.Documentation/Packager/LCX Format.md
@@ -0,0 +1,47 @@
+---
+sidebar_label: LCX Package Format
+sidebar_position: 4
+---
+
+# LCX Package Format
+
+An `.LCX` file is a standard ZIP archive containing everything needed to install and configure a game through LANCommander. The Packager generates this format automatically, but understanding its structure is useful for troubleshooting or manual editing.
+
+## Archive Structure
+
+```
+package.lcx (ZIP)
+├── manifest.yaml # Game metadata (YAML)
+├── Archives/
+│ └── {guid} # Inner ZIP containing game files
+└── Scripts/
+ ├── {guid} # Install script (PowerShell)
+ └── {guid} # Uninstall script (PowerShell)
+```
+
+### manifest.yaml
+
+The manifest is a YAML file describing the game's metadata, actions, archive references, and script references. It follows the LANCommander SDK's `Game` manifest schema and includes:
+
+- **Title, Sort Title, Version, Description, Notes** - basic metadata
+- **Released On, Singleplayer** - classification
+- **Directory Name** - the expected install directory name
+- **Actions** - launch configurations (name, executable path, arguments, primary flag)
+- **Archives** - references to inner archive entries with compressed/uncompressed sizes
+- **Scripts** - references to script entries with type (Install/Uninstall) and admin requirements
+
+### Archives
+
+The `Archives/` directory contains one or more inner ZIP files, each identified by a GUID. The inner archive holds the game files with paths relative to the install directory root.
+
+### Scripts
+
+The `Scripts/` directory contains PowerShell scripts identified by GUID. The Packager generates up to two scripts:
+
+**Install Script** - Recreates registry keys and values captured during monitoring. If the Patch GameSpy option was enabled, it also includes an `Edit-PatchGameSpy` call. Scripts assume `$InstallDirectory` is available in the execution environment (provided by the launcher's PowerShell runtime).
+
+**Uninstall Script** - Removes the registry keys and values that were created by the install script.
+
+## Importing into LANCommander
+
+`.LCX` packages can be imported directly through the LANCommander server's web interface. The server reads the manifest, extracts the archive and scripts, and creates the corresponding game entry with all metadata, actions, and scripts pre-configured.
diff --git a/LANCommander.Documentation/Packager/Overview.md b/LANCommander.Documentation/Packager/Overview.md
new file mode 100644
index 00000000..d6e3bad8
--- /dev/null
+++ b/LANCommander.Documentation/Packager/Overview.md
@@ -0,0 +1,14 @@
+---
+sidebar_label: Overview
+sidebar_position: 1
+---
+
+# Packager
+
+The LANCommander Packager is a standalone Windows utility that automates the creation of `.LCX` game packages. It monitors a game installer as it runs, captures all file and registry changes, and guides you through a wizard to produce a ready-to-import package for your LANCommander server.
+
+Instead of manually creating archives, writing install scripts, and filling out metadata by hand, the Packager handles all of this in a single guided workflow.
+
+import DocCardList from '@theme/DocCardList';
+
+
diff --git a/LANCommander.Documentation/Packager/Wizard.md b/LANCommander.Documentation/Packager/Wizard.md
new file mode 100644
index 00000000..bc5bcea8
--- /dev/null
+++ b/LANCommander.Documentation/Packager/Wizard.md
@@ -0,0 +1,114 @@
+---
+sidebar_label: Wizard Walkthrough
+sidebar_position: 3
+---
+
+# Wizard Walkthrough
+
+The Packager walks you through seven steps to create a complete `.LCX` package. Each step is shown in the sidebar with a progress indicator.
+
+---
+
+## Step 1: Monitor Installer
+
+After selecting an installer executable, the Packager launches it and monitors all file and registry activity using native DLL injection (Interposer). A real-time log displays captured events as the installer runs.
+
+The Packager automatically:
+- Detects the installer's architecture (32-bit or 64-bit) and injects the appropriate Interposer DLL
+- Monitors child processes spawned by the installer
+- Filters out writes to system directories (Windows, temp folders)
+- Captures both file writes and registry key/value creation
+
+Once the installer exits, the captured data is summarized in the status bar. Click **Next** to continue.
+
+:::info
+The log view continues to show captured events for reference. All diagnostic output is also written to `packager.log` in the application directory.
+:::
+
+---
+
+## Step 2: Install Directory
+
+The Packager analyzes the captured file writes to detect the game's installation directory. This is determined by finding the most common non-system directory among the written files.
+
+If the detected directory is incorrect, click **Browse** to manually select the correct location. This directory becomes the root of the game archive.
+
+---
+
+## Step 3: Select Files
+
+All files within the install directory are displayed in a tree view with checkboxes. By default, every file is selected.
+
+- **Check/uncheck a directory** to toggle all files within it
+- **Select All** / **Select None** buttons at the top for bulk operations
+- The counter at the top shows how many files are currently selected
+
+Files outside the install directory (if any were captured) are listed by their full paths. Only files that still exist on disk at this point are shown.
+
+---
+
+## Step 4: Registry Entries
+
+All captured registry writes are displayed in a tree view organized by hive and key path. Entries are deduplicated so if the same key and value were written multiple times during installation, only one entry is shown.
+
+Each leaf entry displays an indicator:
+- **Green +** - the entry was created during installation
+- **Yellow ~** - the entry was updated (written to an existing key)
+
+Selected entries will be included in the auto-generated install and uninstall scripts. The install script recreates the registry keys and values; the uninstall script removes them.
+
+---
+
+## Step 5: Game Metadata
+
+Enter basic information about the game. The title is pre-populated from the installer's filename.
+
+| Field | Description |
+|:------|:------------|
+| **Title** | Display name of the game (required) |
+| **Sort Title** | Optional override for alphabetical sorting |
+| **Version** | Game version, defaults to `1.0` |
+| **Released On** | Release date of the game |
+| **Singleplayer** | Whether the game supports singleplayer |
+| **Description** | A description of the game |
+| **Notes** | Private notes (admin-only, not shown to users) |
+
+---
+
+## Step 6: Game Executable
+
+The Packager scans your selected files for `.exe` files and filters out common installer/redistributable executables (e.g. `vcredist`, `dxsetup`, `setup`, `unins`). The remaining executables are displayed in a list.
+
+Select the primary game executable. This is the file the launcher will run when the user clicks "Play". You can also customize:
+
+| Field | Description |
+|:------|:------------|
+| **Action Name** | Label shown on the play button, defaults to `Play` |
+| **Arguments** | Command-line arguments passed when launching |
+
+---
+
+## Step 7: Generate Package
+
+Configure the output path for the `.LCX` file and optionally adjust packaging options before generating.
+
+### Output Path
+
+The default output path is based on the game title in the current working directory. Click **Browse** to choose a different location.
+
+### Options
+
+Expand the **Options** panel to configure additional settings:
+
+| Option | Description |
+|:-------|:------------|
+| **Patch GameSpy** | Adds an `Edit-PatchGameSpy -Path $InstallDirectory` call to the install script. This scans the install directory for GameSpy references and patches them for OpenSpy compatibility. |
+| **Compression Level** | Controls the trade-off between archive size and packaging speed. Options: Optimal (default), Fastest, No Compression, Smallest Size. |
+| **Write Summary Log** | Writes a `.Package.log` file alongside the `.LCX` output documenting the source installer, selected files, registry entries, metadata, and options used. |
+
+Click **Generate .LCX** to build the package. A progress bar shows the current stage:
+1. Creating game files archive
+2. Generating scripts
+3. Writing manifest
+
+On completion, the output path and file size are displayed.
diff --git a/LANCommander.Packager/App.axaml b/LANCommander.Packager/App.axaml
new file mode 100644
index 00000000..386716a0
--- /dev/null
+++ b/LANCommander.Packager/App.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/App.axaml.cs b/LANCommander.Packager/App.axaml.cs
new file mode 100644
index 00000000..264bb3f8
--- /dev/null
+++ b/LANCommander.Packager/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace LANCommander.Packager;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow(Program.Context);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/LANCommander.Packager/LANCommander.Packager.csproj b/LANCommander.Packager/LANCommander.Packager.csproj
new file mode 100644
index 00000000..dbc9e51f
--- /dev/null
+++ b/LANCommander.Packager/LANCommander.Packager.csproj
@@ -0,0 +1,46 @@
+
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ x86
+ app.manifest
+ LANCommanderDark.ico
+ LANCommander
+ Packager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/LANCommanderDark.ico b/LANCommander.Packager/LANCommanderDark.ico
new file mode 100644
index 00000000..4bec2dd1
Binary files /dev/null and b/LANCommander.Packager/LANCommanderDark.ico differ
diff --git a/LANCommander.Packager/MainWindow.axaml b/LANCommander.Packager/MainWindow.axaml
new file mode 100644
index 00000000..f71d0be6
--- /dev/null
+++ b/LANCommander.Packager/MainWindow.axaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/MainWindow.axaml.cs b/LANCommander.Packager/MainWindow.axaml.cs
new file mode 100644
index 00000000..fb894dd0
--- /dev/null
+++ b/LANCommander.Packager/MainWindow.axaml.cs
@@ -0,0 +1,205 @@
+using System.Collections.ObjectModel;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using LANCommander.Packager.Models;
+using LANCommander.Packager.Views;
+
+namespace LANCommander.Packager;
+
+public partial class MainWindow : Window
+{
+ private readonly PackageContext _context;
+ private readonly UserControl[] _steps;
+ private readonly string[] _stepTitles;
+ private readonly string[] _stepHelps;
+ private readonly ObservableCollection _stepItems;
+ private int _currentStep;
+ private bool _monitoringComplete;
+
+ private readonly MonitoringView _monitoringView;
+ private readonly InstallDirectoryView _installDirView;
+ private readonly FileSelectionView _fileSelectionView;
+ private readonly RegistrySelectionView _registrySelectionView;
+ private readonly MetadataView _metadataView;
+ private readonly ActionView _actionView;
+ private readonly OutputView _outputView;
+
+ public MainWindow() : this(new PackageContext()) { }
+
+ public MainWindow(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+
+ _monitoringView = new MonitoringView(context);
+ _installDirView = new InstallDirectoryView(context);
+ _fileSelectionView = new FileSelectionView(context);
+ _registrySelectionView = new RegistrySelectionView(context);
+ _metadataView = new MetadataView(context);
+ _actionView = new ActionView(context);
+ _outputView = new OutputView(context);
+
+ _monitoringView.MonitoringCompleted += () =>
+ {
+ _monitoringComplete = true;
+ Dispatcher.UIThread.Post(() => NextButton.IsEnabled = true);
+ };
+
+ _steps = [_monitoringView, _installDirView, _fileSelectionView,
+ _registrySelectionView, _metadataView, _actionView, _outputView];
+
+ _stepTitles = ["Monitor Installer", "Install Directory", "Select Files",
+ "Registry Entries", "Game Metadata", "Game Executable", "Generate Package"];
+
+ _stepHelps =
+ [
+ "The installer will be monitored for file and registry changes.",
+ "Confirm the directory where the game was installed. This will be the root of the game archive.",
+ "Select which files to include in the package. Use the checkboxes to toggle selection.",
+ "Select which registry entries should be recreated by the install script.",
+ "Enter basic information about the game.",
+ "Select the primary game executable and configure the launch action.",
+ "Choose the output path and generate the .LCX package file."
+ ];
+
+ _stepItems = new ObservableCollection
+ {
+ new() { Index = 0, Title = "Monitor Installer" },
+ new() { Index = 1, Title = "Install Directory" },
+ new() { Index = 2, Title = "Select Files" },
+ new() { Index = 3, Title = "Registry Entries" },
+ new() { Index = 4, Title = "Game Metadata" },
+ new() { Index = 5, Title = "Game Executable" },
+ new() { Index = 6, Title = "Generate Package" },
+ };
+
+ StepList.ItemsSource = _stepItems;
+
+ BackButton.Click += OnBackClick;
+ NextButton.Click += OnNextClick;
+ CancelButton.Click += (_, _) => Close();
+
+ GoToStep(0);
+ }
+
+ protected override async void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+ await StartFirstStep();
+ }
+
+ private async Task StartFirstStep()
+ {
+ if (string.IsNullOrWhiteSpace(_context.InstallerPath))
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Select Installer",
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new("Executable Files") { Patterns = ["*.exe", "*.msi"] },
+ FilePickerFileTypes.All
+ ]
+ });
+
+ if (files.Count > 0)
+ _context.InstallerPath = files[0].Path.LocalPath;
+ else
+ {
+ Close();
+ return;
+ }
+ }
+
+ _monitoringView.StartMonitoring();
+ }
+
+ private void OnBackClick(object? sender, RoutedEventArgs e)
+ {
+ if (_currentStep > 0)
+ {
+ ApplyCurrentStep();
+ GoToStep(_currentStep - 1);
+ }
+ }
+
+ private void OnNextClick(object? sender, RoutedEventArgs e)
+ {
+ if (_currentStep == _steps.Length - 1)
+ {
+ Close();
+ return;
+ }
+
+ ApplyCurrentStep();
+ GoToStep(_currentStep + 1);
+ }
+
+ private void ApplyCurrentStep()
+ {
+ switch (_currentStep)
+ {
+ case 1: _installDirView.ApplySelection(); break;
+ case 2: _fileSelectionView.ApplySelection(); break;
+ case 3: _registrySelectionView.ApplySelection(); break;
+ case 4: _metadataView.ApplyMetadata(); break;
+ case 5: _actionView.ApplyAction(); break;
+ }
+ }
+
+ private void GoToStep(int step)
+ {
+ _currentStep = step;
+
+ ContentArea.Content = _steps[step];
+ StepTitle.Text = _stepTitles[step];
+ StepHelp.Text = _stepHelps[step];
+
+ for (int i = 0; i < _stepItems.Count; i++)
+ {
+ _stepItems[i].State = i < step ? StepState.Completed
+ : i == step ? StepState.Current
+ : StepState.Pending;
+ }
+
+ BackButton.IsVisible = step > 0;
+ NextButton.Content = step == _steps.Length - 1 ? "Finish" : "Next";
+ NextButton.IsEnabled = step != 0 || _monitoringComplete;
+
+ EnterStep(step);
+ }
+
+ private void EnterStep(int step)
+ {
+ switch (step)
+ {
+ case 1:
+ _installDirView.PopulateFromMonitor(_monitoringView.GetMonitorService());
+ break;
+ case 2:
+ _installDirView.ApplySelection();
+ _fileSelectionView.PopulateFiles();
+ break;
+ case 3:
+ _fileSelectionView.ApplySelection();
+ _registrySelectionView.PopulateEntries();
+ break;
+ case 4:
+ _registrySelectionView.ApplySelection();
+ _metadataView.SetDefaultTitle(
+ Path.GetFileNameWithoutExtension(_context.InstallerPath));
+ break;
+ case 5:
+ _metadataView.ApplyMetadata();
+ _actionView.PopulateExecutables();
+ break;
+ case 6:
+ _actionView.ApplyAction();
+ _outputView.SetDefaultOutputPath();
+ break;
+ }
+ }
+}
diff --git a/LANCommander.Packager/Models/CheckableTreeNode.cs b/LANCommander.Packager/Models/CheckableTreeNode.cs
new file mode 100644
index 00000000..9d855b4a
--- /dev/null
+++ b/LANCommander.Packager/Models/CheckableTreeNode.cs
@@ -0,0 +1,226 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+
+namespace LANCommander.Packager.Models;
+
+public class CheckableTreeNode : INotifyPropertyChanged
+{
+ private bool? _isChecked = true;
+ private bool _isExpanded = true;
+ private bool _suppressEvents;
+
+ public string Name { get; set; } = "";
+ public string FullPath { get; set; } = "";
+ public int SourceIndex { get; set; } = -1;
+ public string? Indicator { get; set; }
+ public bool IsCreate => Indicator == "+";
+ public bool IsUpdate => Indicator == "~";
+ public bool IsLeaf => Children.Count == 0;
+ public ObservableCollection Children { get; } = new();
+ public CheckableTreeNode? Parent { get; set; }
+ public Action? OnTreeSelectionChanged { get; set; }
+
+ public bool? IsChecked
+ {
+ get => _isChecked;
+ set
+ {
+ var effective = value ?? true;
+
+ if (_isChecked == effective)
+ return;
+
+ _isChecked = effective;
+
+ PropertyChanged?.Invoke(this, new(nameof(IsChecked)));
+
+ if (!_suppressEvents)
+ {
+ SetChildrenChecked(effective);
+ Parent?.RecalculateChecked();
+ GetRoot().OnTreeSelectionChanged?.Invoke();
+ }
+ }
+ }
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ if (_isExpanded != value)
+ {
+ _isExpanded = value;
+ PropertyChanged?.Invoke(this, new(nameof(IsExpanded)));
+ }
+ }
+ }
+
+ private void SetChildrenChecked(bool value)
+ {
+ foreach (var child in Children)
+ {
+ child._suppressEvents = true;
+ child._isChecked = value;
+ child.PropertyChanged?.Invoke(child, new(nameof(IsChecked)));
+ child._suppressEvents = false;
+ child.SetChildrenChecked(value);
+ }
+ }
+
+ private void RecalculateChecked()
+ {
+ if (Children.Count == 0) return;
+
+ var allChecked = Children.All(c => c.IsChecked == true);
+ var allUnchecked = Children.All(c => c.IsChecked == false);
+ bool? newState = allChecked ? true : allUnchecked ? false : null;
+
+ if (_isChecked != newState)
+ {
+ _suppressEvents = true;
+ _isChecked = newState;
+ PropertyChanged?.Invoke(this, new(nameof(IsChecked)));
+ _suppressEvents = false;
+ Parent?.RecalculateChecked();
+ }
+ }
+
+ private CheckableTreeNode GetRoot()
+ {
+ var node = this;
+ while (node.Parent != null) node = node.Parent;
+ return node;
+ }
+
+ public IEnumerable GetCheckedLeaves()
+ {
+ if (Children.Count == 0 && IsChecked == true)
+ {
+ yield return this;
+ yield break;
+ }
+
+ foreach (var child in Children)
+ foreach (var leaf in child.GetCheckedLeaves())
+ yield return leaf;
+ }
+
+ public int CountCheckedLeaves()
+ {
+ if (Children.Count == 0)
+ return IsChecked == true ? 1 : 0;
+
+ return Children.Sum(c => c.CountCheckedLeaves());
+ }
+
+ public int CountTotalLeaves()
+ {
+ if (Children.Count == 0)
+ return 1;
+
+ return Children.Sum(c => c.CountTotalLeaves());
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public static CheckableTreeNode BuildFileTree(IEnumerable<(string fullPath, string relativePath)> files)
+ {
+ var root = new CheckableTreeNode { Name = "Root", IsExpanded = true };
+
+ foreach (var (fullPath, relativePath) in files)
+ {
+ var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var current = root;
+
+ for (int i = 0; i < parts.Length; i++)
+ {
+ var part = parts[i];
+ var existing = current.Children.FirstOrDefault(
+ c => c.Name.Equals(part, StringComparison.OrdinalIgnoreCase));
+
+ if (existing != null)
+ current = existing;
+ else
+ {
+ var node = new CheckableTreeNode
+ {
+ Name = part,
+ Parent = current,
+ FullPath = i == parts.Length - 1 ? fullPath : "",
+ IsExpanded = i < 2
+ };
+
+ current.Children.Add(node);
+ current = node;
+ }
+ }
+ }
+
+ return root;
+ }
+
+ public static CheckableTreeNode BuildRegistryTree(IList entries)
+ {
+ var root = new CheckableTreeNode { Name = "Registry", IsExpanded = true };
+
+ // Deduplicate by (KeyPath, ValueName), keeping one representative entry per unique pair
+ var deduped = entries
+ .Select((entry, index) => (entry, index))
+ .GroupBy(x => (
+ key: x.entry.KeyPath.ToLowerInvariant(),
+ value: x.entry.ValueName.ToLowerInvariant()))
+ .Select(g =>
+ {
+ var isCreate = g.Any(x =>
+ x.entry.Verb.Equals("REG CREATE", StringComparison.OrdinalIgnoreCase));
+ var first = g.First();
+ return (entry: first.entry, index: first.index, isCreate);
+ })
+ .ToList();
+
+ foreach (var (entry, index, isCreate) in deduped)
+ {
+ var keyParts = entry.KeyPath.Split('\\');
+ var current = root;
+
+ foreach (var part in keyParts)
+ {
+ if (string.IsNullOrEmpty(part))
+ continue;
+
+ var existing = current.Children.FirstOrDefault(
+ c => c.Children.Count > 0 &&
+ c.Name.Equals(part, StringComparison.OrdinalIgnoreCase));
+
+ if (existing != null)
+ current = existing;
+ else
+ {
+ var node = new CheckableTreeNode
+ {
+ Name = part,
+ Parent = current,
+ IsExpanded = true
+ };
+
+ current.Children.Add(node);
+ current = node;
+ }
+ }
+
+ var valueName = string.IsNullOrEmpty(entry.ValueName) ? "(Default)" : entry.ValueName;
+ var leaf = new CheckableTreeNode
+ {
+ Name = valueName,
+ Parent = current,
+ SourceIndex = index,
+ Indicator = isCreate ? "+" : "~"
+ };
+
+ current.Children.Add(leaf);
+ }
+
+ return root;
+ }
+}
diff --git a/LANCommander.Packager/Models/FileChangeEntry.cs b/LANCommander.Packager/Models/FileChangeEntry.cs
new file mode 100644
index 00000000..a3afe15c
--- /dev/null
+++ b/LANCommander.Packager/Models/FileChangeEntry.cs
@@ -0,0 +1,7 @@
+namespace LANCommander.Packager.Models;
+
+public class FileChangeEntry
+{
+ public string Verb { get; set; } = string.Empty;
+ public string Path { get; set; } = string.Empty;
+}
diff --git a/LANCommander.Packager/Models/GeneratedScript.cs b/LANCommander.Packager/Models/GeneratedScript.cs
new file mode 100644
index 00000000..10502448
--- /dev/null
+++ b/LANCommander.Packager/Models/GeneratedScript.cs
@@ -0,0 +1,11 @@
+using LANCommander.SDK.Enums;
+
+namespace LANCommander.Packager.Models;
+
+public class GeneratedScript
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public ScriptType Type { get; set; }
+ public string Contents { get; set; } = string.Empty;
+ public bool RequiresAdmin { get; set; }
+}
diff --git a/LANCommander.Packager/Models/PackageContext.cs b/LANCommander.Packager/Models/PackageContext.cs
new file mode 100644
index 00000000..9a6bd15e
--- /dev/null
+++ b/LANCommander.Packager/Models/PackageContext.cs
@@ -0,0 +1,21 @@
+using System.IO.Compression;
+using LANCommander.SDK.Models.Manifest;
+
+namespace LANCommander.Packager.Models;
+
+public class PackageContext
+{
+ public string InstallerPath { get; set; } = string.Empty;
+ public string InstallDirectory { get; set; } = string.Empty;
+ public List FileChanges { get; set; } = new();
+ public List RegistryChanges { get; set; } = new();
+ public List SelectedFiles { get; set; } = new();
+ public List SelectedRegistryEntries { get; set; } = new();
+ public Game Manifest { get; set; } = new();
+ public string OutputPath { get; set; } = string.Empty;
+
+ // Options
+ public bool PatchGameSpy { get; set; }
+ public CompressionLevel CompressionLevel { get; set; } = CompressionLevel.Optimal;
+ public bool WriteSummaryLog { get; set; }
+}
diff --git a/LANCommander.Packager/Models/RegistryChangeEntry.cs b/LANCommander.Packager/Models/RegistryChangeEntry.cs
new file mode 100644
index 00000000..5be00d8a
--- /dev/null
+++ b/LANCommander.Packager/Models/RegistryChangeEntry.cs
@@ -0,0 +1,8 @@
+namespace LANCommander.Packager.Models;
+
+public class RegistryChangeEntry
+{
+ public string Verb { get; set; } = string.Empty;
+ public string KeyPath { get; set; } = string.Empty;
+ public string ValueName { get; set; } = string.Empty;
+}
diff --git a/LANCommander.Packager/Models/WizardStepItem.cs b/LANCommander.Packager/Models/WizardStepItem.cs
new file mode 100644
index 00000000..d63a0a43
--- /dev/null
+++ b/LANCommander.Packager/Models/WizardStepItem.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel;
+
+namespace LANCommander.Packager.Models;
+
+public enum StepState { Pending, Current, Completed }
+
+public class WizardStepItem : INotifyPropertyChanged
+{
+ private StepState _state = StepState.Pending;
+
+ public int Index { get; init; }
+ public string Title { get; init; } = "";
+ public bool ShowTopLine => Index > 0;
+ public bool ShowBottomLine => Index < 6;
+
+ public StepState State
+ {
+ get => _state;
+ set
+ {
+ if (_state != value)
+ {
+ _state = value;
+
+ PropertyChanged?.Invoke(this, new(nameof(State)));
+ PropertyChanged?.Invoke(this, new(nameof(IsCurrent)));
+ PropertyChanged?.Invoke(this, new(nameof(IsCompleted)));
+ PropertyChanged?.Invoke(this, new(nameof(IsPending)));
+ PropertyChanged?.Invoke(this, new(nameof(TextOpacity)));
+ }
+ }
+ }
+
+ public bool IsCurrent => State == StepState.Current;
+ public bool IsCompleted => State == StepState.Completed;
+ public bool IsPending => State == StepState.Pending;
+
+ public double TextOpacity => State switch
+ {
+ StepState.Current => 1.0,
+ StepState.Completed => 0.7,
+ _ => 0.4
+ };
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+}
diff --git a/LANCommander.Packager/Options.cs b/LANCommander.Packager/Options.cs
new file mode 100644
index 00000000..fe2c660b
--- /dev/null
+++ b/LANCommander.Packager/Options.cs
@@ -0,0 +1,12 @@
+using CommandLine;
+
+namespace LANCommander.Packager;
+
+public class Options
+{
+ [Value(0, MetaName = "installer", HelpText = "Path to installer executable")]
+ public string? InstallerPath { get; set; }
+
+ [Option('o', "output", HelpText = "Output .lcx file path")]
+ public string? OutputPath { get; set; }
+}
diff --git a/LANCommander.Packager/Program.cs b/LANCommander.Packager/Program.cs
new file mode 100644
index 00000000..11574596
--- /dev/null
+++ b/LANCommander.Packager/Program.cs
@@ -0,0 +1,32 @@
+using Avalonia;
+using CommandLine;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager;
+
+internal static class Program
+{
+ public static PackageContext Context { get; } = new();
+
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ Parser.Default.ParseArguments(args)
+ .WithParsed(options =>
+ {
+ if (!string.IsNullOrWhiteSpace(options.InstallerPath))
+ Context.InstallerPath = options.InstallerPath;
+
+ if (!string.IsNullOrWhiteSpace(options.OutputPath))
+ Context.OutputPath = options.OutputPath;
+ });
+
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/LANCommander.Packager/Services/InstallerMonitorService.cs b/LANCommander.Packager/Services/InstallerMonitorService.cs
new file mode 100644
index 00000000..ca6f95e8
--- /dev/null
+++ b/LANCommander.Packager/Services/InstallerMonitorService.cs
@@ -0,0 +1,400 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using LANCommander.Interposer;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager.Services;
+
+public class InstallerMonitorService : IDisposable
+{
+ private readonly List _interposers = new();
+ private readonly ConcurrentDictionary _injectedPids = new();
+
+ private readonly ConcurrentDictionary _fileChanges = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentBag _registryChanges = new();
+
+ private CancellationTokenSource? _childMonitorCts;
+
+ private static readonly string[] IgnoredPathPrefixes = new[]
+ {
+ Environment.GetFolderPath(Environment.SpecialFolder.Windows),
+ Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar),
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Temp",
+ };
+
+ private static readonly string[] WriteVerbs =
+ {
+ "FILE WRITE",
+ "FILE R/W",
+ "FILE COPY",
+ "FILE MOVE"
+ };
+
+ private static readonly string[] RegistryWriteVerbs =
+ {
+ "REG WRITE",
+ "REG CREATE"
+ };
+
+ public IReadOnlyCollection FileChanges => _fileChanges.Values.ToList();
+ public IReadOnlyCollection RegistryChanges => _registryChanges.ToList();
+
+ public event Action? OnFileChange;
+ public event Action? OnRegistryChange;
+ public event Action? OnInstallerExited;
+
+ public int FileChangeCount => _fileChanges.Count;
+ public int RegistryChangeCount => _registryChanges.Count;
+
+ private Action? _log;
+
+ public string LaunchInstaller(string installerPath, Action? log = null)
+ {
+ _log = log;
+
+ installerPath = Path.GetFullPath(installerPath);
+
+ if (!File.Exists(installerPath))
+ throw new FileNotFoundException($"Installer not found: {installerPath}");
+
+ var dllPath = ResolveNativeDllForTarget(installerPath);
+
+ log?.Invoke($"Native DLL: {dllPath}");
+
+ var interposer = CreateInterposer(dllPath, log);
+
+ var psi = new ProcessStartInfo(installerPath)
+ {
+ UseShellExecute = false,
+ WorkingDirectory = Path.GetDirectoryName(installerPath)
+ };
+
+ var process = interposer.Start(psi, dllPath);
+ _injectedPids.TryAdd(process.Id, true);
+
+ log?.Invoke($"Interposer injected (PID {process.Id}).");
+
+ process.EnableRaisingEvents = true;
+ process.Exited += (_, _) =>
+ {
+ // Give child processes a moment to finish
+ Thread.Sleep(1000);
+ _childMonitorCts?.Cancel();
+ OnInstallerExited?.Invoke();
+ };
+
+ // Start monitoring for child processes
+ _childMonitorCts = new CancellationTokenSource();
+
+ Task.Run(() => MonitorChildProcesses(process.Id, dllPath, _childMonitorCts.Token));
+
+ return "live";
+ }
+
+ private InterposerService CreateInterposer(string? dllPath, Action? log)
+ {
+ var interposer = new InterposerService();
+ _interposers.Add(interposer);
+
+ interposer.PipeDiagnostic += (sender, msg) => log?.Invoke(msg);
+
+ interposer.FileAccessed += (sender, e) =>
+ {
+ if (string.IsNullOrEmpty(e.Path))
+ return;
+
+ log?.Invoke($"[FILE] {e.Verb}: {e.Path}");
+
+ if (!IsWriteVerb(e.Verb) || IsIgnoredPath(e.Path))
+ return;
+
+ var entry = new FileChangeEntry { Verb = e.Verb, Path = e.Path };
+
+ _fileChanges.AddOrUpdate(e.Path, entry, (_, _) => entry);
+ OnFileChange?.Invoke(entry);
+ };
+
+ interposer.RegistryAccessed += (sender, e) =>
+ {
+ if (string.IsNullOrEmpty(e.KeyPath))
+ return;
+
+ log?.Invoke($"[REG] {e.Verb}: {e.KeyPath}");
+
+ if (!IsRegistryWriteVerb(e.Verb))
+ return;
+
+ var entry = new RegistryChangeEntry
+ {
+ Verb = e.Verb,
+ KeyPath = e.KeyPath,
+ ValueName = e.ValueName ?? string.Empty
+ };
+
+ _registryChanges.Add(entry);
+ OnRegistryChange?.Invoke(entry);
+ };
+
+ return interposer;
+ }
+
+ private async Task MonitorChildProcesses(int parentPid, string? dllPath, CancellationToken ct)
+ {
+ _log?.Invoke("Monitoring for child processes...");
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(500, ct);
+
+ var children = GetChildProcessIds(parentPid);
+
+ foreach (var childPid in children)
+ {
+ if (_injectedPids.TryAdd(childPid, true))
+ {
+ try
+ {
+ _log?.Invoke($"Injecting into child process PID {childPid}...");
+ var childInterposer = CreateInterposer(dllPath, _log);
+ childInterposer.Inject(childPid, dllPath);
+ _log?.Invoke($"Injected into child PID {childPid}.");
+
+ // Also monitor grandchildren
+ _ = Task.Run(() => MonitorChildProcesses(childPid, dllPath, ct));
+ }
+ catch (Exception ex)
+ {
+ _log?.Invoke($"Failed to inject into PID {childPid}: {ex.Message}");
+ }
+ }
+ }
+ }
+ catch (TaskCanceledException) { break; }
+ catch (Exception ex)
+ {
+ _log?.Invoke($"Child monitor error: {ex.Message}");
+ }
+ }
+ }
+
+ #region Child process detection via toolhelp32
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID);
+
+ [DllImport("kernel32.dll")]
+ private static extern bool Process32First(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
+
+ [DllImport("kernel32.dll")]
+ private static extern bool Process32Next(IntPtr hSnapshot, ref PROCESSENTRY32 lppe);
+
+ [DllImport("kernel32.dll")]
+ private static extern bool CloseHandle(IntPtr hObject);
+
+ private const uint TH32CS_SNAPPROCESS = 0x00000002;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+ private struct PROCESSENTRY32
+ {
+ public uint dwSize;
+ public uint cntUsage;
+ public uint th32ProcessID;
+ public IntPtr th32DefaultHeapID;
+ public uint th32ModuleID;
+ public uint cntThreads;
+ public uint th32ParentProcessID;
+ public int pcPriClassBase;
+ public uint dwFlags;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+ public string szExeFile;
+ }
+
+ private static List GetChildProcessIds(int parentPid)
+ {
+ var children = new List();
+ var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+
+ if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1))
+ return children;
+
+ try
+ {
+ var entry = new PROCESSENTRY32 { dwSize = (uint)Marshal.SizeOf() };
+
+ if (Process32First(snapshot, ref entry))
+ {
+ do
+ {
+ if (entry.th32ParentProcessID == (uint)parentPid)
+ children.Add((int)entry.th32ProcessID);
+ }
+ while (Process32Next(snapshot, ref entry));
+ }
+ }
+ finally
+ {
+ CloseHandle(snapshot);
+ }
+
+ return children;
+ }
+
+ #endregion
+
+ #region Install directory detection
+
+ public string DetectInstallDirectory()
+ {
+ var filePaths = _fileChanges.Keys
+ .Where(p => !string.IsNullOrWhiteSpace(p) && Path.IsPathRooted(p))
+ .Select(p => Path.GetDirectoryName(p))
+ .Where(p => p != null)
+ .Cast()
+ .ToList();
+
+ if (filePaths.Count == 0)
+ return string.Empty;
+
+ var directoryGroups = filePaths
+ .GroupBy(p => p, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(g => g.Count())
+ .ToList();
+
+ foreach (var group in directoryGroups)
+ {
+ var dir = group.Key;
+
+ if (!IsIgnoredPath(dir) && !IsSystemPath(dir))
+ return dir;
+ }
+
+ var nonSystemPaths = filePaths.Where(p => !IsIgnoredPath(p) && !IsSystemPath(p)).ToList();
+
+ if (nonSystemPaths.Count > 0)
+ return FindCommonAncestor(nonSystemPaths);
+
+ return filePaths.First();
+ }
+
+ private static string FindCommonAncestor(List paths)
+ {
+ if (paths.Count == 0)
+ return string.Empty;
+
+ if (paths.Count == 1)
+ return paths[0];
+
+ var splits = paths.Select(p => p.Split(Path.DirectorySeparatorChar)).ToList();
+ var minLength = splits.Min(s => s.Length);
+ var common = new List();
+
+ for (int i = 0; i < minLength; i++)
+ {
+ var segment = splits[0][i];
+
+ if (splits.All(s => s[i].Equals(segment, StringComparison.OrdinalIgnoreCase)))
+ common.Add(segment);
+ else
+ break;
+ }
+
+ return string.Join(Path.DirectorySeparatorChar, common);
+ }
+
+ #endregion
+
+ #region Architecture detection
+
+ private static string? ResolveNativeDllForTarget(string exePath)
+ {
+ bool targetIs64 = IsPE64Bit(exePath);
+ var baseDir = AppDomain.CurrentDomain.BaseDirectory;
+
+ var candidates = targetIs64
+ ? new[]
+ {
+ Path.Combine(baseDir, "runtimes", "win-x64", "native", "LANCommander.Interposer.dll"),
+ Path.Combine(baseDir, "LANCommander.Interposer.dll"),
+ }
+ : new[]
+ {
+ Path.Combine(baseDir, "runtimes", "win-x86", "native", "LANCommander.Interposer.dll"),
+ };
+
+ foreach (var candidate in candidates)
+ {
+ var resolved = Path.GetFullPath(candidate);
+
+ if (File.Exists(resolved))
+ return resolved;
+ }
+
+ return null;
+ }
+
+ private static bool IsPE64Bit(string exePath)
+ {
+ try
+ {
+ using var fs = File.OpenRead(exePath);
+ using var reader = new BinaryReader(fs);
+
+ fs.Seek(0x3C, SeekOrigin.Begin);
+ int peOffset = reader.ReadInt32();
+
+ fs.Seek(peOffset, SeekOrigin.Begin);
+ uint peSignature = reader.ReadUInt32();
+ if (peSignature != 0x00004550)
+ return Environment.Is64BitProcess;
+
+ ushort machine = reader.ReadUInt16();
+ return machine == 0x8664 || machine == 0xAA64;
+ }
+ catch
+ {
+ return Environment.Is64BitProcess;
+ }
+ }
+
+ #endregion
+
+ #region Verb/path filters
+
+ private static bool IsWriteVerb(string verb)
+ => WriteVerbs.Any(v => verb.Equals(v, StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsRegistryWriteVerb(string verb)
+ => RegistryWriteVerbs.Any(v => verb.Equals(v, StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsIgnoredPath(string path)
+ => IgnoredPathPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsSystemPath(string path)
+ {
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+
+ if (path.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ return path.StartsWith(systemRoot, StringComparison.OrdinalIgnoreCase);
+ }
+
+ #endregion
+
+ public void Dispose()
+ {
+ _childMonitorCts?.Cancel();
+ _childMonitorCts?.Dispose();
+
+ foreach (var interposer in _interposers)
+ interposer.Dispose();
+
+ _interposers.Clear();
+ }
+}
diff --git a/LANCommander.Packager/Services/LcxBuilderService.cs b/LANCommander.Packager/Services/LcxBuilderService.cs
new file mode 100644
index 00000000..233cebbe
--- /dev/null
+++ b/LANCommander.Packager/Services/LcxBuilderService.cs
@@ -0,0 +1,118 @@
+using System.IO.Compression;
+using LANCommander.Packager.Models;
+using LANCommander.SDK.Helpers;
+using LANCommander.SDK.Enums;
+
+namespace LANCommander.Packager.Services;
+
+public static class LcxBuilderService
+{
+ public static async Task BuildAsync(PackageContext context, IProgress? progress = null)
+ {
+ using var outputStream = File.Create(context.OutputPath);
+ using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create);
+
+ // 1. Create inner game files archive
+ progress?.Report("Creating game files archive...");
+
+ var archiveId = Guid.NewGuid();
+
+ long compressedSize = 0;
+ long uncompressedSize = 0;
+
+ var archiveZipEntry = archive.CreateEntry($"Archives/{archiveId}", context.CompressionLevel);
+
+ using (var archiveEntryStream = archiveZipEntry.Open())
+ using (var innerArchive = new ZipArchive(archiveEntryStream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var filePath in context.SelectedFiles)
+ {
+ if (!File.Exists(filePath))
+ continue;
+
+ var relativePath = Path.GetRelativePath(context.InstallDirectory, filePath);
+ var entry = innerArchive.CreateEntry(relativePath, context.CompressionLevel);
+
+ using var entryStream = entry.Open();
+ using var fileStream = File.OpenRead(filePath);
+
+ uncompressedSize += fileStream.Length;
+
+ await fileStream.CopyToAsync(entryStream);
+ }
+ }
+
+ // Get the compressed size from the output stream position delta
+ compressedSize = outputStream.Position;
+
+ // 2. Generate and write scripts
+ progress?.Report("Generating scripts...");
+
+ var scripts = ScriptGeneratorService.Generate(context);
+
+ foreach (var script in scripts)
+ {
+ var scriptEntry = archive.CreateEntry($"Scripts/{script.Id}", CompressionLevel.NoCompression);
+
+ using var scriptStream = scriptEntry.Open();
+ using var writer = new StreamWriter(scriptStream);
+
+ await writer.WriteAsync(script.Contents);
+ }
+
+ // 3. Populate manifest
+ progress?.Report("Writing manifest...");
+
+ var manifest = context.Manifest;
+
+ manifest.Id = manifest.Id == Guid.Empty ? Guid.NewGuid() : manifest.Id;
+ manifest.ManifestVersion = "1.0.0";
+ manifest.CreatedOn = DateTime.UtcNow;
+ manifest.CreatedBy = "LANCommander.Packager";
+ manifest.UpdatedOn = DateTime.UtcNow;
+ manifest.UpdatedBy = "LANCommander.Packager";
+
+ manifest.Archives.Add(new SDK.Models.Manifest.Archive
+ {
+ Id = archiveId,
+ ObjectKey = archiveId.ToString(),
+ Version = manifest.Version ?? "1.0",
+ CompressedSize = compressedSize,
+ UncompressedSize = uncompressedSize,
+ CreatedOn = DateTime.UtcNow,
+ CreatedBy = "LANCommander.Packager"
+ });
+
+ foreach (var script in scripts)
+ {
+ manifest.Scripts.Add(new SDK.Models.Manifest.Script
+ {
+ Id = script.Id,
+ Type = script.Type,
+ Name = script.Type.ToString(),
+ RequiresAdmin = script.RequiresAdmin,
+ CreatedOn = DateTime.UtcNow,
+ CreatedBy = "LANCommander.Packager"
+ });
+ }
+
+ // 4. Serialize manifest to YAML and write to archive
+ var yaml = ManifestHelper.Serialize(manifest);
+
+ var manifestEntry = archive.CreateEntry(ManifestHelper.ManifestFilename, CompressionLevel.NoCompression);
+
+ using (var ms = new MemoryStream())
+ using (var writer = new StreamWriter(ms))
+ {
+ await writer.WriteAsync(yaml);
+ await writer.FlushAsync();
+
+ using var entryStream = manifestEntry.Open();
+
+ ms.Seek(0, SeekOrigin.Begin);
+ await ms.CopyToAsync(entryStream);
+ }
+
+ progress?.Report("Done!");
+ }
+}
diff --git a/LANCommander.Packager/Services/ScriptGeneratorService.cs b/LANCommander.Packager/Services/ScriptGeneratorService.cs
new file mode 100644
index 00000000..5cf58998
--- /dev/null
+++ b/LANCommander.Packager/Services/ScriptGeneratorService.cs
@@ -0,0 +1,127 @@
+using System.Text;
+using LANCommander.Packager.Models;
+using LANCommander.SDK.Enums;
+
+namespace LANCommander.Packager.Services;
+
+public static class ScriptGeneratorService
+{
+ public static List Generate(PackageContext context)
+ {
+ var scripts = new List();
+
+ bool hasRegistry = context.SelectedRegistryEntries.Count > 0;
+ bool hasInstallActions = hasRegistry || context.PatchGameSpy;
+
+ if (hasInstallActions)
+ scripts.Add(GenerateInstallScript(context));
+
+ if (hasRegistry)
+ scripts.Add(GenerateUninstallScript(context));
+
+ return scripts;
+ }
+
+ private static GeneratedScript GenerateInstallScript(PackageContext context)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("# Install Script - Generated by LANCommander.Packager");
+ sb.AppendLine();
+
+ bool requiresAdmin = false;
+
+ if (context.SelectedRegistryEntries.Count > 0)
+ {
+ var groupedEntries = context.SelectedRegistryEntries
+ .GroupBy(e => e.KeyPath, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in groupedEntries)
+ {
+ var keyPath = ConvertToPoShRegistryPath(group.Key);
+
+ if (group.Key.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase) ||
+ group.Key.StartsWith("HKEY_LOCAL_MACHINE", StringComparison.OrdinalIgnoreCase))
+ requiresAdmin = true;
+
+ sb.AppendLine($"New-Item -Path \"{keyPath}\" -Force | Out-Null");
+
+ foreach (var entry in group.Where(e => !string.IsNullOrEmpty(e.ValueName)))
+ {
+ var valueName = entry.ValueName;
+ sb.AppendLine($"Set-ItemProperty -Path \"{keyPath}\" -Name \"{valueName}\" -Value \"\"");
+ }
+
+ sb.AppendLine();
+ }
+ }
+
+ if (context.PatchGameSpy)
+ {
+ sb.AppendLine("# Patch GameSpy references for OpenSpy compatibility");
+ sb.AppendLine("Edit-PatchGameSpy -Path $InstallDirectory");
+ sb.AppendLine();
+ }
+
+ return new GeneratedScript
+ {
+ Type = ScriptType.Install,
+ Contents = sb.ToString(),
+ RequiresAdmin = requiresAdmin
+ };
+ }
+
+ private static GeneratedScript GenerateUninstallScript(PackageContext context)
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine("# Uninstall Script - Generated by LANCommander.Packager");
+ sb.AppendLine();
+
+ var groupedEntries = context.SelectedRegistryEntries
+ .GroupBy(e => e.KeyPath, StringComparer.OrdinalIgnoreCase);
+
+ bool requiresAdmin = false;
+
+ foreach (var group in groupedEntries)
+ {
+ var keyPath = ConvertToPoShRegistryPath(group.Key);
+
+ if (group.Key.StartsWith("HKLM", StringComparison.OrdinalIgnoreCase) ||
+ group.Key.StartsWith("HKEY_LOCAL_MACHINE", StringComparison.OrdinalIgnoreCase))
+ requiresAdmin = true;
+
+ foreach (var entry in group.Where(e => !string.IsNullOrEmpty(e.ValueName)))
+ sb.AppendLine($"Remove-ItemProperty -Path \"{keyPath}\" -Name \"{entry.ValueName}\" -ErrorAction SilentlyContinue");
+
+ sb.AppendLine($"Remove-Item -Path \"{keyPath}\" -ErrorAction SilentlyContinue");
+ sb.AppendLine();
+ }
+
+ return new GeneratedScript
+ {
+ Type = ScriptType.Uninstall,
+ Contents = sb.ToString(),
+ RequiresAdmin = requiresAdmin
+ };
+ }
+
+ private static string ConvertToPoShRegistryPath(string keyPath)
+ {
+ if (keyPath.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
+ return "HKLM:\\" + keyPath["HKEY_LOCAL_MACHINE\\".Length..];
+
+ if (keyPath.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
+ return "HKCU:\\" + keyPath["HKEY_CURRENT_USER\\".Length..];
+
+ if (keyPath.StartsWith("HKEY_CLASSES_ROOT\\", StringComparison.OrdinalIgnoreCase))
+ return "HKCR:\\" + keyPath["HKEY_CLASSES_ROOT\\".Length..];
+
+ if (keyPath.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase))
+ return "HKLM:\\" + keyPath["HKLM\\".Length..];
+
+ if (keyPath.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase))
+ return "HKCU:\\" + keyPath["HKCU\\".Length..];
+
+ return keyPath;
+ }
+}
diff --git a/LANCommander.Packager/Theme/ColorPalette.axaml b/LANCommander.Packager/Theme/ColorPalette.axaml
new file mode 100644
index 00000000..f558ea8b
--- /dev/null
+++ b/LANCommander.Packager/Theme/ColorPalette.axaml
@@ -0,0 +1,144 @@
+
+
+ #E6F4FF
+ #BAE0FF
+ #91CAFF
+ #69B1FF
+ #4096FF
+ #1677FF
+ #0958D9
+ #003EB3
+ #002C8C
+ #001D66
+
+
+ #F6FFED
+ #D9F7BE
+ #B7EB8F
+ #95DE64
+ #73D13D
+ #52C41A
+ #389E0D
+ #237804
+ #135200
+ #092B00
+
+
+ #FFFBE6
+ #FFF1B8
+ #FFE58F
+ #FFD666
+ #FFC53D
+ #FAAD14
+ #D48806
+ #AD6800
+ #874D00
+ #613400
+
+
+ #FFF2F0
+ #FFF1F0
+ #FFCCC7
+ #FFA39E
+ #FF7875
+ #FF4D4F
+ #CF1322
+ #A8071A
+ #820014
+ #5C0011
+
+
+ #000000
+ #141414
+ #1F1F1F
+ #282828
+ #303030
+ #3A3A3A
+ #424242
+ #595959
+ #8C8C8C
+ #BFBFBF
+ #D9D9D9
+ #F0F0F0
+ #FFFFFF
+
+
+ #1677FF
+ #49AA19
+ #D89614
+ #DC4446
+
+ #D9FFFFFF
+ #A6FFFFFF
+ #40FFFFFF
+
+ #141414
+ #000000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #177ddc
+ #095cb5
+ #0958D9
+ #FFFFFF
+
+
+ #49AA19
+ #6ABF40
+ #338F0F
+ #FFFFFF
+
+
+ #14FFFFFF
+ #40FFFFFF
+
+
+ #0FFFFFFF
+ #424242
+ #4096FF
+ #1677FF
+ #D9FFFFFF
+ #40FFFFFF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Theme/Packager.axaml b/LANCommander.Packager/Theme/Packager.axaml
new file mode 100644
index 00000000..001732b5
--- /dev/null
+++ b/LANCommander.Packager/Theme/Packager.axaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/ActionView.axaml b/LANCommander.Packager/Views/ActionView.axaml
new file mode 100644
index 00000000..3666e2c7
--- /dev/null
+++ b/LANCommander.Packager/Views/ActionView.axaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/ActionView.axaml.cs b/LANCommander.Packager/Views/ActionView.axaml.cs
new file mode 100644
index 00000000..96238b4e
--- /dev/null
+++ b/LANCommander.Packager/Views/ActionView.axaml.cs
@@ -0,0 +1,85 @@
+using System.Collections.ObjectModel;
+using Avalonia.Controls;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager.Views;
+
+public partial class ActionView : UserControl
+{
+ private readonly PackageContext _context;
+ private readonly ObservableCollection _exeFiles = new();
+
+ public ActionView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+ ExeList.ItemsSource = _exeFiles;
+ }
+
+ public void PopulateExecutables()
+ {
+ _exeFiles.Clear();
+
+ var exes = _context.SelectedFiles
+ .Where(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ .Select(f => Path.GetRelativePath(_context.InstallDirectory, f))
+ .Where(f => !IsInstallerExecutable(f))
+ .OrderBy(f => f)
+ .ToList();
+
+ foreach (var exe in exes)
+ _exeFiles.Add(exe);
+
+ if (_exeFiles.Count > 0)
+ {
+ var bestGuess = FindBestExecutable();
+ ExeList.SelectedIndex = bestGuess;
+ }
+ }
+
+ public void ApplyAction()
+ {
+ var selected = ExeList.SelectedIndex;
+
+ if (selected < 0 || selected >= _exeFiles.Count)
+ return;
+
+ var selectedExe = _exeFiles[selected];
+
+ var action = new SDK.Models.Manifest.Action
+ {
+ Name = ActionNameField.Text ?? "Play",
+ Path = selectedExe,
+ Arguments = ArgumentsField.Text ?? string.Empty,
+ IsPrimaryAction = true,
+ SortOrder = 0,
+ CreatedOn = DateTime.UtcNow,
+ CreatedBy = "LANCommander.Packager"
+ };
+
+ _context.Manifest.Actions.Add(action);
+ }
+
+ private int FindBestExecutable()
+ {
+ var noisePatterns = new[] { "redist", "setup", "install", "unins", "directx", "vcredist", "dxsetup", "dotnet" };
+
+ for (int i = 0; i < _exeFiles.Count; i++)
+ {
+ var lower = _exeFiles[i].ToLowerInvariant();
+
+ if (!noisePatterns.Any(p => lower.Contains(p)))
+ return i;
+ }
+
+ return 0;
+ }
+
+ private static bool IsInstallerExecutable(string relativePath)
+ {
+ var name = Path.GetFileNameWithoutExtension(relativePath).ToLowerInvariant();
+ var patterns = new[] { "unins", "setup", "install", "vcredist", "dxsetup", "dotnetfx" };
+
+ return patterns.Any(p => name.Contains(p));
+ }
+}
diff --git a/LANCommander.Packager/Views/FileSelectionView.axaml b/LANCommander.Packager/Views/FileSelectionView.axaml
new file mode 100644
index 00000000..8dbe0cc8
--- /dev/null
+++ b/LANCommander.Packager/Views/FileSelectionView.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/FileSelectionView.axaml.cs b/LANCommander.Packager/Views/FileSelectionView.axaml.cs
new file mode 100644
index 00000000..88d32bda
--- /dev/null
+++ b/LANCommander.Packager/Views/FileSelectionView.axaml.cs
@@ -0,0 +1,74 @@
+using Avalonia.Controls;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager.Views;
+
+public partial class FileSelectionView : UserControl
+{
+ private readonly PackageContext _context;
+ private CheckableTreeNode _root = new();
+
+ public FileSelectionView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+
+ SelectAllButton.Click += (_, _) => { _root.IsChecked = true; UpdateCount(); };
+ SelectNoneButton.Click += (_, _) => { _root.IsChecked = false; UpdateCount(); };
+ }
+
+ public void PopulateFiles()
+ {
+ var installDir = _context.InstallDirectory;
+
+ List<(string fullPath, string relativePath)> files;
+
+ if (string.IsNullOrEmpty(installDir) || !Directory.Exists(installDir))
+ {
+ files = _context.FileChanges
+ .Select(f => (fullPath: f.Path, relativePath: f.Path))
+ .Where(f => File.Exists(f.fullPath))
+ .DistinctBy(f => f.fullPath.ToLowerInvariant())
+ .OrderBy(f => f.relativePath)
+ .ToList();
+ }
+ else
+ {
+ try
+ {
+ files = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories)
+ .Select(f => (fullPath: f, relativePath: Path.GetRelativePath(installDir, f)))
+ .OrderBy(f => f.relativePath)
+ .ToList();
+ }
+ catch
+ {
+ files = [];
+ }
+ }
+
+ _root = CheckableTreeNode.BuildFileTree(files);
+ _root.OnTreeSelectionChanged = UpdateCount;
+ FileTree.ItemsSource = _root.Children;
+ UpdateCount();
+ }
+
+ public void ApplySelection()
+ {
+ _context.SelectedFiles.Clear();
+
+ foreach (var leaf in _root.GetCheckedLeaves())
+ {
+ if (!string.IsNullOrEmpty(leaf.FullPath))
+ _context.SelectedFiles.Add(leaf.FullPath);
+ }
+ }
+
+ private void UpdateCount()
+ {
+ var selected = _root.CountCheckedLeaves();
+ var total = _root.CountTotalLeaves();
+
+ CountLabel.Text = $"{selected} / {total} files selected";
+ }
+}
diff --git a/LANCommander.Packager/Views/InstallDirectoryView.axaml b/LANCommander.Packager/Views/InstallDirectoryView.axaml
new file mode 100644
index 00000000..db2f911f
--- /dev/null
+++ b/LANCommander.Packager/Views/InstallDirectoryView.axaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/InstallDirectoryView.axaml.cs b/LANCommander.Packager/Views/InstallDirectoryView.axaml.cs
new file mode 100644
index 00000000..902e15e8
--- /dev/null
+++ b/LANCommander.Packager/Views/InstallDirectoryView.axaml.cs
@@ -0,0 +1,50 @@
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using LANCommander.Packager.Models;
+using LANCommander.Packager.Services;
+
+namespace LANCommander.Packager.Views;
+
+public partial class InstallDirectoryView : UserControl
+{
+ private readonly PackageContext _context;
+
+ public InstallDirectoryView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+
+ BrowseButton.Click += async (s, e) =>
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+
+ if (topLevel == null)
+ return;
+
+ var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select Install Directory",
+ AllowMultiple = false
+ });
+
+ if (folders.Count > 0)
+ DirectoryField.Text = folders[0].Path.LocalPath;
+ };
+ }
+
+ public void PopulateFromMonitor(InstallerMonitorService? monitor)
+ {
+ if (monitor != null)
+ {
+ var detected = monitor.DetectInstallDirectory();
+
+ DirectoryField.Text = detected;
+ _context.InstallDirectory = detected;
+ }
+ }
+
+ public void ApplySelection()
+ {
+ _context.InstallDirectory = DirectoryField.Text ?? string.Empty;
+ }
+}
diff --git a/LANCommander.Packager/Views/MetadataView.axaml b/LANCommander.Packager/Views/MetadataView.axaml
new file mode 100644
index 00000000..2ba137ad
--- /dev/null
+++ b/LANCommander.Packager/Views/MetadataView.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/MetadataView.axaml.cs b/LANCommander.Packager/Views/MetadataView.axaml.cs
new file mode 100644
index 00000000..9d0c97b1
--- /dev/null
+++ b/LANCommander.Packager/Views/MetadataView.axaml.cs
@@ -0,0 +1,36 @@
+using Avalonia.Controls;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager.Views;
+
+public partial class MetadataView : UserControl
+{
+ private readonly PackageContext _context;
+
+ public MetadataView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+ ReleasedOnPicker.SelectedDate = DateTime.Today;
+ }
+
+ public void SetDefaultTitle(string title)
+ {
+ TitleField.Text = title;
+ }
+
+ public void ApplyMetadata()
+ {
+ var manifest = _context.Manifest;
+ manifest.Title = TitleField.Text ?? string.Empty;
+ manifest.SortTitle = string.IsNullOrWhiteSpace(SortTitleField.Text)
+ ? manifest.Title
+ : SortTitleField.Text;
+ manifest.Version = VersionField.Text ?? "1.0";
+ manifest.ReleasedOn = ReleasedOnPicker.SelectedDate ?? DateTime.Today;
+ manifest.Singleplayer = SingleplayerCheckbox.IsChecked == true;
+ manifest.Description = DescriptionField.Text ?? string.Empty;
+ manifest.Notes = NotesField.Text ?? string.Empty;
+ manifest.DirectoryName = Path.GetFileName(_context.InstallDirectory);
+ }
+}
diff --git a/LANCommander.Packager/Views/MonitoringView.axaml b/LANCommander.Packager/Views/MonitoringView.axaml
new file mode 100644
index 00000000..5bc68257
--- /dev/null
+++ b/LANCommander.Packager/Views/MonitoringView.axaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/MonitoringView.axaml.cs b/LANCommander.Packager/Views/MonitoringView.axaml.cs
new file mode 100644
index 00000000..58323234
--- /dev/null
+++ b/LANCommander.Packager/Views/MonitoringView.axaml.cs
@@ -0,0 +1,157 @@
+using System.Collections.Concurrent;
+using System.Text;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using LANCommander.Packager.Models;
+using LANCommander.Packager.Services;
+
+namespace LANCommander.Packager.Views;
+
+public partial class MonitoringView : UserControl
+{
+ private readonly PackageContext _context;
+ private readonly StringBuilder _logText = new();
+ private readonly ConcurrentQueue _pendingLogEntries = new();
+ private InstallerMonitorService? _monitorService;
+ private DispatcherTimer? _updateTimer;
+ private volatile int _lastFileCount;
+ private volatile int _lastRegistryCount;
+ private volatile bool _installerExited;
+
+ public event Action? MonitoringCompleted;
+
+ public MonitoringView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+ }
+
+ public void StartMonitoring()
+ {
+ _monitorService = new InstallerMonitorService();
+
+ _monitorService.OnFileChange += entry =>
+ {
+ _lastFileCount = _monitorService.FileChangeCount;
+ };
+
+ _monitorService.OnRegistryChange += entry =>
+ {
+ _lastRegistryCount = _monitorService.RegistryChangeCount;
+ };
+
+ _monitorService.OnInstallerExited += () =>
+ {
+ _installerExited = true;
+ _pendingLogEntries.Enqueue("Installer exited.");
+ };
+
+ _updateTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
+ _updateTimer.Tick += (_, _) => FlushUpdates();
+ _updateTimer.Start();
+
+ StatusLabel.Text = "Preparing...";
+
+ var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.log");
+
+ File.WriteAllText(logPath, $"=== Packager started {DateTime.Now} ==={Environment.NewLine}");
+
+ Task.Run(() =>
+ {
+ try
+ {
+ Action log = msg =>
+ {
+ try
+ {
+ File.AppendAllText(logPath, msg + Environment.NewLine);
+ }
+ catch { }
+
+ _pendingLogEntries.Enqueue(msg);
+ };
+
+ var mode = _monitorService.LaunchInstaller(_context.InstallerPath, log);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ StatusLabel.Text = mode == "snapshot"
+ ? "Installer running (snapshot mode). Waiting..."
+ : "Installer running (Interposer). Monitoring...";
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ StatusLabel.Text = "Error - see packager.log";
+
+ _pendingLogEntries.Enqueue($"Exception: [{ex.GetType().Name}] {ex.Message}");
+ _pendingLogEntries.Enqueue($"Installer: {_context.InstallerPath}");
+
+ if (ex.InnerException != null)
+ _pendingLogEntries.Enqueue($"Inner: {ex.InnerException.Message}");
+
+ _pendingLogEntries.Enqueue(ex.StackTrace ?? "");
+
+ try
+ {
+ File.AppendAllLines(logPath, new[]
+ {
+ $"Exception: [{ex.GetType().Name}] {ex.Message}",
+ ex.StackTrace ?? ""
+ });
+ }
+ catch { }
+ });
+ }
+ });
+ }
+
+ private void FlushUpdates()
+ {
+ FileCountLabel.Text = $"Files changed: {_lastFileCount}";
+ RegistryCountLabel.Text = $"Registry entries: {_lastRegistryCount}";
+
+ bool hasNew = false;
+
+ while (_pendingLogEntries.TryDequeue(out var entry))
+ {
+ _logText.AppendLine(entry);
+ hasNew = true;
+ }
+
+ if (hasNew)
+ {
+ EventLog.Text = _logText.ToString();
+ EventLog.CaretIndex = EventLog.Text.Length;
+ }
+
+ if (_installerExited)
+ {
+ _installerExited = false;
+ _updateTimer?.Stop();
+
+ // Dispose the monitor service to stop all background Interposer activity
+ _context.FileChanges = _monitorService?.FileChanges.ToList() ?? [];
+ _context.RegistryChanges = _monitorService?.RegistryChanges.ToList() ?? [];
+ _monitorService?.Dispose();
+ _monitorService = null;
+
+ // Final drain
+ while (_pendingLogEntries.TryDequeue(out var remaining))
+ _logText.AppendLine(remaining);
+
+ EventLog.Text = _logText.ToString();
+ EventLog.CaretIndex = EventLog.Text.Length;
+
+ FileCountLabel.Text = $"Files changed: {_lastFileCount}";
+ RegistryCountLabel.Text = $"Registry entries: {_lastRegistryCount}";
+ StatusLabel.Text = "Done! Press Next to continue.";
+
+ MonitoringCompleted?.Invoke();
+ }
+ }
+
+ public InstallerMonitorService? GetMonitorService() => _monitorService;
+}
diff --git a/LANCommander.Packager/Views/OutputView.axaml b/LANCommander.Packager/Views/OutputView.axaml
new file mode 100644
index 00000000..17e87dca
--- /dev/null
+++ b/LANCommander.Packager/Views/OutputView.axaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/OutputView.axaml.cs b/LANCommander.Packager/Views/OutputView.axaml.cs
new file mode 100644
index 00000000..a55ca8c7
--- /dev/null
+++ b/LANCommander.Packager/Views/OutputView.axaml.cs
@@ -0,0 +1,190 @@
+using System.IO.Compression;
+using System.Text;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using LANCommander.Packager.Models;
+using LANCommander.Packager.Services;
+
+namespace LANCommander.Packager.Views;
+
+public partial class OutputView : UserControl
+{
+ private readonly PackageContext _context;
+
+ public OutputView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+
+ GenerateButton.Click += async (s, e) =>
+ {
+ await GeneratePackageAsync();
+ };
+
+ BrowseButton.Click += async (s, e) =>
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel == null) return;
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save .LCX Package",
+ DefaultExtension = "lcx",
+ FileTypeChoices = [new("LCX Package") { Patterns = ["*.lcx"] }],
+ SuggestedFileName = Path.GetFileName(OutputPathField.Text ?? "Game.lcx")
+ });
+
+ if (file != null)
+ OutputPathField.Text = file.Path.LocalPath;
+ };
+ }
+
+ public void SetDefaultOutputPath()
+ {
+ var title = _context.Manifest.Title ?? "Game";
+ var safeName = string.Join("_", title.Split(Path.GetInvalidFileNameChars()));
+ OutputPathField.Text = Path.Combine(Environment.CurrentDirectory, $"{safeName}.lcx");
+ }
+
+ private void ApplyOptions()
+ {
+ _context.PatchGameSpy = PatchGameSpyCheck.IsChecked == true;
+ _context.WriteSummaryLog = WriteSummaryLogCheck.IsChecked == true;
+ _context.CompressionLevel = CompressionLevelCombo.SelectedIndex switch
+ {
+ 0 => CompressionLevel.Optimal,
+ 1 => CompressionLevel.Fastest,
+ 2 => CompressionLevel.NoCompression,
+ 3 => CompressionLevel.SmallestSize,
+ _ => CompressionLevel.Optimal
+ };
+ }
+
+ private async Task GeneratePackageAsync()
+ {
+ var outputPath = OutputPathField.Text;
+
+ if (string.IsNullOrWhiteSpace(outputPath))
+ {
+ StatusLabel.Text = "Please specify an output path.";
+ return;
+ }
+
+ _context.OutputPath = outputPath;
+ ApplyOptions();
+
+ GenerateButton.IsEnabled = false;
+ Progress.IsVisible = true;
+ Progress.Value = 0;
+
+ var progress = new Progress(message =>
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ StatusLabel.Text = message;
+
+ Progress.Value = message switch
+ {
+ "Creating game files archive..." => 0.25,
+ "Generating scripts..." => 0.50,
+ "Writing manifest..." => 0.75,
+ "Done!" => 1.0,
+ _ => Progress.Value
+ };
+ });
+ });
+
+ try
+ {
+ StatusLabel.Text = "Generating package...";
+ await Task.Run(() => LcxBuilderService.BuildAsync(_context, progress));
+
+ var fileInfo = new FileInfo(outputPath);
+ var sizeMb = fileInfo.Length / (1024.0 * 1024.0);
+
+ if (_context.WriteSummaryLog)
+ WriteSummaryLog(outputPath, sizeMb);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ StatusLabel.Text = $"Package created successfully!\n{outputPath}\nSize: {sizeMb:F2} MB";
+ GenerateButton.IsEnabled = true;
+ Progress.IsVisible = false;
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ GenerateButton.IsEnabled = true;
+ Progress.IsVisible = false;
+ });
+ }
+ }
+
+ private void WriteSummaryLog(string outputPath, double sizeMb)
+ {
+ var manifest = _context.Manifest;
+ var title = manifest.Title ?? "Game";
+ var safeName = string.Join("_", title.Split(Path.GetInvalidFileNameChars()));
+ var logDir = Path.GetDirectoryName(outputPath) ?? Environment.CurrentDirectory;
+ var logPath = Path.Combine(logDir, $"{safeName}.Package.log");
+
+ var compressionName = _context.CompressionLevel switch
+ {
+ CompressionLevel.Optimal => "Optimal",
+ CompressionLevel.Fastest => "Fastest",
+ CompressionLevel.NoCompression => "No Compression",
+ CompressionLevel.SmallestSize => "Smallest Size",
+ _ => "Optimal"
+ };
+
+ var primaryAction = manifest.Actions.FirstOrDefault(a => a.IsPrimaryAction);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("LANCommander Packager - Summary Log");
+ sb.AppendLine("====================================");
+ sb.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
+ sb.AppendLine();
+ sb.AppendLine("Source");
+ sb.AppendLine($" Installer: {_context.InstallerPath}");
+ sb.AppendLine($" Install Directory: {_context.InstallDirectory}");
+ sb.AppendLine();
+ sb.AppendLine("Metadata");
+ sb.AppendLine($" Title: {manifest.Title}");
+
+ if (!string.IsNullOrWhiteSpace(manifest.SortTitle) && manifest.SortTitle != manifest.Title)
+ sb.AppendLine($" Sort Title: {manifest.SortTitle}");
+
+ sb.AppendLine($" Version: {manifest.Version}");
+ sb.AppendLine($" Released On: {manifest.ReleasedOn:yyyy-MM-dd}");
+ sb.AppendLine($" Singleplayer: {(manifest.Singleplayer ? "Yes" : "No")}");
+ sb.AppendLine();
+ sb.AppendLine("Contents");
+ sb.AppendLine($" Files Included: {_context.SelectedFiles.Count}");
+ sb.AppendLine($" Registry Entries: {_context.SelectedRegistryEntries.Count}");
+
+ if (primaryAction != null)
+ sb.AppendLine($" Primary Action: {primaryAction.Name} -> {primaryAction.Path}");
+
+ sb.AppendLine();
+ sb.AppendLine("Options");
+ sb.AppendLine($" Compression Level: {compressionName}");
+ sb.AppendLine($" Patch GameSpy: {(_context.PatchGameSpy ? "Yes" : "No")}");
+ sb.AppendLine();
+ sb.AppendLine("Output");
+ sb.AppendLine($" File: {outputPath}");
+ sb.AppendLine($" Size: {sizeMb:F2} MB");
+
+ try
+ {
+ File.WriteAllText(logPath, sb.ToString());
+ }
+ catch
+ {
+ // Non-critical, ignore write failures
+ }
+ }
+}
diff --git a/LANCommander.Packager/Views/RegistrySelectionView.axaml b/LANCommander.Packager/Views/RegistrySelectionView.axaml
new file mode 100644
index 00000000..94cb0a4b
--- /dev/null
+++ b/LANCommander.Packager/Views/RegistrySelectionView.axaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/RegistrySelectionView.axaml.cs b/LANCommander.Packager/Views/RegistrySelectionView.axaml.cs
new file mode 100644
index 00000000..b4d2e4d6
--- /dev/null
+++ b/LANCommander.Packager/Views/RegistrySelectionView.axaml.cs
@@ -0,0 +1,46 @@
+using Avalonia.Controls;
+using LANCommander.Packager.Models;
+
+namespace LANCommander.Packager.Views;
+
+public partial class RegistrySelectionView : UserControl
+{
+ private readonly PackageContext _context;
+ private CheckableTreeNode _root = new();
+
+ public RegistrySelectionView(PackageContext context)
+ {
+ _context = context;
+ InitializeComponent();
+
+ SelectAllButton.Click += (_, _) => { _root.IsChecked = true; UpdateCount(); };
+ SelectNoneButton.Click += (_, _) => { _root.IsChecked = false; UpdateCount(); };
+ }
+
+ public void PopulateEntries()
+ {
+ _root = CheckableTreeNode.BuildRegistryTree(_context.RegistryChanges);
+ _root.OnTreeSelectionChanged = UpdateCount;
+ RegistryTree.ItemsSource = _root.Children;
+ UpdateCount();
+ }
+
+ public void ApplySelection()
+ {
+ _context.SelectedRegistryEntries.Clear();
+
+ foreach (var leaf in _root.GetCheckedLeaves())
+ {
+ if (leaf.SourceIndex >= 0 && leaf.SourceIndex < _context.RegistryChanges.Count)
+ _context.SelectedRegistryEntries.Add(_context.RegistryChanges[leaf.SourceIndex]);
+ }
+ }
+
+ private void UpdateCount()
+ {
+ var selected = _root.CountCheckedLeaves();
+ var total = _root.CountTotalLeaves();
+
+ CountLabel.Text = $"{selected} / {total} entries selected";
+ }
+}
diff --git a/LANCommander.Packager/app.manifest b/LANCommander.Packager/app.manifest
new file mode 100644
index 00000000..4d2703aa
--- /dev/null
+++ b/LANCommander.Packager/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Edit-PatchGameSpy.cs b/LANCommander.SDK/PowerShell/Cmdlets/Edit-PatchGameSpy.cs
index 96026c5b..e07d693e 100644
--- a/LANCommander.SDK/PowerShell/Cmdlets/Edit-PatchGameSpy.cs
+++ b/LANCommander.SDK/PowerShell/Cmdlets/Edit-PatchGameSpy.cs
@@ -29,11 +29,11 @@ public class EditPatchGameSpy : Cmdlet
[Parameter(Mandatory = false, Position = 2, HelpMessage = "The replacement public key for server authentication. Must match the original key length. Defaults to the OpenSpy public key.")]
public string PublicKey { get; set; } = OPENSPY_PUBLICKEY;
- [Parameter(Mandatory = false, Position = 3, HelpMessage = "Glob patterns for binary files to scan and patch. Defaults to '*.dll', '*.exe', '*.ini'.")]
- public string[] BinariesToPatch { get; set; } = { "*.dll", "*.exe", "*.ini" };
+ [Parameter(Mandatory = false, Position = 3, HelpMessage = "Glob patterns for binary files to scan and patch. Defaults to '*.dll', '*.exe'.")]
+ public string[] BinariesToPatch { get; set; } = { "*.dll", "*.exe" };
[Parameter(Mandatory = false, Position = 4, HelpMessage = "Glob patterns for text files to scan and patch (e.g. Unreal Engine INI configs). Defaults to '*.ini'.")]
- public string[] TextFilesToPatch { get; set; } = { "*.ini" };
+ public string[] TextFilesToPatch { get; set; } = { "*.ini", "*.cfg", "*.conf" };
protected override void ProcessRecord()
{
diff --git a/LANCommander.slnx b/LANCommander.slnx
index e3a104fb..0c74663b 100644
--- a/LANCommander.slnx
+++ b/LANCommander.slnx
@@ -43,5 +43,6 @@
+
\ No newline at end of file