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
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:
parent
1191226dc0
commit
8917a2d79f
44 changed files with 2858 additions and 3 deletions
100
.github/workflows/LANCommander.Packager.yml
vendored
Normal file
100
.github/workflows/LANCommander.Packager.yml
vendored
Normal 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
|
||||
18
.github/workflows/LANCommander.Release.yml
vendored
18
.github/workflows/LANCommander.Release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
34
LANCommander.Documentation/Packager/Getting Started.md
Normal file
34
LANCommander.Documentation/Packager/Getting Started.md
Normal 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.
|
||||
47
LANCommander.Documentation/Packager/LCX Format.md
Normal file
47
LANCommander.Documentation/Packager/LCX Format.md
Normal 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.
|
||||
14
LANCommander.Documentation/Packager/Overview.md
Normal file
14
LANCommander.Documentation/Packager/Overview.md
Normal 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 />
|
||||
114
LANCommander.Documentation/Packager/Wizard.md
Normal file
114
LANCommander.Documentation/Packager/Wizard.md
Normal 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.
|
||||
18
LANCommander.Packager/App.axaml
Normal file
18
LANCommander.Packager/App.axaml
Normal 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>
|
||||
23
LANCommander.Packager/App.axaml.cs
Normal file
23
LANCommander.Packager/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
46
LANCommander.Packager/LANCommander.Packager.csproj
Normal file
46
LANCommander.Packager/LANCommander.Packager.csproj
Normal 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>
|
||||
BIN
LANCommander.Packager/LANCommanderDark.ico
Normal file
BIN
LANCommander.Packager/LANCommanderDark.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
86
LANCommander.Packager/MainWindow.axaml
Normal file
86
LANCommander.Packager/MainWindow.axaml
Normal 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>
|
||||
205
LANCommander.Packager/MainWindow.axaml.cs
Normal file
205
LANCommander.Packager/MainWindow.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
226
LANCommander.Packager/Models/CheckableTreeNode.cs
Normal file
226
LANCommander.Packager/Models/CheckableTreeNode.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
7
LANCommander.Packager/Models/FileChangeEntry.cs
Normal file
7
LANCommander.Packager/Models/FileChangeEntry.cs
Normal 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;
|
||||
}
|
||||
11
LANCommander.Packager/Models/GeneratedScript.cs
Normal file
11
LANCommander.Packager/Models/GeneratedScript.cs
Normal 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; }
|
||||
}
|
||||
21
LANCommander.Packager/Models/PackageContext.cs
Normal file
21
LANCommander.Packager/Models/PackageContext.cs
Normal 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; }
|
||||
}
|
||||
8
LANCommander.Packager/Models/RegistryChangeEntry.cs
Normal file
8
LANCommander.Packager/Models/RegistryChangeEntry.cs
Normal 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;
|
||||
}
|
||||
46
LANCommander.Packager/Models/WizardStepItem.cs
Normal file
46
LANCommander.Packager/Models/WizardStepItem.cs
Normal 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;
|
||||
}
|
||||
12
LANCommander.Packager/Options.cs
Normal file
12
LANCommander.Packager/Options.cs
Normal 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; }
|
||||
}
|
||||
32
LANCommander.Packager/Program.cs
Normal file
32
LANCommander.Packager/Program.cs
Normal 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();
|
||||
}
|
||||
400
LANCommander.Packager/Services/InstallerMonitorService.cs
Normal file
400
LANCommander.Packager/Services/InstallerMonitorService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
118
LANCommander.Packager/Services/LcxBuilderService.cs
Normal file
118
LANCommander.Packager/Services/LcxBuilderService.cs
Normal 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!");
|
||||
}
|
||||
}
|
||||
127
LANCommander.Packager/Services/ScriptGeneratorService.cs
Normal file
127
LANCommander.Packager/Services/ScriptGeneratorService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
144
LANCommander.Packager/Theme/ColorPalette.axaml
Normal file
144
LANCommander.Packager/Theme/ColorPalette.axaml
Normal 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>
|
||||
85
LANCommander.Packager/Theme/Packager.axaml
Normal file
85
LANCommander.Packager/Theme/Packager.axaml
Normal 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>
|
||||
26
LANCommander.Packager/Views/ActionView.axaml
Normal file
26
LANCommander.Packager/Views/ActionView.axaml
Normal 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>
|
||||
85
LANCommander.Packager/Views/ActionView.axaml.cs
Normal file
85
LANCommander.Packager/Views/ActionView.axaml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
41
LANCommander.Packager/Views/FileSelectionView.axaml
Normal file
41
LANCommander.Packager/Views/FileSelectionView.axaml
Normal 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>
|
||||
74
LANCommander.Packager/Views/FileSelectionView.axaml.cs
Normal file
74
LANCommander.Packager/Views/FileSelectionView.axaml.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
12
LANCommander.Packager/Views/InstallDirectoryView.axaml
Normal file
12
LANCommander.Packager/Views/InstallDirectoryView.axaml
Normal 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>
|
||||
50
LANCommander.Packager/Views/InstallDirectoryView.axaml.cs
Normal file
50
LANCommander.Packager/Views/InstallDirectoryView.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
41
LANCommander.Packager/Views/MetadataView.axaml
Normal file
41
LANCommander.Packager/Views/MetadataView.axaml
Normal 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>
|
||||
36
LANCommander.Packager/Views/MetadataView.axaml.cs
Normal file
36
LANCommander.Packager/Views/MetadataView.axaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
LANCommander.Packager/Views/MonitoringView.axaml
Normal file
31
LANCommander.Packager/Views/MonitoringView.axaml
Normal 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>
|
||||
157
LANCommander.Packager/Views/MonitoringView.axaml.cs
Normal file
157
LANCommander.Packager/Views/MonitoringView.axaml.cs
Normal 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;
|
||||
}
|
||||
60
LANCommander.Packager/Views/OutputView.axaml
Normal file
60
LANCommander.Packager/Views/OutputView.axaml
Normal 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>
|
||||
190
LANCommander.Packager/Views/OutputView.axaml.cs
Normal file
190
LANCommander.Packager/Views/OutputView.axaml.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
51
LANCommander.Packager/Views/RegistrySelectionView.axaml
Normal file
51
LANCommander.Packager/Views/RegistrySelectionView.axaml
Normal 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>
|
||||
46
LANCommander.Packager/Views/RegistrySelectionView.axaml.cs
Normal file
46
LANCommander.Packager/Views/RegistrySelectionView.axaml.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
10
LANCommander.Packager/app.manifest
Normal file
10
LANCommander.Packager/app.manifest
Normal 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>
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue