Add package application for auto-building LCX files
Some checks failed
LANCommander SDK Release / prep (push) Failing after 59s
LANCommander SDK Release / publish (push) Has been skipped
LANCommander Release / prep (push) Failing after 1m12s
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_avalonia_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_avalonia_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_avalonia_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_avalonia_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped

This commit is contained in:
Pat Hartl 2026-05-30 13:10:01 -05:00
parent 1191226dc0
commit 8917a2d79f
44 changed files with 2858 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -92,6 +92,7 @@
<PackageVersion Include="Facepunch.Steamworks" Version="2.3.3" />
<PackageVersion Include="IGDB" Version="6.1.0" />
<PackageVersion Include="IPXRelayDotNet" Version="1.1.3" />
<PackageVersion Include="LANCommander.Interposer" Version="1.0.7" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="RestSharp" Version="112.1.0" />
<PackageVersion Include="rix0rrr.BeaconLib" Version="1.0.2" />

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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';
<DocCardList />

View file

@ -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.

View file

@ -0,0 +1,18 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://LANCommander.Packager/Theme/Packager.axaml" />
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LANCommander.Packager/Theme/ColorPalette.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -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();
}
}

View file

@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x86</PlatformTarget>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>LANCommanderDark.ico</ApplicationIcon>
<Company>LANCommander</Company>
<Product>Packager</Product>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="LANCommanderDark.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="CommandLineParser" />
<PackageReference Include="LANCommander.Interposer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.SDK\LANCommander.SDK.csproj" />
</ItemGroup>
<!-- Copy both x64 and x86 native Interposer DLLs so we can inject into either arch.
Always use Release builds to avoid debug CRT dependencies (MSVCP140D.dll, ucrtbased.dll)
which are not present on target machines. -->
<ItemGroup>
<None Include="..\..\LANCommander.Interposer\x64\Release\LANCommander.Interposer.dll"
Condition="Exists('..\..\LANCommander.Interposer\x64\Release\LANCommander.Interposer.dll')"
CopyToOutputDirectory="PreserveNewest"
Link="runtimes\win-x64\native\LANCommander.Interposer.dll" />
<None Include="..\..\LANCommander.Interposer\Release\Injector\x86\LANCommander.Interposer.dll"
Condition="Exists('..\..\LANCommander.Interposer\Release\Injector\x86\LANCommander.Interposer.dll')"
CopyToOutputDirectory="PreserveNewest"
Link="runtimes\win-x86\native\LANCommander.Interposer.dll" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

@ -0,0 +1,86 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:LANCommander.Packager.Models"
x:Class="LANCommander.Packager.MainWindow"
Title="LANCommander Packager"
Width="950" Height="650"
MinWidth="800" MinHeight="500"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource BackgroundBrush}"
Icon="avares://LANCommander.Packager/LANCommanderDark.ico">
<Grid RowDefinitions="*,Auto">
<!-- Main area -->
<Grid ColumnDefinitions="220,*">
<!-- Step sidebar -->
<Border Background="#1A1A1A" BorderBrush="#2A2A2A" BorderThickness="0,0,1,0">
<ItemsControl Name="StepList" Margin="24,0" VerticalAlignment="Center">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:WizardStepItem">
<Grid ColumnDefinitions="20,*" Height="36">
<!-- Top connecting line -->
<Border Grid.Column="0" Width="2" Height="18"
Background="#333333"
HorizontalAlignment="Center" VerticalAlignment="Top"
IsVisible="{Binding ShowTopLine}" />
<!-- Bottom connecting line -->
<Border Grid.Column="0" Width="2" Height="18"
Background="#333333"
HorizontalAlignment="Center" VerticalAlignment="Bottom"
IsVisible="{Binding ShowBottomLine}" />
<!-- Completed dot -->
<Ellipse Grid.Column="0" Width="10" Height="10"
Fill="{DynamicResource PrimaryBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding IsCompleted}" />
<!-- Current dot -->
<Ellipse Grid.Column="0" Width="12" Height="12"
Fill="{DynamicResource PrimaryBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding IsCurrent}" />
<!-- Pending dot -->
<Ellipse Grid.Column="0" Width="10" Height="10"
Fill="Transparent"
Stroke="#555555" StrokeThickness="1.5"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding IsPending}" />
<!-- Step title -->
<TextBlock Grid.Column="1" Text="{Binding Title}"
VerticalAlignment="Center" Margin="10,0,0,0"
FontSize="13"
Opacity="{Binding TextOpacity}"
Foreground="{DynamicResource TextPrimaryBrush}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Content area -->
<DockPanel Grid.Column="1" Margin="28,24">
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,20">
<TextBlock Name="StepTitle" FontSize="22" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Name="StepHelp" FontSize="13" Opacity="0.5"
TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource TextPrimaryBrush}" />
</StackPanel>
<ContentControl Name="ContentArea" />
</DockPanel>
</Grid>
<!-- Bottom navigation -->
<Border Grid.Row="1" Background="#1A1A1A" BorderBrush="#2A2A2A"
BorderThickness="0,1,0,0" Padding="20,12">
<Grid>
<Button Name="CancelButton" Content="Cancel" HorizontalAlignment="Left" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Name="BackButton" Content="Back" />
<Button Name="NextButton" Content="Next" Classes="Primary" />
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View file

@ -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<WizardStepItem> _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<WizardStepItem>
{
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;
}
}
}

View file

@ -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<CheckableTreeNode> 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<CheckableTreeNode> 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<RegistryChangeEntry> 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;
}
}

View file

@ -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;
}

View file

@ -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; }
}

View file

@ -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<FileChangeEntry> FileChanges { get; set; } = new();
public List<RegistryChangeEntry> RegistryChanges { get; set; } = new();
public List<string> SelectedFiles { get; set; } = new();
public List<RegistryChangeEntry> 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; }
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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; }
}

View file

@ -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<Options>(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<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View file

@ -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<InterposerService> _interposers = new();
private readonly ConcurrentDictionary<int, bool> _injectedPids = new();
private readonly ConcurrentDictionary<string, FileChangeEntry> _fileChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentBag<RegistryChangeEntry> _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<FileChangeEntry> FileChanges => _fileChanges.Values.ToList();
public IReadOnlyCollection<RegistryChangeEntry> RegistryChanges => _registryChanges.ToList();
public event Action<FileChangeEntry>? OnFileChange;
public event Action<RegistryChangeEntry>? OnRegistryChange;
public event Action? OnInstallerExited;
public int FileChangeCount => _fileChanges.Count;
public int RegistryChangeCount => _registryChanges.Count;
private Action<string>? _log;
public string LaunchInstaller(string installerPath, Action<string>? 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<string>? 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<int> GetChildProcessIds(int parentPid)
{
var children = new List<int>();
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<PROCESSENTRY32>() };
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<string>()
.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<string> 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<string>();
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();
}
}

View file

@ -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<string>? 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!");
}
}

View file

@ -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<GeneratedScript> Generate(PackageContext context)
{
var scripts = new List<GeneratedScript>();
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;
}
}

View file

@ -0,0 +1,144 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Primary (Blue) Scale -->
<Color x:Key="Primary1">#E6F4FF</Color>
<Color x:Key="Primary2">#BAE0FF</Color>
<Color x:Key="Primary3">#91CAFF</Color>
<Color x:Key="Primary4">#69B1FF</Color>
<Color x:Key="Primary5">#4096FF</Color>
<Color x:Key="Primary6">#1677FF</Color>
<Color x:Key="Primary7">#0958D9</Color>
<Color x:Key="Primary8">#003EB3</Color>
<Color x:Key="Primary9">#002C8C</Color>
<Color x:Key="Primary10">#001D66</Color>
<!-- Success (Green) -->
<Color x:Key="Success1">#F6FFED</Color>
<Color x:Key="Success2">#D9F7BE</Color>
<Color x:Key="Success3">#B7EB8F</Color>
<Color x:Key="Success4">#95DE64</Color>
<Color x:Key="Success5">#73D13D</Color>
<Color x:Key="Success6">#52C41A</Color>
<Color x:Key="Success7">#389E0D</Color>
<Color x:Key="Success8">#237804</Color>
<Color x:Key="Success9">#135200</Color>
<Color x:Key="Success10">#092B00</Color>
<!-- Warning (Gold) -->
<Color x:Key="Warning1">#FFFBE6</Color>
<Color x:Key="Warning2">#FFF1B8</Color>
<Color x:Key="Warning3">#FFE58F</Color>
<Color x:Key="Warning4">#FFD666</Color>
<Color x:Key="Warning5">#FFC53D</Color>
<Color x:Key="Warning6">#FAAD14</Color>
<Color x:Key="Warning7">#D48806</Color>
<Color x:Key="Warning8">#AD6800</Color>
<Color x:Key="Warning9">#874D00</Color>
<Color x:Key="Warning10">#613400</Color>
<!-- Error (Red) -->
<Color x:Key="Error1">#FFF2F0</Color>
<Color x:Key="Error2">#FFF1F0</Color>
<Color x:Key="Error3">#FFCCC7</Color>
<Color x:Key="Error4">#FFA39E</Color>
<Color x:Key="Error5">#FF7875</Color>
<Color x:Key="Error6">#FF4D4F</Color>
<Color x:Key="Error7">#CF1322</Color>
<Color x:Key="Error8">#A8071A</Color>
<Color x:Key="Error9">#820014</Color>
<Color x:Key="Error10">#5C0011</Color>
<!-- Neutral (Gray) — 1=darkest, 13=lightest -->
<Color x:Key="Gray1">#000000</Color>
<Color x:Key="Gray2">#141414</Color>
<Color x:Key="Gray3">#1F1F1F</Color>
<Color x:Key="Gray4">#282828</Color>
<Color x:Key="Gray5">#303030</Color>
<Color x:Key="Gray6">#3A3A3A</Color>
<Color x:Key="Gray7">#424242</Color>
<Color x:Key="Gray8">#595959</Color>
<Color x:Key="Gray9">#8C8C8C</Color>
<Color x:Key="Gray10">#BFBFBF</Color>
<Color x:Key="Gray11">#D9D9D9</Color>
<Color x:Key="Gray12">#F0F0F0</Color>
<Color x:Key="Gray13">#FFFFFF</Color>
<!-- Semantic Tokens -->
<Color x:Key="ColorPrimary">#1677FF</Color>
<Color x:Key="ColorSuccess">#49AA19</Color>
<Color x:Key="ColorWarning">#D89614</Color>
<Color x:Key="ColorError">#DC4446</Color>
<Color x:Key="TextPrimary">#D9FFFFFF</Color>
<Color x:Key="TextSecondary">#A6FFFFFF</Color>
<Color x:Key="TextDisabled">#40FFFFFF</Color>
<Color x:Key="BackgroundBase">#141414</Color>
<Color x:Key="BackgroundLayout">#000000</Color>
<!-- Brushes -->
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource ColorPrimary}" />
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource ColorSuccess}" />
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource ColorWarning}" />
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ColorError}" />
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimary}" />
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondary}" />
<SolidColorBrush x:Key="TextDisabledBrush" Color="{StaticResource TextDisabled}" />
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundBase}" />
<SolidColorBrush x:Key="Primary5Brush" Color="{StaticResource Primary5}" />
<SolidColorBrush x:Key="Primary7Brush" Color="{StaticResource Primary7}" />
<SolidColorBrush x:Key="Gray3Brush" Color="{StaticResource Gray3}" />
<!-- Button - Primary -->
<Color x:Key="ButtonPrimaryBg">#177ddc</Color>
<Color x:Key="ButtonPrimaryHover">#095cb5</Color>
<Color x:Key="ButtonPrimaryActive">#0958D9</Color>
<Color x:Key="ButtonPrimaryText">#FFFFFF</Color>
<!-- Button - Success -->
<Color x:Key="ButtonSuccessBg">#49AA19</Color>
<Color x:Key="ButtonSuccessHover">#6ABF40</Color>
<Color x:Key="ButtonSuccessActive">#338F0F</Color>
<Color x:Key="ButtonSuccessText">#FFFFFF</Color>
<!-- Button - Shared -->
<Color x:Key="ButtonDisabledBg">#14FFFFFF</Color>
<Color x:Key="ButtonDisabledText">#40FFFFFF</Color>
<!-- Input -->
<Color x:Key="InputBg">#0FFFFFFF</Color>
<Color x:Key="InputBorder">#424242</Color>
<Color x:Key="InputHoverBorder">#4096FF</Color>
<Color x:Key="InputFocusBorder">#1677FF</Color>
<Color x:Key="InputText">#D9FFFFFF</Color>
<Color x:Key="InputPlaceholder">#40FFFFFF</Color>
<!-- Component Brushes -->
<SolidColorBrush x:Key="ButtonPrimaryBgBrush" Color="{StaticResource ButtonPrimaryBg}" />
<SolidColorBrush x:Key="ButtonPrimaryHoverBrush" Color="{StaticResource ButtonPrimaryHover}" />
<SolidColorBrush x:Key="ButtonPrimaryActiveBrush" Color="{StaticResource ButtonPrimaryActive}" />
<SolidColorBrush x:Key="ButtonPrimaryTextBrush" Color="{StaticResource ButtonPrimaryText}" />
<SolidColorBrush x:Key="ButtonSuccessBgBrush" Color="{StaticResource ButtonSuccessBg}" />
<SolidColorBrush x:Key="ButtonSuccessHoverBrush" Color="{StaticResource ButtonSuccessHover}" />
<SolidColorBrush x:Key="ButtonSuccessActiveBrush" Color="{StaticResource ButtonSuccessActive}" />
<SolidColorBrush x:Key="ButtonSuccessTextBrush" Color="{StaticResource ButtonSuccessText}" />
<SolidColorBrush x:Key="ButtonDisabledBgBrush" Color="{StaticResource ButtonDisabledBg}" />
<SolidColorBrush x:Key="ButtonDisabledTextBrush" Color="{StaticResource ButtonDisabledText}" />
<!-- Override FluentTheme TextBox internal resource keys -->
<SolidColorBrush x:Key="TextControlBackground" Color="{StaticResource InputBg}" />
<SolidColorBrush x:Key="TextControlBackgroundPointerOver" Color="{StaticResource InputBg}" />
<SolidColorBrush x:Key="TextControlBackgroundFocused" Color="{StaticResource InputBg}" />
<SolidColorBrush x:Key="TextControlBackgroundDisabled" Color="{StaticResource Gray3}" />
<SolidColorBrush x:Key="InputBgBrush" Color="{StaticResource InputBg}" />
<SolidColorBrush x:Key="InputBorderBrush" Color="{StaticResource InputBorder}" />
<SolidColorBrush x:Key="InputHoverBorderBrush" Color="{StaticResource InputHoverBorder}" />
<SolidColorBrush x:Key="InputFocusBorderBrush" Color="{StaticResource InputFocusBorder}" />
<SolidColorBrush x:Key="InputTextBrush" Color="{StaticResource InputText}" />
<SolidColorBrush x:Key="InputPlaceholderBrush" Color="{StaticResource InputPlaceholder}" />
</ResourceDictionary>

View file

@ -0,0 +1,85 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Button base -->
<Style Selector="Button">
<Setter Property="Background" Value="{DynamicResource BackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{DynamicResource Primary5Brush}" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{DynamicResource Primary7Brush}" />
</Style>
<Style Selector="Button:focus-visible /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BoxShadow" Value="inset 0 0 0 2 #994096FF" />
<Setter Property="CornerRadius" Value="4" />
</Style>
<!-- Primary button -->
<Style Selector="Button.Primary">
<Setter Property="Background" Value="{DynamicResource ButtonPrimaryBgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ButtonPrimaryTextBrush}" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Button.Primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonPrimaryHoverBrush}" />
</Style>
<Style Selector="Button.Primary:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonPrimaryActiveBrush}" />
</Style>
<Style Selector="Button.Primary:disabled, Button.Success:disabled">
<Setter Property="Foreground" Value="{DynamicResource ButtonDisabledTextBrush}" />
</Style>
<Style Selector="Button.Primary:disabled /template/ ContentPresenter#PART_ContentPresenter,
Button.Success:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonDisabledBgBrush}" />
</Style>
<!-- Success button -->
<Style Selector="Button.Success">
<Setter Property="Background" Value="{DynamicResource ButtonSuccessBgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ButtonSuccessTextBrush}" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Button.Success:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonSuccessHoverBrush}" />
</Style>
<Style Selector="Button.Success:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonSuccessActiveBrush}" />
</Style>
<!-- TextBox -->
<Style Selector="TextBox">
<Setter Property="Foreground" Value="{DynamicResource InputTextBrush}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="8,4" />
</Style>
<Style Selector="TextBox /template/ Border">
<Setter Property="Background" Value="{DynamicResource InputBgBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
</Style>
<Style Selector="TextBox:pointerover /template/ Border">
<Setter Property="BorderBrush" Value="{DynamicResource InputHoverBorderBrush}" />
</Style>
<Style Selector="TextBox:focus-within /template/ Border">
<Setter Property="BorderBrush" Value="{DynamicResource InputFocusBorderBrush}" />
</Style>
<Style Selector="TextBox:disabled /template/ Border">
<Setter Property="Background" Value="{DynamicResource Gray3Brush}" />
</Style>
<Style Selector="TextBox:disabled">
<Setter Property="Foreground" Value="{DynamicResource TextDisabledBrush}" />
</Style>
</Styles>

View file

@ -0,0 +1,26 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.ActionView">
<DockPanel>
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto"
Margin="0,16,0,0">
<TextBlock Text="Action Name" Opacity="0.7" Grid.Row="0" Grid.Column="0"
Margin="0,0,0,4" />
<TextBlock Text="Arguments" Opacity="0.7" Grid.Row="0" Grid.Column="1"
Margin="8,0,0,4" />
<TextBox Name="ActionNameField" Text="Play" Grid.Row="1" Grid.Column="0"
Width="150" />
<TextBox Name="ArgumentsField" Grid.Row="1" Grid.Column="1"
Margin="8,0,0,0" />
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Select primary executable:" Opacity="0.7" />
<ListBox Name="ExeList"
Background="Transparent"
BorderThickness="1"
BorderBrush="#2A2A2A"
CornerRadius="4" />
</StackPanel>
</DockPanel>
</UserControl>

View file

@ -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<string> _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));
}
}

View file

@ -0,0 +1,41 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:LANCommander.Packager.Models"
x:Class="LANCommander.Packager.Views.FileSelectionView">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
<TextBlock Name="CountLabel" Text="0 files selected"
Foreground="{DynamicResource TextSecondaryBrush}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="SelectAllButton" Content="Select All" Padding="8,4" />
<Button Name="SelectNoneButton" Content="Select None" Padding="8,4" />
</StackPanel>
</StackPanel>
<TreeView Name="FileTree"
Background="Transparent"
BorderThickness="1"
BorderBrush="#2A2A2A"
CornerRadius="4">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="22" />
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="models:CheckableTreeNode"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,-2">
<CheckBox IsChecked="{Binding IsChecked}"
VerticalAlignment="Center"
MinWidth="0" Padding="0" Margin="0" />
<TextBlock Text="{Binding Name}" FontSize="12"
VerticalAlignment="Center" />
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</UserControl>

View file

@ -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";
}
}

View file

@ -0,0 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.InstallDirectoryView">
<StackPanel Spacing="8">
<TextBlock Text="Detected install directory:" Opacity="0.7" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Name="DirectoryField" />
<Button Name="BrowseButton" Content="Browse..."
Grid.Column="1" Margin="8,0,0,0" VerticalAlignment="Stretch" />
</Grid>
</StackPanel>
</UserControl>

View file

@ -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;
}
}

View file

@ -0,0 +1,41 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.MetadataView">
<ScrollViewer>
<StackPanel Spacing="12" MaxWidth="500">
<StackPanel Spacing="4">
<TextBlock Text="Title *" Opacity="0.7" />
<TextBox Name="TitleField" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Sort Title" Opacity="0.7" />
<TextBox Name="SortTitleField" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Version" Opacity="0.7" />
<TextBox Name="VersionField" Text="1.0" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Released On" Opacity="0.7" />
<CalendarDatePicker Name="ReleasedOnPicker" HorizontalAlignment="Left" />
</StackPanel>
<CheckBox Name="SingleplayerCheckbox" Content="Singleplayer" IsChecked="True" />
<StackPanel Spacing="4">
<TextBlock Text="Description" Opacity="0.7" />
<TextBox Name="DescriptionField" AcceptsReturn="True" Height="80"
TextWrapping="Wrap" VerticalContentAlignment="Top" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Notes" Opacity="0.7" />
<TextBox Name="NotesField" AcceptsReturn="True" Height="80"
TextWrapping="Wrap" VerticalContentAlignment="Top" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>

View file

@ -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);
}
}

View file

@ -0,0 +1,31 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.MonitoringView">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="4">
<TextBlock Name="StatusLabel" Text="Waiting to start installer..."
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Name="FileCountLabel" Text="Files changed: 0"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Name="RegistryCountLabel" Text="Registry entries: 0"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<TextBlock Text="Log:" Margin="0,16,0,4" Opacity="0.5" DockPanel.Dock="Top" />
<Border Background="#0C0C0C" BorderThickness="1" BorderBrush="#2A2A2A"
CornerRadius="4" ClipToBounds="True">
<TextBox Name="EventLog"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
FontFamily="Consolas,Courier New,monospace"
FontSize="11"
Foreground="#AACCAA"
Background="Transparent"
BorderThickness="0"
Padding="10,6"
VerticalContentAlignment="Top" />
</Border>
</DockPanel>
</UserControl>

View file

@ -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<string> _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<string> 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;
}

View file

@ -0,0 +1,60 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.OutputView">
<StackPanel Spacing="16">
<StackPanel Spacing="4">
<TextBlock Text="Output path" Opacity="0.7" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Name="OutputPathField" />
<Button Name="BrowseButton" Content="Browse..."
Grid.Column="1" Margin="8,0,0,0" VerticalAlignment="Stretch" />
</Grid>
</StackPanel>
<Expander Header="Options" IsExpanded="False">
<StackPanel Spacing="16" Margin="0,12,0,0">
<!-- Patch GameSpy -->
<StackPanel Spacing="2">
<CheckBox Name="PatchGameSpyCheck" Content="Patch GameSpy" />
<TextBlock Text="On install, scan the install directory for any references to GameSpy and automatically patch for OpenSpy."
Opacity="0.4" FontSize="11" TextWrapping="Wrap"
Margin="28,0,0,0" />
</StackPanel>
<!-- Compression Level -->
<StackPanel Spacing="4">
<TextBlock Text="Compression Level" />
<ComboBox Name="CompressionLevelCombo" SelectedIndex="0" Width="200"
HorizontalAlignment="Left">
<ComboBoxItem Content="Optimal" />
<ComboBoxItem Content="Fastest" />
<ComboBoxItem Content="No Compression" />
<ComboBoxItem Content="Smallest Size" />
</ComboBox>
<TextBlock Text="Controls the trade-off between archive size and packaging speed. Optimal is recommended for most packages. Use Fastest for quick iteration during testing, or Smallest Size for large distributions."
Opacity="0.4" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
<!-- Write Summary Log -->
<StackPanel Spacing="2">
<CheckBox Name="WriteSummaryLogCheck" Content="Write Summary Log" />
<TextBlock Text="Write a .Package.log file alongside the .LCX output that documents the source installer, selected files, registry entries, metadata, and options used to create this package."
Opacity="0.4" FontSize="11" TextWrapping="Wrap"
Margin="28,0,0,0" />
</StackPanel>
</StackPanel>
</Expander>
<Button Name="GenerateButton" Content="Generate .LCX" Classes="Primary"
HorizontalAlignment="Center" Padding="32,12" FontSize="16"
Margin="0,8" />
<ProgressBar Name="Progress" Minimum="0" Maximum="1" Value="0" Height="8"
IsVisible="False" />
<TextBlock Name="StatusLabel" TextWrapping="Wrap" HorizontalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</UserControl>

View file

@ -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<string>(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
}
}
}

View file

@ -0,0 +1,51 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:LANCommander.Packager.Models"
x:Class="LANCommander.Packager.Views.RegistrySelectionView">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
<TextBlock Name="CountLabel" Text="0 entries selected"
Foreground="{DynamicResource TextSecondaryBrush}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="SelectAllButton" Content="Select All" Padding="8,4" />
<Button Name="SelectNoneButton" Content="Select None" Padding="8,4" />
</StackPanel>
</StackPanel>
<TreeView Name="RegistryTree"
Background="Transparent"
BorderThickness="1"
BorderBrush="#2A2A2A"
CornerRadius="4">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="22" />
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="models:CheckableTreeNode"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,-2">
<CheckBox IsChecked="{Binding IsChecked}"
VerticalAlignment="Center"
MinWidth="0" Padding="0" Margin="0" />
<!-- Create indicator (green +) -->
<TextBlock Text="+" Foreground="#52C41A" FontWeight="Bold"
FontFamily="Consolas,monospace" FontSize="13"
VerticalAlignment="Center"
IsVisible="{Binding IsCreate}" Width="12" />
<!-- Update indicator (yellow ~) -->
<TextBlock Text="~" Foreground="#D89614" FontWeight="Bold"
FontFamily="Consolas,monospace" FontSize="13"
VerticalAlignment="Center"
IsVisible="{Binding IsUpdate}" Width="12" />
<TextBlock Text="{Binding Name}" FontSize="12"
VerticalAlignment="Center" />
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</UserControl>

View file

@ -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";
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View file

@ -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()
{

View file

@ -43,5 +43,6 @@
<Project Path="LANCommander.Steam\LANCommander.Steam.csproj" />
<Project Path="LANCommander.UI\LANCommander.UI.csproj" />
<Project Path="LANCommander.CompletionGenerator\LANCommander.CompletionGenerator.csproj" />
<Project Path="LANCommander.Packager\LANCommander.Packager.csproj" />
<Project Path="LANCommander.SDK.SourceGenerators\LANCommander.SDK.SourceGenerators.csproj" />
</Solution>